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:
ccbikai 2025-05-18 19:06:28 +08:00
parent f05393cc2e
commit 0dd263c354
23 changed files with 583 additions and 130 deletions

View file

@ -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);

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -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)

View file

@ -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})`)
}

View file

@ -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)

View file

@ -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: {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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': {

View file

@ -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",

View file

@ -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: {}

View file

@ -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`)),
}
})
}

View file

@ -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',
}