feat: enhance realtime dashboard with time filters
Implements comprehensive time filtering and visualization improvements: - Adds time range picker with presets from 5min to 24h - Enhances chart visualization with minute-level granularity - Updates globe visualization to respond to time/filter changes - Implements animated event log display - Swaps primary/secondary color scheme for better contrast Improves realtime data handling with automatic updates and proper cleanup on unmount. Includes i18n support for new time picker UI across all supported languages.
This commit is contained in:
parent
f05393cc2e
commit
0dd263c354
23 changed files with 583 additions and 130 deletions
|
|
@ -42,8 +42,8 @@
|
|||
--vis-tooltip-backdrop-filter: none !important;
|
||||
--vis-tooltip-padding: none !important;
|
||||
|
||||
--vis-primary-color: 198 93% 60%;
|
||||
--vis-secondary-color: 158 64% 52%;
|
||||
--vis-primary-color: 158 64% 52%;
|
||||
--vis-secondary-color: 198 93% 60%;
|
||||
/* --vis-secondary-color: 160 81% 40%; */
|
||||
/* --vis-secondary-color: var(--primary); */
|
||||
--vis-text-color: var(--muted-foreground);
|
||||
|
|
|
|||
94
app/components/dashboard/TimePicker.vue
Normal file
94
app/components/dashboard/TimePicker.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<script setup>
|
||||
import { now } from '@internationalized/date'
|
||||
import { useUrlSearchParams } from '@vueuse/core'
|
||||
|
||||
const emit = defineEmits(['update:timeRange'])
|
||||
|
||||
const timeRange = ref('last-1h')
|
||||
|
||||
watch(timeRange, (newValue) => {
|
||||
switch (newValue) {
|
||||
case 'today':
|
||||
emit('update:timeRange', [date2unix(now(), 'start'), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-5m':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ minutes: 5 })), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-10m':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ minutes: 10 })), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-30m':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ minutes: 30 })), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-1h':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ hours: 1 })), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-6h':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ hours: 6 })), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-12h':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ hours: 12 })), date2unix(now())], newValue)
|
||||
break
|
||||
case 'last-24h':
|
||||
emit('update:timeRange', [date2unix(now().subtract({ hours: 24 })), date2unix(now())], newValue)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
function restoreTimeRange() {
|
||||
try {
|
||||
const searchParams = useUrlSearchParams('history')
|
||||
if (searchParams.time) {
|
||||
timeRange.value = searchParams.time
|
||||
triggerRef(timeRange)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('restore searchParams error', error)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
restoreTimeRange,
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
restoreTimeRange()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="timeRange">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">
|
||||
{{ $t('dashboard.time_picker.today') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-5m">
|
||||
{{ $t('dashboard.time_picker.last_5m') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-10m">
|
||||
{{ $t('dashboard.time_picker.last_10m') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-30m">
|
||||
{{ $t('dashboard.time_picker.last_30m') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-1h">
|
||||
{{ $t('dashboard.time_picker.last_1h') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-6h">
|
||||
{{ $t('dashboard.time_picker.last_6h') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-12h">
|
||||
{{ $t('dashboard.time_picker.last_12h') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="last-24h">
|
||||
{{ $t('dashboard.time_picker.last_24h') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
|
@ -2,15 +2,30 @@
|
|||
import { AreaChart } from '@/components/ui/chart-area'
|
||||
import { BarChart } from '@/components/ui/chart-bar'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'full',
|
||||
},
|
||||
chartType: {
|
||||
type: String,
|
||||
default: 'area',
|
||||
},
|
||||
})
|
||||
|
||||
const views = ref([])
|
||||
const chart = computed(() => views.value.length > 1 ? AreaChart : BarChart)
|
||||
const chart = computed(() => (props.chartType === 'area' && views.value.length > 1) ? AreaChart : BarChart)
|
||||
|
||||
const id = inject('id')
|
||||
const time = inject('time')
|
||||
const filters = inject('filters')
|
||||
|
||||
const OneHour = 60 * 60 // 1 hour in seconds
|
||||
const OneDay = 24 * 60 * 60 // 1 day in seconds
|
||||
function getUnit(startAt, endAt) {
|
||||
if (startAt && endAt && endAt - startAt <= OneHour)
|
||||
return 'minute'
|
||||
|
||||
if (startAt && endAt && endAt - startAt <= OneDay)
|
||||
return 'hour'
|
||||
|
||||
|
|
@ -61,16 +76,20 @@ function formatTime(tick) {
|
|||
|
||||
<template>
|
||||
<Card class="px-0 py-6 md:px-6">
|
||||
<CardTitle class="px-6 md:px-0">
|
||||
<CardTitle v-if="mode === 'full'" class="px-6 md:px-0">
|
||||
{{ $t('dashboard.views') }}
|
||||
</CardTitle>
|
||||
<component
|
||||
:is="chart"
|
||||
:data="views"
|
||||
v-if="views.length"
|
||||
class="w-full h-full"
|
||||
index="time"
|
||||
:categories="['visitors', 'visits']"
|
||||
:data="views"
|
||||
:categories="mode === 'full' ? ['visits', 'visitors'] : ['visits']"
|
||||
:x-formatter="formatTime"
|
||||
:y-formatter="formatNumber"
|
||||
:show-grid-line="mode === 'full'"
|
||||
:show-legend="mode === 'full'"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
56
app/components/dashboard/realtime/Chart.vue
Normal file
56
app/components/dashboard/realtime/Chart.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script setup>
|
||||
import NumberFlow from '@number-flow/vue'
|
||||
import { MousePointerClick } from 'lucide-vue-next'
|
||||
|
||||
provide('id', ref())
|
||||
|
||||
const time = inject('time')
|
||||
const filters = inject('filters')
|
||||
const stats = ref({ visits: 0 })
|
||||
|
||||
async function getRealtimeStats() {
|
||||
const { data } = await useAPI('/api/stats/counters', {
|
||||
query: {
|
||||
startAt: time.value.startAt,
|
||||
endAt: time.value.endAt,
|
||||
...filters.value,
|
||||
},
|
||||
})
|
||||
|
||||
stats.value = data?.[0] || {}
|
||||
}
|
||||
|
||||
const stopWatchQueryChange = watch([time, filters], getRealtimeStats, {
|
||||
deep: true,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getRealtimeStats()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopWatchQueryChange()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="md:w-80 h-72 flex flex-col p-4 m-2">
|
||||
<div class="h-24">
|
||||
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0 px-0 pt-2">
|
||||
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
||||
<span class="size-1.5 inline-flex animate-ping rounded-full bg-green-400 opacity-75" />
|
||||
{{ $t('dashboard.visits') }}
|
||||
</CardTitle>
|
||||
<MousePointerClick class="w-4 h-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent class="px-0 pb-4">
|
||||
<NumberFlow class="text-2xl font-bold" :class="{ 'blur-md opacity-60': !stats.visits }" :value="stats.visits" />
|
||||
</CardContent>
|
||||
</div>
|
||||
<DashboardAnalysisViews
|
||||
class="w-full h-40 border-none !p-0"
|
||||
mode="simple"
|
||||
chart-type="bar"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -13,6 +13,9 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const time = inject('time')
|
||||
const filters = inject('filters')
|
||||
|
||||
const countries = ref({})
|
||||
const locations = ref([])
|
||||
|
||||
|
|
@ -39,7 +42,9 @@ async function getGlobeJSON() {
|
|||
async function getLiveLocations() {
|
||||
const { data } = await useAPI('/api/logs/locations', {
|
||||
query: {
|
||||
startAt: Math.floor(Date.now() / 1000) - 60 * props.minutes,
|
||||
startAt: time.value.startAt,
|
||||
endAt: time.value.endAt,
|
||||
...filters.value,
|
||||
},
|
||||
})
|
||||
locations.value = data?.map(e => ({
|
||||
|
|
@ -59,7 +64,7 @@ function initGlobe() {
|
|||
// .globeOffset([width.value > 768 ? -100 : 0, width.value > 768 ? 0 : 100])
|
||||
.atmosphereColor('rgba(170, 170, 200, 1)')
|
||||
.globeMaterial(new MeshPhongMaterial({
|
||||
color: 'hsl(220.9, 30.3%, 16%)',
|
||||
color: 'rgb(228, 228, 231)',
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
}))
|
||||
|
|
@ -99,6 +104,10 @@ function stopRotation() {
|
|||
}
|
||||
}
|
||||
|
||||
const stopWatchQueryChange = watch([time, filters], getLiveLocations, {
|
||||
deep: true,
|
||||
})
|
||||
|
||||
watch(width, () => {
|
||||
if (globe) {
|
||||
globe.width(size.value.width)
|
||||
|
|
@ -106,10 +115,20 @@ watch(width, () => {
|
|||
}
|
||||
})
|
||||
|
||||
watch(locations, () => {
|
||||
if (globe) {
|
||||
globe.hexBinPointsData(locations.value)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([getGlobeJSON(), getLiveLocations()])
|
||||
initGlobe()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopWatchQueryChange()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,70 @@
|
|||
<script setup>
|
||||
import { now } from '@internationalized/date'
|
||||
import { useIntervalFn, useUrlSearchParams } from '@vueuse/core'
|
||||
import { safeDestr } from 'destr'
|
||||
|
||||
const searchParams = useUrlSearchParams('history')
|
||||
|
||||
const timePicker = ref(null)
|
||||
|
||||
const time = ref({
|
||||
startAt: date2unix(now().subtract({ hours: 1 })),
|
||||
endAt: date2unix(now()),
|
||||
})
|
||||
|
||||
provide('time', time)
|
||||
|
||||
function changeTime(timeRange, timeName) {
|
||||
console.log('changeTime', timeRange, timeName)
|
||||
time.value.startAt = timeRange[0]
|
||||
time.value.endAt = timeRange[1]
|
||||
|
||||
searchParams.time = timeName
|
||||
}
|
||||
|
||||
const filters = ref({})
|
||||
|
||||
provide('filters', filters)
|
||||
|
||||
function changeFilter(type, value) {
|
||||
console.log('changeFilter', type, value)
|
||||
filters.value[type] = value
|
||||
|
||||
searchParams.filters = JSON.stringify(filters.value)
|
||||
}
|
||||
|
||||
useIntervalFn(() => {
|
||||
timePicker.value?.restoreTimeRange()
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
function restoreSearchParams() {
|
||||
try {
|
||||
if (searchParams.filters) {
|
||||
filters.value = safeDestr(searchParams.filters)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('restore searchParams error', error)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
restoreSearchParams()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="space-y-6">
|
||||
<div class="flex flex-col gap-6 sm:gap-2 sm:flex-row sm:justify-between">
|
||||
<DashboardNav class="flex-1">
|
||||
Mode & TimeRange
|
||||
<DashboardTimePicker ref="timePicker" @update:time-range="changeTime" />
|
||||
</DashboardNav>
|
||||
<!-- add filter -->
|
||||
<DashboardFilters @change="changeFilter" />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<DashboardRealtimeChart class="md:absolute top-0 left-0 z-10" />
|
||||
<DashboardRealtimeGlobe />
|
||||
<DashboardRealtimeLogs class="md:absolute top-0 right-0 h-full z-10" />
|
||||
</div>
|
||||
<DashboardRealtimeGlobe />
|
||||
<!-- <DashboardRealtimeLogs /> -->
|
||||
</main>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,111 +1,49 @@
|
|||
<script setup>
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
useVueTable,
|
||||
} from '@tanstack/vue-table'
|
||||
import AnimatedList from '@/components/spark-ui/AnimatedList.vue'
|
||||
import Notification from '@/components/spark-ui/Notification.vue'
|
||||
|
||||
// const time = inject('time')
|
||||
// const filters = inject('filters')
|
||||
const time = inject('time')
|
||||
const filters = inject('filters')
|
||||
const logs = ref([])
|
||||
const logskey = ref(0)
|
||||
|
||||
async function getEvents() {
|
||||
const data = await useAPI('/api/logs/events', {
|
||||
query: {
|
||||
startAt: 1746945961,
|
||||
startAt: time.value.startAt,
|
||||
endAt: time.value.endAt,
|
||||
...filters.value,
|
||||
},
|
||||
})
|
||||
console.log(data)
|
||||
logs.value = data
|
||||
logs.value = data?.reverse()
|
||||
logskey.value = Date.now()
|
||||
}
|
||||
|
||||
// const stopWatchQueryChange = watch([time, filters], getLinkViews, {
|
||||
// deep: true,
|
||||
// })
|
||||
const stopWatchQueryChange = watch([time, filters], getEvents, {
|
||||
deep: true,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getEvents()
|
||||
})
|
||||
|
||||
// onBeforeUnmount(() => {
|
||||
// stopWatchQueryChange()
|
||||
// })
|
||||
|
||||
const columns = [
|
||||
{
|
||||
accessorKey: 'slug',
|
||||
header: () => h('div', { class: 'text-left' }, 'Slug'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-left font-medium' }, row.original.slug)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: () => h('div', { class: 'text-left' }, 'URL'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-left' }, row.original.url)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
header: () => h('div', { class: 'text-left' }, 'IP'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-left' }, row.original.ip)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'referer',
|
||||
header: () => h('div', { class: 'text-left' }, 'Referer'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-left' }, row.original.referer)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: () => h('div', { class: 'text-left' }, 'Country'),
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'text-left' }, row.original.country)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const table = useVueTable({
|
||||
get data() { return logs.value },
|
||||
get columns() { return columns },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onBeforeUnmount(() => {
|
||||
stopWatchQueryChange()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<TableRow
|
||||
v-for="row in table.getRowModel().rows" :key="row.id"
|
||||
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||
>
|
||||
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<AnimatedList v-if="logs.length" :key="logskey" class="md:w-72">
|
||||
<template #default>
|
||||
<Notification
|
||||
v-for="item in logs"
|
||||
:key="item.id"
|
||||
:name="item.slug"
|
||||
:description="[item.os, item.browser].filter(Boolean).join(' ')"
|
||||
:icon="getFlag(item.country)"
|
||||
:time="item.timestamp"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</AnimatedList>
|
||||
</template>
|
||||
|
|
|
|||
93
app/components/spark-ui/AnimatedList.vue
Normal file
93
app/components/spark-ui/AnimatedList.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils'
|
||||
import { computed, onMounted, ref, useSlots } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: string
|
||||
delay?: number
|
||||
}>(), {
|
||||
delay: 1000,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const index = ref(0)
|
||||
const slotsArray = ref<any>([])
|
||||
const maxShowItems = 100
|
||||
|
||||
const itemsToShow = computed(() => {
|
||||
const start = index.value - maxShowItems < 0 ? 0 : index.value - maxShowItems
|
||||
return slotsArray.value.slice(start, index.value)
|
||||
})
|
||||
|
||||
async function loadComponents() {
|
||||
slotsArray.value = slots.default ? slots.default()?.[0]?.children : []
|
||||
|
||||
while (index.value < slotsArray.value.length) {
|
||||
index.value++
|
||||
await delay(props.delay)
|
||||
}
|
||||
}
|
||||
|
||||
async function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getInitial(idx: number) {
|
||||
return idx === index.value - 1
|
||||
? {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
function getEnter(idx: number) {
|
||||
return idx === index.value - 1
|
||||
? {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 250,
|
||||
damping: 40,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getLeave() {
|
||||
return {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 350,
|
||||
damping: 40,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => loadComponents())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('overflow-auto', $props.class)">
|
||||
<transition-group name="list" tag="div" class="flex flex-col-reverse items-center p-2" move-class="move">
|
||||
<div
|
||||
v-for="(item, idx) in itemsToShow"
|
||||
:key="item.props.key"
|
||||
v-motion :initial="getInitial(idx)" :enter="getEnter(idx)" :leave="getLeave()"
|
||||
:class="cn('mx-auto w-full')"
|
||||
>
|
||||
<component :is="item" />
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.move {
|
||||
transition: transform 0.4s ease-out;
|
||||
}
|
||||
</style>
|
||||
45
app/components/spark-ui/Notification.vue
Normal file
45
app/components/spark-ui/Notification.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script setup lang='ts'>
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
class?: string
|
||||
description: string
|
||||
icon?: string
|
||||
color?: string
|
||||
time: number
|
||||
}>()
|
||||
|
||||
const className = cn(
|
||||
'relative mx-auto min-h-fit w-full cursor-pointer border rounded-2xl my-1',
|
||||
// animation styles
|
||||
'transition-all duration-200 ease-in-out hover:scale-[103%] transform-gpu',
|
||||
// light styles
|
||||
'bg-white',
|
||||
// dark styles
|
||||
'dark:bg-transparent dark:backdrop-blur-md dark:[border:1px_solid_rgba(255,255,255,.1)] dark:[box-shadow:0_-20px_80px_-20px_#ffffff1f_inset]',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure :class="className">
|
||||
<div class="flex flex-row py-2 items-center px-2 gap-2">
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-2xl"
|
||||
:style="{ backgroundColor: props.color || 'transparent' }"
|
||||
>
|
||||
<span class="text-lg">{{ props.icon }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<div class="flex flex-row items-center whitespace-pre text-lg font-medium ">
|
||||
<span class="text-sm text-foreground sm:text-lg">{{ props.name }}</span>
|
||||
<span class="mx-1">·</span>
|
||||
<span class="text-xs text-gray-500">{{ shortTime(props.time) }}</span>
|
||||
</div>
|
||||
<p class="text-sm font-normal">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</template>
|
||||
|
|
@ -25,7 +25,7 @@ function template(d: any) {
|
|||
const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
|
||||
const legendReference = props.items.find(i => i.name === key)
|
||||
return { ...legendReference, value }
|
||||
})
|
||||
}).filter(i => i.name)
|
||||
const TooltipComponent = props.customTooltip ?? ChartTooltip
|
||||
createApp(TooltipComponent, { title: d[props.index].toString(), data: omittedData }).mount(componentDiv)
|
||||
wm.set(d, componentDiv.innerHTML)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export function colorGradation(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => `hsl(var(--vis-secondary-color) / ${1 - (1 / count) * i})`)
|
||||
return Array.from({ length: count }, (_, i) => `hsl(var(--vis-primary-color) / ${1 - (1 / count) * i})`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,14 @@ export function longDate(unix = 0) {
|
|||
return new Date(unix * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export function date2unix(dateValue: DateValue | Date, type: string) {
|
||||
export function shortTime(unix = 0) {
|
||||
const shortTime = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
return shortTime.format(unix * 1000)
|
||||
}
|
||||
|
||||
export function date2unix(dateValue: DateValue | Date, type?: string) {
|
||||
const date = dateValue instanceof Date ? dateValue : dateValue.toDate(getTimeZone())
|
||||
if (type === 'start')
|
||||
return Math.floor(date.setHours(0, 0, 0) / 1000)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import withNuxt from './.nuxt/eslint.config.mjs'
|
|||
export default withNuxt(
|
||||
antfu(),
|
||||
{
|
||||
ignores: ['app/components/ui', '.data', 'public/world.json'],
|
||||
ignores: ['app/components/ui', '.data', 'public/*.json'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@
|
|||
"os": "OS",
|
||||
"browser": "Browser",
|
||||
"browserType": "Browser Type"
|
||||
},
|
||||
"time_picker": {
|
||||
"today": "Today",
|
||||
"last_5m": "Last 5 minutes",
|
||||
"last_10m": "Last 10 minutes",
|
||||
"last_30m": "Last 30 minutes",
|
||||
"last_1h": "Last 1 hour",
|
||||
"last_6h": "Last 6 hours",
|
||||
"last_12h": "Last 12 hours",
|
||||
"last_24h": "Last 24 hours"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@
|
|||
"os": "OS",
|
||||
"browser": "Navigateur",
|
||||
"browserType": "Type de navigateur"
|
||||
},
|
||||
"time_picker": {
|
||||
"today": "Aujourd'hui",
|
||||
"last_5m": "5 minutes",
|
||||
"last_10m": "10 minutes",
|
||||
"last_30m": "30 minutes",
|
||||
"last_1h": "1 heure",
|
||||
"last_6h": "6 heures",
|
||||
"last_12h": "12 heures",
|
||||
"last_24h": "24 heures"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@
|
|||
"os": "Hệ điều hành",
|
||||
"browser": "Trình duyệt",
|
||||
"browserType": "Loại trình duyệt"
|
||||
},
|
||||
"time_picker": {
|
||||
"today": "Hôm nay",
|
||||
"last_5m": "5 phút qua",
|
||||
"last_10m": "10 phút qua",
|
||||
"last_30m": "30 phút qua",
|
||||
"last_1h": "1 giờ qua",
|
||||
"last_6h": "6 giờ qua",
|
||||
"last_12h": "12 giờ qua",
|
||||
"last_24h": "24 giờ qua"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@
|
|||
"os": "操作系统",
|
||||
"browser": "浏览器",
|
||||
"browserType": "浏览器类型"
|
||||
},
|
||||
"time_picker": {
|
||||
"today": "今天",
|
||||
"last_5m": "最近5分钟",
|
||||
"last_10m": "最近10分钟",
|
||||
"last_30m": "最近30分钟",
|
||||
"last_1h": "最近1小时",
|
||||
"last_6h": "最近6小时",
|
||||
"last_12h": "最近12小时",
|
||||
"last_24h": "最近24小时"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@
|
|||
"os": "操作系統",
|
||||
"browser": "瀏覽器",
|
||||
"browserType": "瀏覽器類型"
|
||||
},
|
||||
"time_picker": {
|
||||
"today": "今天",
|
||||
"last_5m": "最近5分鐘",
|
||||
"last_10m": "最近10分鐘",
|
||||
"last_30m": "最近30分鐘",
|
||||
"last_1h": "最近1小時",
|
||||
"last_6h": "最近6小時",
|
||||
"last_12h": "最近12小時",
|
||||
"last_24h": "最近24小時"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export default defineNuxtConfig({
|
|||
'shadcn-nuxt',
|
||||
'@nuxt/eslint',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@vueuse/motion/nuxt',
|
||||
'@nuxtjs/color-mode',
|
||||
'@nuxtjs/i18n',
|
||||
],
|
||||
|
|
@ -41,8 +42,10 @@ export default defineNuxtConfig({
|
|||
routeRules: {
|
||||
'/': {
|
||||
prerender: true,
|
||||
ssr: false,
|
||||
},
|
||||
'/dashboard/**': {
|
||||
prerender: true,
|
||||
ssr: false,
|
||||
},
|
||||
'/dashboard': {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@
|
|||
"dependencies": {
|
||||
"@intlify/message-compiler": "^11.1.3",
|
||||
"@number-flow/vue": "^0.3.3",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unovis/ts": "^1.5.0",
|
||||
"@unovis/vue": "^1.5.0",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^12.2.0",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
|
|
|
|||
109
pnpm-lock.yaml
109
pnpm-lock.yaml
|
|
@ -14,9 +14,6 @@ importers:
|
|||
'@number-flow/vue':
|
||||
specifier: ^0.3.3
|
||||
version: 0.3.3(vue@3.5.13(typescript@5.7.2))
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.13(typescript@5.7.2))
|
||||
'@unovis/ts':
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0
|
||||
|
|
@ -29,6 +26,9 @@ importers:
|
|||
'@vueuse/core':
|
||||
specifier: ^12.2.0
|
||||
version: 12.2.0(typescript@5.7.2)
|
||||
'@vueuse/motion':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3(magicast@0.3.5)(vue@3.5.13(typescript@5.7.2))
|
||||
d3-scale:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
|
|
@ -2114,22 +2114,12 @@ packages:
|
|||
'@swc/helpers@0.5.11':
|
||||
resolution: {integrity: sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==}
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.2':
|
||||
resolution: {integrity: sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==}
|
||||
|
||||
'@tanstack/virtual-core@3.8.1':
|
||||
resolution: {integrity: sha512-uNtAwenT276M9QYCjTBoHZ8X3MUeCRoGK59zPi92hMIxdfS9AyHjkDWJ94WroDxnv48UE+hIeo21BU84jKc8aQ==}
|
||||
|
||||
'@tanstack/vue-table@8.21.3':
|
||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.2':
|
||||
resolution: {integrity: sha512-z4swzjdhzCh95n9dw9lTvw+t3iwSkYRlVkYkra3C9mul/m5fTzHR7KmtkwH4qXMTXGJUbngtC/bz2cHQIHkO8g==}
|
||||
peerDependencies:
|
||||
|
|
@ -2354,6 +2344,9 @@ packages:
|
|||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.18.2':
|
||||
resolution: {integrity: sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -2622,6 +2615,11 @@ packages:
|
|||
'@vueuse/core@12.7.0':
|
||||
resolution: {integrity: sha512-jtK5B7YjZXmkGNHjviyGO4s3ZtEhbzSgrbX+s5o+Lr8i2nYqNyHuPVOeTdM1/hZ5Tkxg/KktAuAVDDiHMraMVA==}
|
||||
|
||||
'@vueuse/core@13.2.0':
|
||||
resolution: {integrity: sha512-n5TZoIAxbWAQ3PqdVPDzLgIRQOujFfMlatdI+f7ditSmoEeNpPBvp7h2zamzikCmrhFIePAwdEQB6ENccHr7Rg==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/integrations@12.2.0':
|
||||
resolution: {integrity: sha512-Bc0unXiGNZ0w7xqSvzCuP7AflBRKcZX6ib7tGi7vAjOxhLd6GtN3J8qizIbSPnI62XyPy5fauOkq2i2HUPq6MQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2672,6 +2670,14 @@ packages:
|
|||
'@vueuse/metadata@12.7.0':
|
||||
resolution: {integrity: sha512-4VvTH9mrjXqFN5LYa5YfqHVRI6j7R00Vy4995Rw7PQxyCL3z0Lli86iN4UemWqixxEvYfRjG+hF9wL8oLOn+3g==}
|
||||
|
||||
'@vueuse/metadata@13.2.0':
|
||||
resolution: {integrity: sha512-kPpzuQCU0+D8DZCzK0iPpIcXI+6ufWSgwnjJ6//GNpEn+SHViaCtR+XurzORChSgvpHO9YC8gGM97Y1kB+UabA==}
|
||||
|
||||
'@vueuse/motion@3.0.3':
|
||||
resolution: {integrity: sha512-4B+ITsxCI9cojikvrpaJcLXyq0spj3sdlzXjzesWdMRd99hhtFI6OJ/1JsqwtF73YooLe0hUn/xDR6qCtmn5GQ==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.0'
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
|
|
@ -2681,6 +2687,11 @@ packages:
|
|||
'@vueuse/shared@12.7.0':
|
||||
resolution: {integrity: sha512-coLlUw2HHKsm7rPN6WqHJQr18WymN4wkA/3ThFaJ4v4gWGWAQQGK+MJxLuJTBs4mojQiazlVWAKNJNpUWGRkNw==}
|
||||
|
||||
'@vueuse/shared@13.2.0':
|
||||
resolution: {integrity: sha512-vx9ZPDF5HcU9up3Jgt3G62dMUfZEdk6tLyBAHYAG4F4n73vpaA7J5hdncDI/lS9Vm7GA/FPlbOmh9TrDZROTpg==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
|
|
@ -4097,6 +4108,9 @@ packages:
|
|||
frame-ticker@1.0.3:
|
||||
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
|
||||
|
||||
framesync@6.1.2:
|
||||
resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==}
|
||||
|
||||
fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -4316,6 +4330,9 @@ packages:
|
|||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hey-listen@1.0.8:
|
||||
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
|
|
@ -5447,6 +5464,9 @@ packages:
|
|||
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
popmotion@11.0.5:
|
||||
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
|
||||
|
||||
portfinder@1.0.32:
|
||||
resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==}
|
||||
engines: {node: '>= 0.12.0'}
|
||||
|
|
@ -6146,6 +6166,9 @@ packages:
|
|||
striptags@3.2.0:
|
||||
resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==}
|
||||
|
||||
style-value-types@5.1.2:
|
||||
resolution: {integrity: sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q==}
|
||||
|
||||
stylehacks@7.0.4:
|
||||
resolution: {integrity: sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==}
|
||||
engines: {node: ^18.12.0 || ^20.9.0 || >=22.0}
|
||||
|
|
@ -6349,6 +6372,9 @@ packages:
|
|||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
tslib@2.4.0:
|
||||
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
|
||||
|
||||
tslib@2.6.3:
|
||||
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
|
||||
|
||||
|
|
@ -9026,17 +9052,10 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.6.3
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.2': {}
|
||||
|
||||
'@tanstack/virtual-core@3.8.1': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.3(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.2(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.2
|
||||
|
|
@ -9299,6 +9318,8 @@ snapshots:
|
|||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.18.2(@typescript-eslint/parser@8.18.2(eslint@9.17.0(jiti@2.4.2))(typescript@5.7.2))(eslint@9.17.0(jiti@2.4.2))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.11.0
|
||||
|
|
@ -9764,6 +9785,13 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/core@13.2.0(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 13.2.0
|
||||
'@vueuse/shared': 13.2.0(vue@3.5.13(typescript@5.7.2))
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
|
||||
'@vueuse/integrations@12.2.0(fuse.js@7.0.0)(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@vueuse/core': 12.2.0(typescript@5.7.2)
|
||||
|
|
@ -9780,6 +9808,23 @@ snapshots:
|
|||
|
||||
'@vueuse/metadata@12.7.0': {}
|
||||
|
||||
'@vueuse/metadata@13.2.0': {}
|
||||
|
||||
'@vueuse/motion@3.0.3(magicast@0.3.5)(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
'@vueuse/core': 13.2.0(vue@3.5.13(typescript@5.7.2))
|
||||
'@vueuse/shared': 13.2.0(vue@3.5.13(typescript@5.7.2))
|
||||
defu: 6.1.4
|
||||
framesync: 6.1.2
|
||||
popmotion: 11.0.5
|
||||
style-value-types: 5.1.2
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
optionalDependencies:
|
||||
'@nuxt/kit': 3.15.4(magicast@0.3.5)
|
||||
transitivePeerDependencies:
|
||||
- magicast
|
||||
- supports-color
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.2))
|
||||
|
|
@ -9799,6 +9844,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/shared@13.2.0(vue@3.5.13(typescript@5.7.2))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.2)
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
|
|
@ -11387,6 +11436,10 @@ snapshots:
|
|||
dependencies:
|
||||
simplesignal: 2.1.7
|
||||
|
||||
framesync@6.1.2:
|
||||
dependencies:
|
||||
tslib: 2.4.0
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
fs-extra@11.2.0:
|
||||
|
|
@ -11644,6 +11697,8 @@ snapshots:
|
|||
|
||||
he@1.2.0: {}
|
||||
|
||||
hey-listen@1.0.8: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
hosted-git-info@2.8.9: {}
|
||||
|
|
@ -13155,6 +13210,13 @@ snapshots:
|
|||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
|
||||
popmotion@11.0.5:
|
||||
dependencies:
|
||||
framesync: 6.1.2
|
||||
hey-listen: 1.0.8
|
||||
style-value-types: 5.1.2
|
||||
tslib: 2.4.0
|
||||
|
||||
portfinder@1.0.32:
|
||||
dependencies:
|
||||
async: 2.6.4
|
||||
|
|
@ -13898,6 +13960,11 @@ snapshots:
|
|||
|
||||
striptags@3.2.0: {}
|
||||
|
||||
style-value-types@5.1.2:
|
||||
dependencies:
|
||||
hey-listen: 1.0.8
|
||||
tslib: 2.4.0
|
||||
|
||||
stylehacks@7.0.4(postcss@8.4.49):
|
||||
dependencies:
|
||||
browserslist: 4.24.3
|
||||
|
|
@ -14152,6 +14219,8 @@ snapshots:
|
|||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tslib@2.4.0: {}
|
||||
|
||||
tslib@2.6.3: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { H3Event } from 'h3'
|
||||
import { QuerySchema } from '@@/schemas/query'
|
||||
import { date2unix } from '~/utils/time'
|
||||
|
||||
const { select } = SqlBricks
|
||||
|
||||
function query2sql(query: Query, event: H3Event): string {
|
||||
const filter = query2filter(query)
|
||||
const { dataset } = useRuntimeConfig(event)
|
||||
const sql = select(`*`).from(dataset).where(filter)
|
||||
const sql = select(`*`).from(dataset).where(filter).orderBy('timestamp DESC')
|
||||
appendTimeFilter(sql, query)
|
||||
return sql.toString()
|
||||
}
|
||||
|
|
@ -23,8 +24,8 @@ function events2logs(events: WAEEvents[]) {
|
|||
}, [])
|
||||
return {
|
||||
...blobs2logs(blobs),
|
||||
id: event.index1,
|
||||
timestamp: event.timestamp,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: date2unix(new Date(`${event.timestamp}Z`)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { z } from 'zod'
|
|||
const { select } = SqlBricks
|
||||
|
||||
const unitMap: { [x: string]: string } = {
|
||||
minute: '%H:%M',
|
||||
hour: '%Y-%m-%d %H',
|
||||
day: '%Y-%m-%d',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue