feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,9 @@
# Fictional local values only. Never commit a real Access token or audience.
TELEMETRY_ADMIN_MOCK_AUTH=true
TELEMETRY_ADMIN_MOCK_SCENARIO=normal
NUXT_ACCESS_TEAM_DOMAIN=example
NUXT_ACCESS_AUDIENCE=fixture-audience
# Cloudflare GraphQL Analytics API (system page account usage check, local dev only)
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ANALYTICS_TOKEN=

2
apps/telemetry-dashboard/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.vercel
.env*

View File

@ -0,0 +1,5 @@
.nuxt/
.output/
.wrangler/
node_modules/
coverage/

View File

@ -0,0 +1,106 @@
# Axolotl Telemetry Dashboard
Read-only administration UI for the existing `axolotl-telemetry` D1 database and
`axolotl-telemetry-errors` R2 bucket. It does not share routes or code with the public telemetry
collector.
## Local development
Mock authentication is accepted only when `NODE_ENV=development` and
`TELEMETRY_ADMIN_MOCK_AUTH=true` are both present.
Use the production D1 and R2 resources through Wrangler remote bindings while keeping authentication
local to the development server:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_REMOTE_BINDINGS='true'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Remote mode is read-only at the application layer: the Admin API issues only `SELECT` statements
and a single registered-object R2 `GET`. It never exposes Cloudflare credentials to the browser.
For deterministic fixture scenarios, leave `TELEMETRY_ADMIN_REMOTE_BINDINGS` unset:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_MOCK_SCENARIO='normal'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Supported deterministic scenarios are `normal`, `empty`, `api-error`, `no-sample`,
`budget-reached`, `unconfigured-auth`, and `forbidden`. All mock values are synthetic fixtures.
## Production deployment (Cloudflare Workers)
The dashboard runs on the `axolotl-telemetry-dashboard` Worker with the custom domain
`admin.axlmc.org`. It uses the native D1 and R2 bindings declared in `wrangler.toml`; no data
credentials are sent to the browser.
Do not deploy the dashboard until every item below is complete:
1. Create a GitHub OAuth App controlled by the `Axolotl-Launcher` organization. Use
`https://admin.axlmc.org` as its homepage and the callback URL shown by Cloudflare Zero Trust,
normally `https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback`.
2. Add GitHub as a Cloudflare Zero Trust identity provider using that Client ID and Client Secret.
Keep the Client Secret only in GitHub and Cloudflare.
3. Create a self-hosted Access application for `admin.axlmc.org` with an 8-hour session duration.
4. Create one Allow policy with `Include -> GitHub Organization -> Axolotl-Launcher`. Do not add a
bypass policy or a broader include rule.
5. Keep the Worker custom domain `admin.axlmc.org` bound to `axolotl-telemetry-dashboard`. The DNS
records for `axlmc.org` stay proxied through Cloudflare.
Build and deploy from `apps/telemetry-dashboard`:
```powershell
pnpm --filter @axolotl/telemetry-dashboard build
pnpm --filter @axolotl/telemetry-dashboard exec wrangler deploy
```
`nuxi build` uses Nitro's `cloudflare-module` preset by default and emits
`.output/server/index.mjs` plus `.output/public`, exactly what `wrangler.toml` references.
### Automatic deployment
`.github/workflows/deploy-telemetry-dashboard.yml` deploys on every push to `main` that touches
`apps/telemetry-dashboard/**`, and can also be triggered manually. It requires one repository
secret:
- `CLOUDFLARE_API_TOKEN`: an API token with Workers Scripts Edit and Workers Routes Edit
permission for the account that owns `axolotl-telemetry-dashboard`.
Do not add `[skip ci]` to commits that should reach production: GitHub Actions skips all workflows
for such commits, including this deployment.
### Cloudflare account usage check
The system page reports account-wide Workers, D1, and R2 usage through the GraphQL Analytics API.
Enable it once with two Worker secrets; secrets survive CI deploys because they live on the Worker,
not in the repository:
1. Create an API token at `dash.cloudflare.com` → My Profile → API Tokens with the
`Account Analytics: Read` permission for the account that owns the telemetry resources.
2. From `apps/telemetry-dashboard`, run
`pnpm exec wrangler secret put CLOUDFLARE_ANALYTICS_TOKEN` and paste that token.
3. Run `pnpm exec wrangler secret put CLOUDFLARE_ACCOUNT_ID` and paste the 32-character account ID.
The check stays fail-closed: without both values the row reports 尚未配置, and any dataset failure
only degrades the row. The token is used server-side for three read-only GraphQL queries per system
page load (Workers invocations 24h, D1 rows today, R2 operations 30d) and is never sent to the
browser.
### Unused Vercel target
An earlier Vercel project (`axolotl-telemetry-dashboard`) exists, but its `admin.axlmc.org` domain
was never pointed at Vercel and traffic is served by the Cloudflare Worker. The Nitro `vercel`
preset and `server/utils/vercel-data-source.ts` remain as an offline fallback and are not part of
the production path. Do not enable both targets for the same hostname.
Verify an unauthenticated request is intercepted by Cloudflare Access, then verify
`/api/admin/session` reports `dataSource: production` after authentication.
The server fails closed when Access or the remote data source configuration is absent. A production
build ignores the mock provider even if a mock environment variable is present. All Admin API D1
operations are restricted to `SELECT`/`WITH` queries, and R2 reads require an object key previously
registered in D1.

View File

@ -0,0 +1,6 @@
<template>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@ -0,0 +1,162 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 18% 97%;
--foreground: 220 20% 11%;
--card: 0 0% 100%;
--card-foreground: 220 18% 12%;
--popover: 0 0% 100%;
--popover-foreground: 220 18% 12%;
--primary: 164 64% 27%;
--primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%;
--secondary-foreground: 220 16% 18%;
--muted: 210 16% 94%;
--muted-foreground: 215 12% 43%;
--accent: 38 92% 52%;
--accent-foreground: 30 30% 12%;
--destructive: 0 72% 48%;
--destructive-foreground: 0 0% 100%;
--border: 214 16% 86%;
--input: 214 16% 78%;
--ring: 164 64% 27%;
--surface-1: 210 18% 97%;
--surface-2: 0 0% 100%;
--surface-3: 210 17% 94%;
--surface-4: 214 16% 86%;
--surface-5: 214 15% 72%;
--chart-green: #17845f;
--chart-gold: #d19213;
--chart-coral: #d85d4a;
--chart-cyan: #278ba8;
}
.dark {
--background: 220 16% 8%;
--foreground: 210 16% 92%;
--card: 220 15% 12%;
--card-foreground: 210 16% 92%;
--popover: 220 16% 12%;
--popover-foreground: 210 16% 92%;
--primary: 160 54% 48%;
--primary-foreground: 220 18% 8%;
--secondary: 218 14% 17%;
--secondary-foreground: 210 16% 90%;
--muted: 218 14% 16%;
--muted-foreground: 215 12% 64%;
--accent: 40 88% 56%;
--accent-foreground: 30 32% 10%;
--destructive: 0 62% 51%;
--destructive-foreground: 0 0% 100%;
--border: 218 13% 23%;
--input: 218 13% 27%;
--ring: 160 54% 48%;
--surface-1: 220 16% 8%;
--surface-2: 220 15% 11%;
--surface-3: 218 14% 15%;
--surface-4: 218 13% 23%;
--surface-5: 216 12% 34%;
--chart-green: #40c995;
--chart-gold: #e6ad3e;
--chart-coral: #ef7b68;
--chart-cyan: #52b6d0;
}
* {
@apply border-border;
}
html,
body,
#__nuxt {
@apply min-h-full;
}
body {
@apply m-0 overflow-x-hidden bg-surface-1 font-sans text-foreground antialiased;
letter-spacing: 0;
}
button,
input,
select {
letter-spacing: 0;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
.dashboard-page-enter-active,
.dashboard-page-leave-active {
transition:
opacity 160ms ease,
transform 160ms ease;
}
.dashboard-page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.dashboard-page-leave-to {
opacity: 0;
transform: translateY(-2px);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.sheet-overlay[data-state='open'] {
animation: overlay-in 180ms ease-out;
}
.sheet-panel[data-state='open'] {
animation: sheet-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes overlay-in {
from {
opacity: 0;
}
}
@keyframes sheet-in {
from {
transform: translateX(20px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-page-enter-active,
.dashboard-page-leave-active,
.sheet-overlay[data-state='open'],
.sheet-panel[data-state='open'] {
animation: none;
transition: none;
}
}
.unovis-xy-container text {
fill: hsl(var(--muted-foreground));
font-size: 11px;
}
.unovis-xy-container .axis line,
.unovis-xy-container .axis path {
stroke: hsl(var(--surface-4));
}

View File

@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "assets/styles/tailwind.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui"
}
}

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import { Ban, LogIn, RefreshCw, ServerOff } from 'lucide-vue-next'
import { computed } from 'vue'
import Button from '~/components/ui/Button.vue'
const props = defineProps<{
kind: 'error' | 'unauthenticated' | 'forbidden'
compact?: boolean
}>()
const emit = defineEmits<{ retry: [] }>()
const content = computed(() => {
if (props.kind === 'unauthenticated') {
return {
icon: LogIn,
title: '需要登录',
detail: '请使用 Axolotl-Launcher 组织成员账号通过 Cloudflare Access 登录。',
action: '前往登录',
}
}
if (props.kind === 'forbidden') {
return {
icon: Ban,
title: '无权访问',
detail: '当前 GitHub 身份不符合 Cloudflare Access 组织策略。',
action: null,
}
}
return {
icon: ServerOff,
title: '遥测数据暂不可用',
detail: '只读管理 API 未返回可用结果,请稍后重试。',
action: '重新加载',
}
})
</script>
<template>
<div
:class="compact ? 'py-10' : 'min-h-[60vh]'"
class="flex flex-col items-center justify-center px-4 text-center"
:data-state="kind"
>
<div class="flex size-10 items-center justify-center rounded-lg bg-muted">
<component :is="content.icon" class="size-5 text-muted-foreground" />
</div>
<h2 class="mt-4 text-base font-semibold">{{ content.title }}</h2>
<p class="mt-1 max-w-md text-sm text-muted-foreground">{{ content.detail }}</p>
<Button v-if="content.action" class="mt-4" variant="outline" @click="emit('retry')">
<RefreshCw v-if="kind === 'error'" class="size-4" />
<LogIn v-else class="size-4" />
{{ content.action }}
</Button>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Component } from 'vue'
import Card from '~/components/ui/Card.vue'
import { formatNumber } from '~/utils/format'
const props = withDefaults(
defineProps<{
title: string
value: number
detail: string
icon: Component
tone?: 'green' | 'cyan' | 'gold' | 'coral' | 'neutral'
}>(),
{ tone: 'neutral' },
)
const tones = {
green: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',
cyan: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-300',
gold: 'bg-amber-500/12 text-amber-700 dark:text-amber-300',
coral: 'bg-rose-500/12 text-rose-700 dark:text-rose-300',
neutral: 'bg-surface-3 text-muted-foreground',
}
</script>
<template>
<Card class="min-w-0 overflow-hidden">
<div class="flex items-start justify-between gap-3 p-4 pb-3">
<div class="min-w-0">
<p class="truncate text-xs font-medium text-muted-foreground">{{ title }}</p>
<p class="mt-2 text-[28px] font-semibold tabular-nums leading-8">
{{ formatNumber(value) }}
</p>
</div>
<div
class="flex size-9 shrink-0 items-center justify-center rounded-md"
:class="tones[props.tone]"
>
<component :is="icon" class="size-[18px]" />
</div>
</div>
<p
class="truncate border-t border-surface-4 bg-surface-3/45 px-4 py-2 text-[11px] text-muted-foreground"
:title="detail"
>
{{ detail }}
</p>
</Card>
</template>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{ title: string; description: string }>()
</script>
<template>
<header class="mb-6 flex flex-wrap items-end justify-between gap-3">
<div class="min-w-0">
<h1 class="text-[22px] font-semibold leading-8">{{ title }}</h1>
<p class="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{{ description }}</p>
</div>
<slot />
</header>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { DistributionItemDto } from '~/shared/types/telemetry'
import { formatNumber } from '~/utils/format'
const props = defineProps<{ items: DistributionItemDto[]; color: string }>()
const maximum = computed(() => Math.max(1, ...props.items.map((item) => item.value)))
</script>
<template>
<div v-if="items.length" class="h-56 space-y-3.5 overflow-y-auto pr-1">
<div
v-for="item in items"
:key="item.label"
class="group"
:title="`${item.label}: ${formatNumber(item.value)}`"
>
<div class="mb-1 flex items-center justify-between gap-3 text-xs">
<span class="truncate font-medium">{{ item.label }}</span>
<span class="shrink-0 tabular-nums text-muted-foreground">{{
formatNumber(item.value)
}}</span>
</div>
<div class="h-1.5 overflow-hidden rounded-sm bg-surface-3">
<div
class="h-full rounded-sm transition-[width] duration-300"
:style="{ width: `${(item.value / maximum) * 100}%`, backgroundColor: color }"
></div>
</div>
</div>
</div>
<div
v-else
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
data-state="empty"
>
当前 UTC 时间范围内暂无数据
</div>
</template>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
import { computed, ref } from 'vue'
import type { DailyPointDto } from '~/shared/types/telemetry'
import { formatNumber, formatUtcDay } from '~/utils/format'
const props = defineProps<{
data: DailyPointDto[]
series: Array<{
key: 'activeInstallations' | 'newInstallations'
label: string
color: string
}>
}>()
const hovered = ref<number | null>(null)
const available = computed(() =>
props.data.some((point) => props.series.some((series) => Number(point[series.key]) > 0)),
)
const yAccessors = computed(() =>
props.series.map((series) => (point: DailyPointDto) => point[series.key]),
)
const colors = computed(() => props.series.map((series) => series.color))
function move(event: PointerEvent): void {
if (!props.data.length) return
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = Math.min(1, Math.max(0, (event.clientX - box.left) / box.width))
hovered.value = Math.min(props.data.length - 1, Math.round(ratio * (props.data.length - 1)))
}
</script>
<template>
<div class="relative h-64 min-w-0" @pointermove="move" @pointerleave="hovered = null">
<div v-if="available" class="h-56 w-full">
<VisXYContainer :data="data" :margin="{ left: 16, right: 12, top: 12, bottom: 28 }">
<VisLine
:x="(_point: DailyPointDto, index: number) => index"
:y="yAccessors"
:color="colors"
:line-width="2"
/>
<VisAxis
type="x"
:x="(_point: DailyPointDto, index: number) => index"
:tick-format="(value: number) => formatUtcDay(data[Math.round(value)]?.day || '')"
:num-ticks="4"
/>
<VisAxis type="y" :num-ticks="4" :tick-format="(value: number) => formatNumber(value)" />
</VisXYContainer>
</div>
<div
v-else
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
data-state="empty"
>
当前 UTC 时间范围内暂无数据
</div>
<div
class="flex h-8 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
aria-label="图表图例"
>
<span v-for="item in series" :key="item.key" class="inline-flex items-center gap-1.5">
<span class="size-2 rounded-sm" :style="{ backgroundColor: item.color }"></span>
{{ item.label }}
</span>
</div>
<div
v-if="hovered !== null && available"
class="pointer-events-none absolute right-2 top-2 z-10 min-w-40 rounded-md border bg-popover p-2 text-xs text-popover-foreground shadow-md"
role="tooltip"
>
<p class="mb-1 font-medium">{{ data[hovered].day }}UTC</p>
<p
v-for="item in series"
:key="item.key"
class="flex justify-between gap-4 py-0.5 text-muted-foreground"
>
<span>{{ item.label }}</span>
<span class="font-medium tabular-nums text-foreground">{{
formatNumber(data[hovered][item.key])
}}</span>
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import { CircleCheck, CircleMinus, TriangleAlert } from 'lucide-vue-next'
import Badge from '~/components/ui/Badge.vue'
import type { ServiceCheckDto } from '~/shared/types/telemetry'
defineProps<{ name: string; check: ServiceCheckDto }>()
const icons = { available: CircleCheck, degraded: TriangleAlert, unavailable: CircleMinus }
const variants = { available: 'success', degraded: 'warning', unavailable: 'secondary' } as const
</script>
<template>
<div
class="flex flex-wrap items-center gap-3 border-b border-surface-4 px-4 py-3.5 transition-colors last:border-0 hover:bg-surface-3/45"
>
<component
:is="icons[check.status]"
class="size-4 shrink-0"
:class="
check.status === 'available'
? 'text-emerald-600'
: check.status === 'degraded'
? 'text-amber-600'
: 'text-muted-foreground'
"
/>
<div class="min-w-48 flex-1">
<p class="text-sm font-medium">{{ name }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ check.detail }}</p>
</div>
<Badge :variant="variants[check.status]">{{ check.label }}</Badge>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { AlertCircle, CircleCheck, Info } from 'lucide-vue-next'
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{ variant?: 'info' | 'success' | 'destructive'; title: string; class?: string }>(),
{ variant: 'info', class: undefined },
)
const icons = { info: Info, success: CircleCheck, destructive: AlertCircle }
const styles = {
info: 'border-border bg-muted/40',
success: 'border-emerald-500/30 bg-emerald-500/10',
destructive: 'border-red-500/30 bg-red-500/10',
}
</script>
<template>
<div
role="alert"
:class="cn('flex gap-3 rounded-lg border p-3.5 text-sm', styles[variant], props.class)"
>
<component :is="icons[variant]" class="mt-0.5 size-4 shrink-0" />
<div class="min-w-0">
<p class="font-medium">{{ title }}</p>
<div class="mt-0.5 text-muted-foreground"><slot /></div>
</div>
</div>
</template>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
class?: string
}>(),
{ variant: 'secondary', class: undefined },
)
const variants = {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
success: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
warning: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',
destructive: 'bg-red-500/15 text-red-700 dark:text-red-300',
}
</script>
<template>
<span
:class="
cn(
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
variants[variant],
props.class,
)
"
>
<slot />
</span>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive'
size?: 'default' | 'sm' | 'icon'
class?: ButtonHTMLAttributes['class']
}>(),
{ variant: 'default', size: 'default', class: undefined },
)
const variants = {
default: 'bg-primary text-primary-foreground shadow-sm hover:brightness-95 active:brightness-90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
ghost: 'text-foreground hover:bg-surface-3 active:bg-surface-4',
outline:
'border-surface-4 bg-surface-2 text-foreground hover:border-surface-5 hover:bg-surface-3 active:bg-surface-4',
destructive: 'bg-destructive text-destructive-foreground hover:brightness-95',
}
const sizes = {
default: 'h-9 px-3.5',
sm: 'h-8 px-2.5 text-xs',
icon: 'size-9 shrink-0 p-0',
}
</script>
<template>
<button
:data-variant="variant"
:class="
cn(
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,filter] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50',
variants[props.variant],
sizes[props.size],
props.class,
)
"
>
<slot />
</button>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '~/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<section
:class="
cn(
'rounded-lg border border-surface-4 bg-surface-2 text-card-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]',
props.class,
)
"
>
<slot />
</section>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { BarChart3, Search, Server } from 'lucide-vue-next'
import {
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
const open = defineModel<boolean>('open', { required: true })
const query = ref('')
const router = useRouter()
const items = [
{ label: '数据总览', path: '/', icon: BarChart3 },
{ label: '系统状态', path: '/system', icon: Server },
]
const filtered = computed(() =>
items.filter((item) => item.label.toLowerCase().includes(query.value.toLowerCase())),
)
async function select(path: string): Promise<void> {
open.value = false
query.value = ''
await router.push(path)
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
<DialogContent
class="fixed left-1/2 top-[18vh] z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-lg border border-surface-5 bg-popover shadow-2xl outline-none"
>
<DialogTitle class="sr-only">快速跳转</DialogTitle>
<DialogDescription class="sr-only">搜索控制台页面</DialogDescription>
<div class="flex items-center gap-2 border-b px-3">
<Search class="size-4 text-muted-foreground" />
<input
v-model="query"
autofocus
placeholder="输入页面名称..."
class="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div class="p-1.5">
<p v-if="!filtered.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
没有匹配的页面
</p>
<button
v-for="item in filtered"
:key="item.path"
type="button"
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-surface-3 focus:bg-surface-3 focus:outline-none"
@click="select(item.path)"
>
<component :is="item.icon" class="size-4 text-muted-foreground" />
{{ item.label }}
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { Check, ChevronDown, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'reka-ui'
import type { AdminSessionDto } from '~/shared/types/telemetry'
import { type ThemeMode, useTheme } from '../../composables/use-theme'
defineProps<{ session: AdminSessionDto }>()
const { mode } = useTheme()
const themes: Array<{ value: ThemeMode; label: string; icon: typeof Sun }> = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
]
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger
class="inline-flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-2 px-2.5 text-sm outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
>
<span
class="flex size-6 shrink-0 items-center justify-center rounded bg-primary/15 text-xs font-semibold text-primary"
>
{{ session.identity.name.slice(0, 1).toUpperCase() }}
</span>
<span class="hidden max-w-32 truncate sm:block">{{ session.identity.name }}</span>
<ChevronDown class="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="6"
class="z-50 w-64 rounded-lg border border-surface-5 bg-popover p-1 text-popover-foreground shadow-xl outline-none"
>
<DropdownMenuLabel class="px-2 py-1.5">
<p class="truncate text-sm font-medium">{{ session.identity.name }}</p>
<p class="truncate text-xs font-normal text-muted-foreground">
{{ session.identity.email || 'Cloudflare Access 身份' }}
</p>
</DropdownMenuLabel>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuLabel class="px-2 py-1 text-xs text-muted-foreground"
>界面主题</DropdownMenuLabel
>
<DropdownMenuItem
v-for="theme in themes"
:key="theme.value"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
@click="mode = theme.value"
>
<component :is="theme.icon" class="size-4 text-muted-foreground" />
<span class="flex-1">{{ theme.label }}</span>
<Check v-if="mode === theme.value" class="size-4" />
</DropdownMenuItem>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem as-child>
<a
:href="session.logoutUrl"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
>
<LogOut class="size-4 text-muted-foreground" />
退出登录
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { ChevronDown } from 'lucide-vue-next'
defineProps<{
modelValue: string
label: string
options: Array<{ value: string; label: string }>
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<label class="relative inline-flex min-w-0 items-center">
<span class="sr-only">{{ label }}</span>
<select
:value="modelValue"
class="h-9 max-w-full cursor-pointer appearance-none rounded-md border border-surface-4 bg-surface-2 py-0 pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<ChevronDown class="pointer-events-none absolute right-2 size-4 text-muted-foreground" />
</label>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
defineProps<{ open: boolean; title: string; description?: string }>()
defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="open" @update:open="$emit('update:open', $event)">
<DialogPortal>
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
<DialogContent
class="sheet-panel fixed inset-y-0 right-0 z-50 flex w-full max-w-xl flex-col border-l border-surface-5 bg-surface-1 shadow-2xl focus:outline-none"
>
<header class="flex shrink-0 items-start justify-between gap-4 border-b px-5 py-4">
<div class="min-w-0">
<DialogTitle class="text-base font-semibold">{{ title }}</DialogTitle>
<DialogDescription v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</DialogDescription>
</div>
<DialogClose
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-surface-3 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="关闭详情"
>
<X class="size-4" />
</DialogClose>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<slot />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import { cn } from '~/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('animate-pulse rounded-md bg-muted', props.class)" aria-hidden="true"></div>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
defineProps<{
modelValue: string
label: string
items: Array<{ value: string; label: string }>
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div
role="tablist"
:aria-label="label"
class="inline-flex h-9 items-center rounded-md border border-surface-4 bg-surface-3 p-1"
>
<button
v-for="item in items"
:key="item.value"
type="button"
role="tab"
:aria-selected="modelValue === item.value"
class="h-7 cursor-pointer rounded px-2.5 text-xs font-medium text-muted-foreground transition-[color,background-color,box-shadow] hover:text-foreground active:bg-surface-4"
:class="modelValue === item.value && 'bg-surface-2 text-foreground shadow-sm'"
@click="$emit('update:modelValue', item.value)"
>
{{ item.label }}
</button>
</div>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{ text: string }>()
</script>
<template>
<span class="group relative inline-flex">
<slot />
<span
role="tooltip"
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 hidden w-max max-w-56 -translate-x-1/2 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md group-focus-within:block group-hover:block"
>
{{ text }}
</span>
</span>
</template>

View File

@ -0,0 +1,9 @@
import type { AdminRange } from '~/shared/types/telemetry'
export function useDashboardRange() {
return useState<AdminRange>('dashboard-range', () => '30d')
}
export function useDashboardRefresh() {
return useState<number>('dashboard-refresh', () => 0)
}

View File

@ -0,0 +1,23 @@
export type ThemeMode = 'light' | 'dark' | 'system'
export function useTheme() {
const mode = useState<ThemeMode>('theme-mode', () => 'system')
function apply(value: ThemeMode): void {
if (!import.meta.client) return
const dark =
value === 'dark' ||
(value === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
localStorage.setItem('telemetry-admin-theme', value)
}
onMounted(() => {
const saved = localStorage.getItem('telemetry-admin-theme') as ThemeMode | null
if (saved === 'light' || saved === 'dark' || saved === 'system') mode.value = saved
apply(mode.value)
})
watch(mode, apply)
return { mode }
}

View File

@ -0,0 +1,10 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config.append([
{
rules: {
'import/no-unresolved': 'off',
'no-undef': 'off',
},
},
])

View File

@ -0,0 +1,229 @@
<script setup lang="ts">
import {
Activity,
BarChart3,
Database,
Menu,
RefreshCw,
Search,
Server,
ShieldCheck,
X,
} from 'lucide-vue-next'
import AppState from '~/components/AppState.vue'
import Badge from '~/components/ui/Badge.vue'
import Button from '~/components/ui/Button.vue'
import Command from '~/components/ui/Command.vue'
import DropdownMenu from '~/components/ui/DropdownMenu.vue'
import Skeleton from '~/components/ui/Skeleton.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Tooltip from '~/components/ui/Tooltip.vue'
import type { AdminRange, AdminSessionDto } from '~/shared/types/telemetry'
import { statusCode } from '~/utils/format'
const route = useRoute()
const requestFetch = useRequestFetch()
const range = useDashboardRange()
const refreshEpoch = useDashboardRefresh()
const mobileOpen = ref(false)
const commandOpen = ref(false)
const {
data: session,
status,
error,
refresh: refreshSession,
} = await useAsyncData('admin-session', () => requestFetch<AdminSessionDto>('/api/admin/session'))
const nav = [
{ label: '数据总览', path: '/', icon: BarChart3 },
{ label: '系统状态', path: '/system', icon: Server },
]
const ranges = [
{ value: '7d', label: '7 天' },
{ value: '30d', label: '30 天' },
{ value: '90d', label: '90 天' },
{ value: '365d', label: '1 年' },
]
function setRange(value: string): void {
range.value = value as AdminRange
}
function refresh(): void {
refreshEpoch.value += 1
}
function keydown(event: KeyboardEvent): void {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
commandOpen.value = !commandOpen.value
}
}
onMounted(() => window.addEventListener('keydown', keydown))
onBeforeUnmount(() => window.removeEventListener('keydown', keydown))
watch(
() => route.path,
() => {
mobileOpen.value = false
},
)
</script>
<template>
<div class="min-h-dvh bg-surface-1">
<template v-if="status === 'pending'">
<div
class="fixed inset-y-0 left-0 hidden w-60 border-r border-surface-4 bg-surface-2 p-4 md:block"
>
<Skeleton class="h-10 w-44" />
<div class="mt-8 space-y-2">
<Skeleton v-for="index in 3" :key="index" class="h-10 w-full" />
</div>
</div>
<div class="md:pl-60">
<div class="h-16 border-b border-surface-4 bg-surface-2"></div>
<div class="space-y-4 p-5">
<Skeleton class="h-9 w-48" />
<Skeleton class="h-36 w-full" />
</div>
</div>
</template>
<div v-else-if="error || !session" class="flex min-h-dvh items-center justify-center">
<AppState
:kind="
statusCode(error) === 403
? 'forbidden'
: statusCode(error) === 401
? 'unauthenticated'
: 'error'
"
@retry="refreshSession"
/>
</div>
<template v-else>
<Transition name="fade">
<div
v-if="mobileOpen"
class="fixed inset-0 z-30 bg-black/45 backdrop-blur-[1px] md:hidden"
aria-hidden="true"
@click="mobileOpen = false"
></div>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-40 flex w-60 -translate-x-full flex-col border-r border-surface-4 bg-surface-2 transition-transform duration-200 md:translate-x-0"
:class="mobileOpen && 'translate-x-0'"
>
<div class="flex h-16 items-center gap-3 border-b border-surface-4 px-4">
<div
class="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-sm"
>
<Activity class="size-[18px]" />
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">Axolotl 遥测中心</p>
<p class="mt-0.5 text-[11px] text-muted-foreground">只读管理控制台</p>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto md:hidden"
aria-label="关闭导航"
@click="mobileOpen = false"
>
<X class="size-4" />
</Button>
</div>
<div class="px-3 pb-2 pt-4 text-[11px] font-medium text-muted-foreground">工作区</div>
<nav class="space-y-1 px-3" aria-label="主导航">
<NuxtLink
v-for="item in nav"
:key="item.path"
:to="item.path"
class="flex h-10 cursor-pointer items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-surface-3 hover:text-foreground active:bg-surface-4"
:class="route.path === item.path && 'bg-primary/10 text-primary'"
>
<component :is="item.icon" class="size-4" />
{{ item.label }}
</NuxtLink>
</nav>
<div class="mt-auto p-3">
<div class="rounded-md border border-surface-4 bg-surface-1 p-3">
<div class="flex items-center justify-between gap-2">
<span class="inline-flex items-center gap-2 text-xs font-medium">
<Database class="size-3.5 text-primary" />数据源
</span>
<Badge :variant="session.dataSource === 'production' ? 'success' : 'warning'">
{{ session.dataSource === 'production' ? '生产数据' : '模拟数据' }}
</Badge>
</div>
<p class="mt-2 text-[11px] leading-5 text-muted-foreground">
统计范围仅包含主动同意遥测的匿名安装
</p>
</div>
<div class="mt-3 flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<ShieldCheck class="size-3.5 text-emerald-600" />
Cloudflare Access 已保护
</div>
</div>
</aside>
<div class="min-w-0 md:pl-60">
<header
class="sticky top-0 z-20 flex h-16 items-center gap-2 border-b border-surface-4 bg-surface-2/95 px-3 backdrop-blur md:px-5"
>
<Button
variant="ghost"
size="icon"
class="md:hidden"
aria-label="打开导航"
@click="mobileOpen = true"
>
<Menu class="size-4" />
</Button>
<button
type="button"
class="hidden h-9 min-w-52 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-1 px-3 text-sm text-muted-foreground transition-colors hover:border-surface-5 hover:bg-surface-3 lg:flex"
@click="commandOpen = true"
>
<Search class="size-4" />
<span class="flex-1 text-left">快速跳转</span>
<kbd class="rounded border border-surface-4 bg-surface-2 px-1.5 py-0.5 text-[10px]"
>Ctrl K</kbd
>
</button>
<div class="ml-auto flex min-w-0 items-center gap-2">
<Tabs
:model-value="range"
label="UTC 时间范围"
:items="ranges"
class="hidden sm:inline-flex"
@update:model-value="setRange"
/>
<Tooltip text="刷新当前数据">
<Button variant="outline" size="icon" aria-label="刷新当前数据" @click="refresh">
<RefreshCw class="size-4" />
</Button>
</Tooltip>
<DropdownMenu :session="session" />
</div>
</header>
<div class="border-b border-surface-4 bg-surface-2 px-3 py-2 sm:hidden">
<Tabs
:model-value="range"
label="UTC 时间范围"
:items="ranges"
@update:model-value="setRange"
/>
</div>
<main class="mx-auto min-w-0 max-w-[1680px] p-4 md:p-5 lg:p-6">
<slot />
</main>
</div>
<Command v-model:open="commandOpen" />
</template>
</div>
</template>

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,70 @@
import { fileURLToPath } from 'node:url'
const remoteBindingsEnabled =
process.env.NODE_ENV === 'development' && process.env.TELEMETRY_ADMIN_REMOTE_BINDINGS === 'true'
export default defineNuxtConfig({
compatibilityDate: '2025-08-13',
devtools: { enabled: false },
telemetry: false,
css: ['~/assets/styles/tailwind.css'],
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
title: 'Axolotl 遥测中心',
meta: [
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'color-scheme', content: 'light dark' },
],
script: [
{
key: 'theme-init',
innerHTML:
"(function(){try{var t=localStorage.getItem('telemetry-admin-theme')||'system';var d=document.documentElement;var dark=t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme: dark)').matches);d.classList.toggle('dark',dark)}catch(e){}})()",
},
],
},
pageTransition: { name: 'dashboard-page', mode: 'out-in' },
},
runtimeConfig: {
accessTeamDomain: '',
accessAudience: '',
mockAuth: false,
mockScenario: 'normal',
publicWorkerHealthUrl: 'https://telemetry.axlmc.org/health',
public: {
adminOrigin: 'https://admin.axlmc.org',
},
},
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
hooks: {
'vite:extendConfig'(config, { isClient }) {
if (process.env.NODE_ENV !== 'development') return
if (!config.server) return
const port = isClient ? 24679 : 24680
config.server.hmr = { port, clientPort: port }
},
},
nitro: {
preset:
process.env.NITRO_PRESET ||
(remoteBindingsEnabled
? 'node-server'
: process.env.VERCEL === '1'
? 'vercel'
: 'cloudflare_module'),
externals: remoteBindingsEnabled ? { external: ['wrangler'] } : undefined,
plugins: remoteBindingsEnabled
? [fileURLToPath(new URL('./server/dev/remote-bindings.ts', import.meta.url))]
: [],
},
typescript: {
strict: true,
typeCheck: false,
},
})

View File

@ -0,0 +1,51 @@
{
"name": "@axolotl/telemetry-dashboard",
"private": true,
"type": "module",
"scripts": {
"build": "nuxi build",
"deploy": "pnpm run build && wrangler deploy",
"dev": "nuxi dev",
"preview": "nuxi preview",
"postinstall": "nuxi prepare",
"format": "prettier --check .",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"typecheck": "nuxi typecheck && tsc --noEmit -p tsconfig.test.json",
"test": "pnpm run test:server && pnpm run test:ui",
"test:server": "vitest run --config vitest.server.config.ts",
"test:ui": "vitest run --config vitest.ui.config.ts",
"prepr": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1105.0",
"@unovis/vue": "^1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "^6.1.0",
"lucide-vue-next": "^0.468.0",
"nuxt": "=3.20.2",
"reka-ui": "^2.6.0",
"tailwind-merge": "^2.6.0",
"vue": "^3.5.13",
"vue-router": "*",
"zod": "^3.25.76"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.69",
"@cloudflare/workers-types": "^5.20260801.1",
"@modrinth/tooling-config": "workspace:*",
"@types/node": "^24",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19",
"happy-dom": "^18.0.1",
"postcss": "^8.4.39",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.4",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"wrangler": "^4.28.1"
},
"prettier": "@modrinth/tooling-config/frontend.prettier.config.cjs"
}

View File

@ -0,0 +1,159 @@
<script setup lang="ts">
import {
Activity,
CalendarDays,
Clock3,
Database,
PackagePlus,
Users,
} from 'lucide-vue-next'
import AppState from '~/components/AppState.vue'
import DistributionChart from '~/components/charts/DistributionChart.vue'
import TrendChart from '~/components/charts/TrendChart.vue'
import MetricCard from '~/components/MetricCard.vue'
import PageHeader from '~/components/PageHeader.vue'
import Alert from '~/components/ui/Alert.vue'
import Badge from '~/components/ui/Badge.vue'
import Card from '~/components/ui/Card.vue'
import Skeleton from '~/components/ui/Skeleton.vue'
import type { ActivityDto, DistributionsDto, OverviewDto } from '~/shared/types/telemetry'
import { formatUtcTimestamp } from '~/utils/format'
const range = useDashboardRange()
const refreshEpoch = useDashboardRefresh()
const requestFetch = useRequestFetch()
const { data, status, error, refresh } = await useAsyncData(
'overview-data',
async () => {
const query = { range: range.value }
const [overview, activity, distributions] = await Promise.all([
requestFetch<OverviewDto>('/api/admin/overview', { query }),
requestFetch<ActivityDto>('/api/admin/activity', { query }),
requestFetch<DistributionsDto>('/api/admin/distributions', { query }),
])
return { overview, activity, distributions }
},
{ watch: [range, refreshEpoch] },
)
const metricDefinitions = [
{ key: 'totalInstallations', title: '累计同意遥测安装', icon: Database, tone: 'green' },
{ key: 'dau', title: '日活跃安装DAU', icon: Activity, tone: 'cyan' },
{ key: 'wau', title: '周活跃安装WAU', icon: CalendarDays, tone: 'cyan' },
{ key: 'mau', title: '月活跃安装MAU', icon: Users, tone: 'cyan' },
{ key: 'newInstallationsToday', title: '今日新增安装', icon: PackagePlus, tone: 'green' },
] as const
const trendCharts = [
{
title: '每日活跃安装趋势',
description: '按 UTC 自然日统计的唯一活跃安装。',
series: [
{
key: 'activeInstallations' as const,
label: '活跃安装',
color: 'var(--chart-green)',
},
],
},
{
title: '每日新增安装趋势',
description: '按 UTC 自然日首次出现的同意遥测安装。',
series: [{ key: 'newInstallations' as const, label: '新增安装', color: 'var(--chart-cyan)' }],
},
]
const distributionCharts = computed(() => [
{
title: '启动器版本',
description: '按上报版本统计的唯一活跃安装。',
items: data.value?.distributions.versions ?? [],
color: 'var(--chart-green)',
},
{
title: '操作系统',
description: '按上报平台统计的唯一活跃安装。',
items: data.value?.distributions.platforms ?? [],
color: 'var(--chart-gold)',
},
{
title: 'CPU 架构',
description: '按上报架构统计的唯一活跃安装。',
items: data.value?.distributions.architectures ?? [],
color: 'var(--chart-cyan)',
},
])
</script>
<template>
<div>
<PageHeader
title="数据总览"
description="查看匿名启动器遥测的活跃度、增长和运行环境分布。"
>
<Badge v-if="data" variant="secondary" class="gap-1.5 px-2.5 py-1">
<Clock3 class="size-3.5" />更新于 {{ formatUtcTimestamp(data.overview.generatedAt) }}
</Badge>
</PageHeader>
<Alert title="统计口径" class="mb-5">
所有统计仅包含主动同意遥测的匿名安装时间均按 UTC 计算
</Alert>
<div v-if="status === 'pending' && !data" data-state="loading">
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Skeleton v-for="index in 5" :key="index" class="h-32" />
</div>
<div class="mt-5 grid gap-4 xl:grid-cols-2">
<Skeleton v-for="index in 4" :key="index" class="h-80" />
</div>
</div>
<AppState v-else-if="error || !data" compact kind="error" @retry="refresh" />
<template v-else>
<div
class="grid grid-cols-2 gap-3 lg:grid-cols-4"
:class="status === 'pending' && 'opacity-65'"
:aria-busy="status === 'pending'"
>
<MetricCard
v-for="metric in metricDefinitions"
:key="metric.key"
:title="metric.title"
:value="data.overview.metrics[metric.key].value"
:detail="data.overview.metrics[metric.key].label"
:icon="metric.icon"
:tone="metric.tone"
/>
</div>
<section class="mt-6">
<div class="mb-3">
<h2 class="text-sm font-semibold">使用者趋势</h2>
<p class="mt-0.5 text-xs text-muted-foreground">当前范围 {{ range }}截止今日 UTC</p>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<Card v-for="chart in trendCharts" :key="chart.title" class="min-w-0 p-4">
<h3 class="text-sm font-semibold">{{ chart.title }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ chart.description }}</p>
<TrendChart class="mt-3" :data="data.activity.points" :series="chart.series" />
</Card>
</div>
</section>
<section class="mt-6">
<div class="mb-3">
<h2 class="text-sm font-semibold">运行环境分布</h2>
<p class="mt-0.5 text-xs text-muted-foreground">所选时间范围内的唯一活跃安装</p>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Card v-for="chart in distributionCharts" :key="chart.title" class="min-w-0 p-4">
<h3 class="text-sm font-semibold">{{ chart.title }}</h3>
<p class="mt-1 text-xs text-muted-foreground">{{ chart.description }}</p>
<DistributionChart class="mt-4" :items="chart.items" :color="chart.color" />
</Card>
</div>
</section>
</template>
</div>
</template>

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import { Database, ShieldCheck } from 'lucide-vue-next'
import AppState from '~/components/AppState.vue'
import PageHeader from '~/components/PageHeader.vue'
import StatusRow from '~/components/system/StatusRow.vue'
import Card from '~/components/ui/Card.vue'
import Skeleton from '~/components/ui/Skeleton.vue'
import type { SystemDto } from '~/shared/types/telemetry'
import { formatUtcTimestamp } from '~/utils/format'
const refreshEpoch = useDashboardRefresh()
const requestFetch = useRequestFetch()
const { data, status, error, refresh } = await useAsyncData(
'system-data',
() => requestFetch<SystemDto>('/api/admin/system'),
{ watch: [refreshEpoch] },
)
</script>
<template>
<div>
<PageHeader title="系统状态" description="检查采集服务、聚合任务和数据状态。" />
<div v-if="status === 'pending' && !data" class="grid gap-4 lg:grid-cols-2" data-state="loading">
<Skeleton v-for="index in 3" :key="index" class="h-48" />
</div>
<AppState v-else-if="error || !data" compact kind="error" @retry="refresh" />
<template v-else>
<div class="grid gap-4 lg:grid-cols-2" :class="status === 'pending' && 'opacity-65'">
<Card class="overflow-hidden">
<div class="border-b px-4 py-3">
<h2 class="text-sm font-semibold">服务检查</h2>
<p class="mt-0.5 text-xs text-muted-foreground">检查时间 {{ formatUtcTimestamp(data.generatedAt) }}</p>
</div>
<StatusRow name="公开遥测 Worker" :check="data.publicWorker" />
<StatusRow name="D1 只读查询" :check="data.d1" />
<StatusRow name="Cron 聚合任务" :check="data.cron" />
<StatusRow name="Cloudflare 账户用量" :check="data.accountUsage" />
</Card>
<Card class="p-4">
<h2 class="text-sm font-semibold">数据状态</h2>
<p class="mt-1 text-xs text-muted-foreground">每日聚合的最近可用数据</p>
<div class="mt-7 flex items-center justify-between gap-4 text-sm">
<span class="text-muted-foreground">最新数据日期</span>
<span class="flex items-center gap-2 tabular-nums"><Database class="size-4" />{{ data.latestDataDay ? `${data.latestDataDay}UTC` : '暂无' }}</span>
</div>
<div class="mt-7 flex items-center gap-2 border-t pt-4 text-xs text-muted-foreground"><ShieldCheck class="size-4 text-emerald-600" />管理 API 只读访问</div>
</Card>
</div>
</template>
</div>
</template>

View File

@ -0,0 +1,7 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).activity(parseRange(getQueryRecord(event).range)),
)

View File

@ -0,0 +1,7 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).distributions(parseRange(getQueryRecord(event).range)),
)

View File

@ -0,0 +1,7 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).overview(parseRange(getQueryRecord(event).range)),
)

View File

@ -0,0 +1,8 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi, requireSession } from '../../utils/service'
export default defineAdminHandler(async (event) => {
const session = requireSession(event)
getAdminApi(event)
return { ...session, dataSource: session.mock ? 'fixture' : 'production' }
})

View File

@ -0,0 +1,4 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
export default defineAdminHandler(async (event) => getAdminApi(event).system())

View File

@ -0,0 +1,26 @@
import { createRequire } from 'node:module'
import type { PlatformProxy } from 'wrangler'
import type { DashboardBindings } from '../utils/bindings'
let proxy: PlatformProxy<DashboardBindings> | null = null
const require = createRequire(import.meta.url)
export default defineNitroPlugin(async (nitroApp) => {
const { getPlatformProxy } = require('wrangler') as typeof import('wrangler')
proxy = await getPlatformProxy<DashboardBindings>({
configPath: 'wrangler.toml',
persist: false,
remoteBindings: true,
})
nitroApp.hooks.hook('request', (event) => {
event.context.cloudflare = { env: proxy?.env ?? {} }
})
nitroApp.hooks.hook('close', async () => {
await proxy?.dispose()
proxy = null
})
})

View File

@ -0,0 +1,36 @@
import { getHeader } from 'h3'
import { toAdminHttpError } from '../utils/admin-http-error'
import {
accessSettings,
authenticateAccessToken,
mockAuthEnabled,
mockScenario,
mockSession,
} from '../utils/auth'
import { AdminApiError, unauthorized, unavailable } from '../utils/errors'
import type { AdminEventContext } from '../utils/service'
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/admin/')) return
try {
const config = useRuntimeConfig(event)
if (mockAuthEnabled(config)) {
;(event.context as AdminEventContext).adminSession = mockSession(mockScenario(config))
return
}
const settings = accessSettings(config)
if (!settings) {
if (process.env.NODE_ENV === 'production') throw unavailable()
throw unauthorized()
}
const token = getHeader(event, 'cf-access-jwt-assertion')
;(event.context as AdminEventContext).adminSession = await authenticateAccessToken(
token,
settings,
)
} catch (error) {
if (error instanceof AdminApiError) throw toAdminHttpError(error)
throw error
}
})

View File

@ -0,0 +1,21 @@
import type {
ActivityDto,
AdminRange,
DistributionsDto,
ErrorDetailDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { ErrorQuery } from './validation'
export interface TelemetryAdminApi {
overview(range: AdminRange): Promise<OverviewDto>
activity(range: AdminRange): Promise<ActivityDto>
distributions(range: AdminRange): Promise<DistributionsDto>
errors(query: ErrorQuery): Promise<ErrorsPageDto>
errorDetail(fingerprint: string): Promise<ErrorDetailDto | null>
errorSample(fingerprint: string): Promise<ErrorSampleDto | null>
system(): Promise<SystemDto>
}

View File

@ -0,0 +1,11 @@
import { createError } from 'h3'
import type { AdminApiError } from './errors'
export function toAdminHttpError(error: AdminApiError) {
return createError({
statusCode: error.statusCode,
statusMessage: error.message,
data: { error: { code: error.code, message: error.message } },
})
}

View File

@ -0,0 +1,78 @@
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from 'jose'
import type { AdminSessionDto } from '../../shared/types/telemetry'
import { forbidden, unauthorized } from './errors'
export interface AccessSettings {
teamDomain: string
audience: string
}
type VerifyKey = JWTVerifyGetKey | CryptoKey | Uint8Array
export function accessSettings(config: ReturnType<typeof useRuntimeConfig>): AccessSettings | null {
const teamDomain = String(
process.env.CF_ACCESS_TEAM_DOMAIN || config.accessTeamDomain || '',
).trim()
const audience = String(process.env.CF_ACCESS_AUDIENCE || config.accessAudience || '').trim()
if (!teamDomain || !audience) return null
return { teamDomain, audience }
}
export function mockAuthEnabled(config: ReturnType<typeof useRuntimeConfig>): boolean {
if (process.env.NODE_ENV !== 'development') return false
return String(process.env.TELEMETRY_ADMIN_MOCK_AUTH || config.mockAuth) === 'true'
}
export function mockScenario(config: ReturnType<typeof useRuntimeConfig>): string {
return String(process.env.TELEMETRY_ADMIN_MOCK_SCENARIO || config.mockScenario || 'normal')
}
export async function verifyAccessJwt(
token: string,
settings: AccessSettings,
key?: VerifyKey,
): Promise<AdminSessionDto> {
const issuer = `https://${settings.teamDomain}.cloudflareaccess.com`
const verifyKey = key ?? createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`))
try {
const { payload } = await jwtVerify(token, verifyKey, {
issuer,
audience: settings.audience,
algorithms: ['RS256'],
})
const email = typeof payload.email === 'string' ? payload.email : null
const nameClaim = payload.name ?? payload.common_name ?? email
const name = typeof nameClaim === 'string' && nameClaim.trim() ? nameClaim : 'GitHub member'
return {
identity: { name, email },
organization: 'Axolotl-Launcher',
logoutUrl: '/cdn-cgi/access/logout',
mock: false,
dataSource: 'production',
}
} catch {
throw unauthorized()
}
}
export async function authenticateAccessToken(
token: string | null | undefined,
settings: AccessSettings,
key?: VerifyKey,
): Promise<AdminSessionDto> {
if (!token) throw unauthorized()
return verifyAccessJwt(token, settings, key)
}
export function mockSession(scenario: string): AdminSessionDto {
if (scenario === 'forbidden') throw forbidden()
if (scenario === 'unconfigured-auth') throw unauthorized()
return {
identity: { name: '本地开发身份', email: null },
organization: 'Axolotl-Launcher',
logoutUrl: '/',
mock: true,
dataSource: 'fixture',
}
}

View File

@ -0,0 +1,11 @@
export interface DashboardBindings {
DB?: D1Database
ERROR_CONTEXTS?: R2Bucket
CLOUDFLARE_ACCOUNT_ID?: string
CLOUDFLARE_ANALYTICS_TOKEN?: string
}
export function dashboardBindings(event: { context: Record<string, unknown> }): DashboardBindings {
const cloudflare = event.context.cloudflare as { env?: DashboardBindings } | undefined
return cloudflare?.env ?? (event.context.env as DashboardBindings | undefined) ?? {}
}

View File

@ -0,0 +1,838 @@
import type {
ActivityDto,
AdminRange,
DistributionItemDto,
DistributionsDto,
ErrorDetailDto,
ErrorFiltersDto,
ErrorRowDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
ServiceCheckDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { TelemetryAdminApi } from './admin-api'
import { unavailable } from './errors'
import { type ErrorQuery, rangeDays, startDay } from './validation'
interface CountRow {
count: number
}
interface OverviewRow {
total_installations: number
dau: number
wau: number
mau: number
new_today: number
error_occurrences: number
distinct_groups: number
r2_today: number
}
interface ActivityRow {
day: string
active_installations: number
new_installations: number
error_occurrences: number
}
interface DistributionRow {
label: string
value: number
}
interface ErrorRow {
fingerprint: string
error_type: string
latest_message: string
app_version: string
first_seen: string
last_seen: string
occurrence_count: number
affected_installations: number
has_sample: number
}
interface SampleRegistration {
object_key: string
}
export interface TelemetryQueryResult<T = Record<string, unknown>> {
results: T[]
}
export interface TelemetryStatement {
bind(...values: unknown[]): TelemetryStatement
first<T = Record<string, unknown>>(): Promise<T | null>
all<T = Record<string, unknown>>(): Promise<TelemetryQueryResult<T>>
}
export interface TelemetryDatabase {
prepare(sql: string): TelemetryStatement
batch(statements: TelemetryStatement[]): Promise<TelemetryQueryResult[]>
}
export interface TelemetryObject {
body: ReadableStream
httpMetadata?: { contentEncoding?: string }
}
export interface TelemetryObjectStore {
get(
key: string,
options?: { range?: { offset: number; length: number } },
): Promise<TelemetryObject | null>
}
const SAMPLE_UNCOMPRESSED_LIMIT = 32 * 1024
const ANALYTICS_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'
const WORKERS_FREE_DAILY_REQUESTS = 100_000
const D1_FREE_DAILY_ROWS_READ = 5_000_000
const D1_FREE_DAILY_ROWS_WRITTEN = 100_000
const R2_FREE_MONTHLY_CLASS_A_OPS = 1_000_000
const USAGE_WARN_RATIO = 0.9
const CACHE_TTL_OVERVIEW = 600_000
const CACHE_TTL_ACTIVITY = 600_000
const CACHE_TTL_DISTRIBUTIONS = 900_000
const CACHE_TTL_ERRORS = 1_800_000
const CACHE_TTL_FILTERS = 900_000
const CACHE_TTL_DETAIL = 600_000
const CACHE_TTL_SAMPLE = 600_000
const CACHE_TTL_SYSTEM = 600_000
export interface CloudflareAnalyticsSettings {
accountTag: string
apiToken: string
}
interface AnalyticsResponse {
data?: Record<string, unknown> | null
errors?: Array<{ message?: string }>
}
interface CacheEntry {
expiresAt: number
value: unknown
}
const responseCache = new Map<string, CacheEntry>()
function utcDay(now = new Date()): string {
return now.toISOString().slice(0, 10)
}
function daysAgoUtc(now: Date, days: number): string {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - days)
return date.toISOString().slice(0, 10)
}
function formatCount(value: number): string {
return Math.round(value).toLocaleString('en-US')
}
function errorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.slice(0, 160)
}
async function withTimeout<T>(
milliseconds: number,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), milliseconds)
try {
return await run(controller.signal)
} finally {
clearTimeout(timer)
}
}
async function queryAnalytics(
fetcher: typeof fetch,
settings: CloudflareAnalyticsSettings,
query: string,
): Promise<Record<string, unknown>> {
const response = await withTimeout(10_000, (signal) =>
fetcher(ANALYTICS_ENDPOINT, {
method: 'POST',
headers: {
authorization: `Bearer ${settings.apiToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ query }),
signal,
}),
)
let body: AnalyticsResponse = {}
try {
body = (await response.json()) as AnalyticsResponse
} catch {
// A non-JSON error body is reported through the HTTP status below.
}
if (!response.ok) {
throw new Error(`GraphQL 请求失败HTTP ${response.status}`)
}
if (body.errors?.length) {
throw new Error(
body.errors
.map((entry) => entry.message ?? '未知错误')
.join('')
.slice(0, 160),
)
}
if (!body.data) throw new Error('GraphQL 响应缺少 data')
return body.data
}
function analyticsRows(data: Record<string, unknown>, dataset: string): unknown[] {
const accounts = (data.viewer as { accounts?: unknown } | undefined)?.accounts
if (!Array.isArray(accounts) || accounts.length === 0) return []
const node = (accounts[0] as Record<string, unknown> | undefined)?.[dataset]
return Array.isArray(node) ? node : []
}
function sumOf(row: unknown, path: string[]): number {
let current: unknown = row
for (const field of path) {
if (typeof current !== 'object' || current === null) return 0
current = (current as Record<string, unknown>)[field]
}
const parsed = Number(current)
return Number.isFinite(parsed) ? parsed : 0
}
function requireRow(rows: unknown[]): Record<string, unknown> {
if (rows.length === 0) throw new Error('数据集暂无数据')
return rows[0] as Record<string, unknown>
}
function mapError(row: ErrorRow): ErrorRowDto {
return {
fingerprint: row.fingerprint,
errorType: row.error_type,
latestMessage: row.latest_message,
appVersion: row.app_version,
firstSeen: row.first_seen,
lastSeen: row.last_seen,
occurrenceCount: Number(row.occurrence_count),
affectedInstallations: Number(row.affected_installations),
hasSample: Boolean(row.has_sample),
}
}
function fillActivity(range: AdminRange, rows: ActivityRow[], now = new Date()): ActivityDto {
const values = new Map(rows.map((row) => [row.day, row]))
const start = new Date(`${startDay(range, now)}T00:00:00.000Z`)
const points = Array.from({ length: rangeDays(range) }, (_, index) => {
const day = new Date(start)
day.setUTCDate(start.getUTCDate() + index)
const key = day.toISOString().slice(0, 10)
const row = values.get(key)
return {
day: key,
activeInstallations: Number(row?.active_installations ?? 0),
newInstallations: Number(row?.new_installations ?? 0),
errorOccurrences: Number(row?.error_occurrences ?? 0),
}
})
return { range, points }
}
function service(
status: ServiceCheckDto['status'],
label: string,
detail: string,
): ServiceCheckDto {
return { status, label, detail }
}
export class D1TelemetryAdminApi implements TelemetryAdminApi {
constructor(
private readonly db: TelemetryDatabase,
private readonly r2: TelemetryObjectStore | undefined,
private readonly options: {
storeErrorContext: boolean
healthUrl: string
analytics?: CloudflareAnalyticsSettings
fetcher?: typeof fetch
now?: () => Date
cacheTtlMs?: number
},
) {}
private now(): Date {
return this.options.now?.() ?? new Date()
}
private async cached<T>(key: string, ttlMs: number, run: () => Promise<T>): Promise<T> {
const effectiveTtl = this.options.cacheTtlMs ?? ttlMs
if (effectiveTtl <= 0) return run()
const entry = responseCache.get(key)
if (entry && entry.expiresAt > Date.now()) return entry.value as T
const value = await run()
if (responseCache.size > 256) {
for (const [cachedKey, cachedEntry] of responseCache) {
if (cachedEntry.expiresAt <= Date.now()) responseCache.delete(cachedKey)
}
}
responseCache.set(key, { expiresAt: Date.now() + effectiveTtl, value })
return value
}
async overview(range: AdminRange): Promise<OverviewDto> {
return this.cached(`overview:${range}`, CACHE_TTL_OVERVIEW, async () => {
const today = utcDay(this.now())
const start = startDay(range, this.now())
const yesterday = daysAgoUtc(this.now(), 1)
const groupCountSql =
range === '365d'
? '(SELECT COUNT(*) FROM error_groups)'
: '(SELECT COUNT(*) FROM error_range_stats WHERE range_days = ?)'
const groupCountBindings = range === '365d' ? [] : [rangeDays(range)]
const row = await this.db
.prepare(
`SELECT
(SELECT COUNT(*) FROM installations) AS total_installations,
(SELECT COUNT(*) FROM daily_active WHERE day = ?) AS dau,
(SELECT COUNT(DISTINCT installation_hash) FROM daily_active
WHERE day >= date(?, '-6 days') AND day <= ?) AS wau,
(SELECT COUNT(DISTINCT installation_hash) FROM daily_active
WHERE day >= date(?, '-29 days') AND day <= ?) AS mau,
(SELECT COUNT(*) FROM installations WHERE first_seen_day = ?) AS new_today,
(SELECT COALESCE(SUM(error_occurrences), 0) FROM daily_totals
WHERE day >= ? AND day <= ?)
+ (SELECT COALESCE(SUM(occurrence_count), 0) FROM error_daily WHERE day = ?) AS error_occurrences,
${groupCountSql}
+ (SELECT COUNT(*) FROM error_daily WHERE day = ?) AS distinct_groups,
(SELECT COALESCE(object_count, 0) FROM error_context_budget WHERE day = ?) AS r2_today`,
)
.bind(today, today, today, today, today, today, start, yesterday, today, ...groupCountBindings, today, today)
.first<OverviewRow>()
if (!row) throw unavailable()
const metric = (value: number, label: string) => ({ value: Number(value), label })
return {
range,
generatedAt: this.now().toISOString(),
metrics: {
totalInstallations: metric(row.total_installations, '历史累计主动同意遥测的安装'),
dau: metric(row.dau, '今日 UTC 唯一活跃安装'),
wau: metric(row.wau, '最近 7 天唯一活跃安装'),
mau: metric(row.mau, '最近 30 天唯一活跃安装'),
newInstallationsToday: metric(row.new_today, '今日 UTC 首次出现'),
errorOccurrences: metric(row.error_occurrences, `${range} 范围内发生次数`),
distinctErrorGroups: metric(row.distinct_groups, `${range} 范围内错误指纹`),
r2SamplesToday: metric(row.r2_today, '今日 UTC 已存储样本'),
},
}
})
}
async activity(range: AdminRange): Promise<ActivityDto> {
return this.cached(`activity:${range}`, CACHE_TTL_ACTIVITY, async () => {
const today = utcDay(this.now())
const yesterday = daysAgoUtc(this.now(), 1)
const rows = await this.db
.prepare(
`SELECT day, active_installations, new_installations, error_occurrences
FROM daily_totals WHERE day >= ? AND day <= ? ORDER BY day ASC`,
)
.bind(startDay(range, this.now()), yesterday)
.all<ActivityRow>()
const todayRow = await this.db
.prepare(
`SELECT
(SELECT COUNT(*) FROM daily_active WHERE day = ?) AS active_installations,
(SELECT COUNT(*) FROM installations WHERE first_seen_day = ?) AS new_installations,
(SELECT COALESCE(SUM(occurrence_count), 0) FROM error_daily WHERE day = ?) AS error_occurrences`,
)
.bind(today, today, today)
.first<ActivityRow>()
const allRows = todayRow ? [...rows.results, todayRow] : rows.results
return fillActivity(range, allRows, this.now())
})
}
async distributions(range: AdminRange): Promise<DistributionsDto> {
return this.cached(`distributions:${range}`, CACHE_TTL_DISTRIBUTIONS, async () => {
const start = startDay(range, this.now())
const today = utcDay(this.now())
const query = async (
field: 'app_version' | 'platform' | 'arch',
): Promise<DistributionItemDto[]> => {
const result = await this.db
.prepare(
`SELECT ${field} AS label, COUNT(DISTINCT installation_hash) AS value
FROM daily_active WHERE day >= ? AND day <= ?
GROUP BY ${field} ORDER BY value DESC, label ASC LIMIT 12`,
)
.bind(start, today)
.all<DistributionRow>()
return result.results.map((row) => ({ label: row.label, value: Number(row.value) }))
}
const [versions, platforms, architectures] = await Promise.all([
query('app_version'),
query('platform'),
query('arch'),
])
return { range, versions, platforms, architectures }
})
}
async errors(query: ErrorQuery): Promise<ErrorsPageDto> {
return this.cached(`errors:${JSON.stringify(query)}`, CACHE_TTL_ERRORS, async () => {
const today = utcDay(this.now())
const start = startDay(query.range, this.now())
const rangeSource =
query.range === '365d'
? {
sql: `SELECT fingerprint, app_version, first_seen_day AS first_seen, last_seen_day AS last_seen,
occurrence_count, installation_count, latest_error_type, latest_message,
CASE WHEN sample_object_key IS NULL THEN 0 ELSE 1 END AS has_sample
FROM error_groups`,
bindings: [] as unknown[],
}
: {
sql: `SELECT fingerprint, app_version, first_seen, last_seen,
occurrence_count, installation_count, latest_error_type, latest_message,
has_sample
FROM error_range_stats WHERE range_days = ?`,
bindings: [rangeDays(query.range)] as unknown[],
}
const liveSql = `SELECT ed.fingerprint, ed.app_version, ed.day AS first_seen, ed.day AS last_seen,
SUM(ed.occurrence_count) AS occurrence_count,
SUM(ed.installation_count) AS installation_count,
MAX(ed.latest_error_type) AS latest_error_type,
MAX(ed.latest_message) AS latest_message,
MAX(ed.has_sample) AS has_sample
FROM error_daily ed WHERE ed.day = ?
GROUP BY ed.fingerprint, ed.app_version`
const scopedSql = `SELECT fingerprint, app_version, first_seen, last_seen,
occurrence_count, installation_count, latest_error_type, latest_message,
has_sample
FROM (${rangeSource.sql})
UNION ALL
${liveSql}`
const bindings: unknown[] = [...rangeSource.bindings, today]
const conditions: string[] = []
if (query.search) {
const escaped = query.search.toLowerCase().replace(/[\\%_]/g, '\\$&')
conditions.push(
"(LOWER(scoped.fingerprint) LIKE ? ESCAPE '\\' OR LOWER(scoped.latest_message) LIKE ? ESCAPE '\\' OR LOWER(scoped.latest_error_type) LIKE ? ESCAPE '\\')",
)
bindings.push(`%${escaped}%`, `%${escaped}%`, `%${escaped}%`)
}
if (query.version) {
conditions.push('scoped.app_version = ?')
bindings.push(query.version)
}
if (query.errorType) {
conditions.push('scoped.latest_error_type = ?')
bindings.push(query.errorType)
}
if (query.platform) {
conditions.push(
`EXISTS (SELECT 1 FROM error_reports er
WHERE er.fingerprint = scoped.fingerprint AND er.app_version = scoped.app_version
AND er.platform = ? AND er.day >= ? AND er.day <= ?)`,
)
bindings.push(query.platform, start, today)
}
if (query.hasSample !== null) {
conditions.push(query.hasSample ? 'scoped.has_sample = 1' : 'scoped.has_sample = 0')
}
const where = conditions.length ? conditions.join(' AND ') : '1 = 1'
const totalRow = await this.db
.prepare(
`WITH scoped AS (${scopedSql})
SELECT COUNT(*) AS count FROM (
SELECT fingerprint FROM scoped WHERE ${where} GROUP BY fingerprint
)`,
)
.bind(...bindings)
.first<CountRow>()
const sortColumns: Record<ErrorQuery['sort'], string> = {
lastSeen: 'last_seen',
firstSeen: 'first_seen',
occurrences: 'occurrence_count',
installations: 'affected_installations',
}
const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
const offset = (query.page - 1) * query.pageSize
const rows = await this.db
.prepare(
`WITH scoped AS (${scopedSql})
SELECT
fingerprint,
MIN(first_seen) AS first_seen,
MAX(last_seen) AS last_seen,
SUM(occurrence_count) AS occurrence_count,
SUM(installation_count) AS affected_installations,
COALESCE((SELECT eg.latest_error_type FROM error_groups eg
WHERE eg.fingerprint = scoped.fingerprint
ORDER BY eg.last_seen_day DESC LIMIT 1), MAX(latest_error_type)) AS error_type,
COALESCE((SELECT eg.latest_message FROM error_groups eg
WHERE eg.fingerprint = scoped.fingerprint
ORDER BY eg.last_seen_day DESC LIMIT 1), MAX(latest_message)) AS latest_message,
COALESCE((SELECT eg.app_version FROM error_groups eg
WHERE eg.fingerprint = scoped.fingerprint
ORDER BY eg.last_seen_day DESC LIMIT 1), MAX(app_version)) AS app_version,
MAX(has_sample) AS has_sample
FROM scoped
WHERE ${where}
GROUP BY fingerprint
ORDER BY ${sortColumns[query.sort]} ${direction}, fingerprint ASC
LIMIT ? OFFSET ?`,
)
.bind(...bindings, query.pageSize, offset)
.all<ErrorRow>()
const filters = await this.errorFilters()
const total = Number(totalRow?.count ?? 0)
return {
items: rows.results.map(mapError),
page: query.page,
pageSize: query.pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / query.pageSize)),
filters,
}
})
}
private async errorFilters(): Promise<ErrorFiltersDto> {
return this.cached('errorFilters', CACHE_TTL_FILTERS, async () => {
const [versions, platforms, errorTypes] = await Promise.all([
this.db
.prepare(
'SELECT DISTINCT app_version AS value FROM error_groups ORDER BY value DESC LIMIT 100',
)
.all<{ value: string }>(),
this.db
.prepare('SELECT platform AS value FROM platforms ORDER BY value ASC LIMIT 100')
.all<{ value: string }>(),
this.db
.prepare(
'SELECT DISTINCT latest_error_type AS value FROM error_groups ORDER BY value ASC LIMIT 100',
)
.all<{ value: string }>(),
])
return {
versions: versions.results.map((row) => row.value),
platforms: platforms.results.map((row) => row.value),
errorTypes: errorTypes.results.map((row) => row.value),
}
})
}
async errorDetail(fingerprint: string): Promise<ErrorDetailDto | null> {
return this.cached(`errorDetail:${fingerprint}`, CACHE_TTL_DETAIL, async () => {
const row = await this.db
.prepare(
`SELECT fingerprint, latest_error_type AS error_type, latest_message,
app_version, first_seen_day AS first_seen, last_seen_day AS last_seen,
occurrence_count, installation_count AS affected_installations,
CASE WHEN sample_object_key IS NULL THEN 0 ELSE 1 END AS has_sample
FROM error_groups WHERE fingerprint = ? ORDER BY last_seen_day DESC LIMIT 1`,
)
.bind(fingerprint)
.first<ErrorRow>()
if (row) return { ...mapError(row), route: null, command: null, stack: null }
const live = await this.db
.prepare(
`SELECT
ed.fingerprint,
MAX(ed.latest_error_type) AS error_type,
MAX(ed.latest_message) AS latest_message,
ed.app_version,
MIN(ed.day) AS first_seen,
MAX(ed.day) AS last_seen,
SUM(ed.occurrence_count) AS occurrence_count,
SUM(ed.installation_count) AS affected_installations,
MAX(ed.has_sample) AS has_sample
FROM error_daily ed
WHERE ed.fingerprint = ?
GROUP BY ed.fingerprint, ed.app_version
ORDER BY last_seen DESC LIMIT 1`,
)
.bind(fingerprint)
.first<ErrorRow>()
if (!live) return null
return { ...mapError(live), route: null, command: null, stack: null }
})
}
async errorSample(fingerprint: string): Promise<ErrorSampleDto | null> {
if (!this.r2) return null
return this.cached(`errorSample:${fingerprint}`, CACHE_TTL_SAMPLE, async () => {
const registration = await this.db
.prepare(
`SELECT object_key FROM error_context_reservations
WHERE fingerprint = ?
ORDER BY created_at DESC LIMIT 1`,
)
.bind(fingerprint)
.first<SampleRegistration>()
if (!registration) return null
const object = await this.r2!.get(registration.object_key)
if (!object?.body) return null
let stream: ReadableStream = object.body
if (
object.httpMetadata?.contentEncoding === 'gzip' ||
registration.object_key.endsWith('.gz')
) {
stream = stream.pipeThrough(new DecompressionStream('gzip'))
}
const bytes = await new Response(stream).arrayBuffer()
if (bytes.byteLength > SAMPLE_UNCOMPRESSED_LIMIT) throw unavailable()
let value: Record<string, unknown>
try {
value = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
} catch {
throw unavailable()
}
const app =
typeof value.app === 'object' && value.app ? (value.app as Record<string, unknown>) : {}
const text = (input: unknown, limit: number): string =>
typeof input === 'string' ? input.slice(0, limit) : ''
const optional = (input: unknown, limit: number): string | null => text(input, limit) || null
return {
fingerprint,
occurredAt: text(value.occurred_at, 64),
appVersion: text(app.version, 64),
platform: text(app.platform, 32),
architecture: text(app.arch, 32),
errorType: text(value.error_type, 128),
message: text(value.message, 1_024),
stack: optional(value.stack, 8_192),
route: optional(value.route, 256),
command: optional(value.command, 256),
context: optional(value.context, 16_384),
}
})
}
private async workersRequests24h(
settings: CloudflareAnalyticsSettings,
fetcher: typeof fetch,
now: Date,
): Promise<number> {
const start = new Date(now.getTime() - 24 * 60 * 60 * 1_000).toISOString()
const data = await queryAnalytics(
fetcher,
settings,
`{ viewer { accounts(filter: { accountTag: "${settings.accountTag}" }) {
workersInvocationsAdaptive(
filter: { datetime_geq: "${start}", datetime_lt: "${now.toISOString()}" }
limit: 1
) { sum { requests } }
} } }`,
)
return sumOf(requireRow(analyticsRows(data, 'workersInvocationsAdaptive')), ['sum', 'requests'])
}
private async d1UsageToday(
settings: CloudflareAnalyticsSettings,
fetcher: typeof fetch,
now: Date,
): Promise<{ rowsRead: number; rowsWritten: number }> {
const today = utcDay(now)
const data = await queryAnalytics(
fetcher,
settings,
`{ viewer { accounts(filter: { accountTag: "${settings.accountTag}" }) {
d1AnalyticsAdaptiveGroups(
filter: { date_geq: "${today}", date_leq: "${today}" }
limit: 1
) { sum { rowsRead rowsWritten } }
} } }`,
)
const row = requireRow(analyticsRows(data, 'd1AnalyticsAdaptiveGroups'))
return {
rowsRead: sumOf(row, ['sum', 'rowsRead']),
rowsWritten: sumOf(row, ['sum', 'rowsWritten']),
}
}
private async r2Operations30d(
settings: CloudflareAnalyticsSettings,
fetcher: typeof fetch,
now: Date,
): Promise<number> {
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000).toISOString().slice(0, 10)
const data = await queryAnalytics(
fetcher,
settings,
`{ viewer { accounts(filter: { accountTag: "${settings.accountTag}" }) {
r2OperationsAdaptiveGroups(
filter: { date_geq: "${start}", date_leq: "${utcDay(now)}" }
limit: 1
) { sum { requests } }
} } }`,
)
return sumOf(requireRow(analyticsRows(data, 'r2OperationsAdaptiveGroups')), ['sum', 'requests'])
}
private async accountUsage(settings: CloudflareAnalyticsSettings): Promise<ServiceCheckDto> {
if (!/^[0-9a-f]{32}$/i.test(settings.accountTag)) {
return service('unavailable', '未配置', 'CLOUDFLARE_ACCOUNT_ID 格式无效(应为 32 位账户 ID')
}
const fetcher = this.options.fetcher ?? fetch
const now = this.now()
const parts: string[] = []
const failures: string[] = []
let workersRatio = 0
let d1WriteRatio = 0
let r2Ratio = 0
try {
const requests = await this.workersRequests24h(settings, fetcher, now)
workersRatio = requests / WORKERS_FREE_DAILY_REQUESTS
parts.push(
`Workers ${formatCount(requests)}/${formatCount(WORKERS_FREE_DAILY_REQUESTS)} 请求24h`,
)
} catch (error) {
failures.push(`Workers 用量查询失败:${errorMessage(error)}`)
}
try {
const { rowsRead, rowsWritten } = await this.d1UsageToday(settings, fetcher, now)
d1WriteRatio = rowsWritten / D1_FREE_DAILY_ROWS_WRITTEN
parts.push(
`D1 读 ${formatCount(rowsRead)}/${formatCount(D1_FREE_DAILY_ROWS_READ)}、写 ${formatCount(rowsWritten)}/${formatCount(D1_FREE_DAILY_ROWS_WRITTEN)} 行(今日 UTC`,
)
} catch (error) {
failures.push(`D1 用量查询失败:${errorMessage(error)}`)
}
try {
const requests = await this.r2Operations30d(settings, fetcher, now)
r2Ratio = requests / R2_FREE_MONTHLY_CLASS_A_OPS
parts.push(
`R2 ${formatCount(requests)}/${formatCount(R2_FREE_MONTHLY_CLASS_A_OPS)} 操作30 天Class A+B 合计)`,
)
} catch (error) {
failures.push(`R2 用量查询失败:${errorMessage(error)}`)
}
if (parts.length === 0) {
return service('unavailable', '查询失败', failures.join('') || 'Analytics API 无可用数据')
}
const detail = failures.length
? `${parts.join(' · ')}${failures.join('')}`
: parts.join(' · ')
if (failures.length > 0) return service('degraded', '部分可用', detail)
if (Math.max(workersRatio, d1WriteRatio, r2Ratio) >= USAGE_WARN_RATIO) {
return service('degraded', '接近配额', detail)
}
return service('available', '额度充足', detail)
}
async system(): Promise<SystemDto> {
return this.cached('system', CACHE_TTL_SYSTEM, async () => {
const now = this.now()
let publicWorker = service('unavailable', '不可用', '健康检查端点未响应')
try {
const response = await withTimeout(3_000, (signal) =>
(this.options.fetcher ?? fetch)(this.options.healthUrl, {
signal,
headers: { accept: 'application/json' },
}),
)
if (response.ok) publicWorker = service('available', '运行正常', '公开采集服务健康检查通过')
} catch {
publicWorker = service('unavailable', '不可用', '健康检查端点未响应')
}
let d1 = service('unavailable', '不可用', 'D1 查询失败')
let latestDataDay: string | null = null
let budget = 0
let sampleKey: string | null = null
try {
const result = await this.db.batch([
this.db.prepare('SELECT MAX(day) AS value FROM daily_totals'),
this.db
.prepare(
'SELECT COALESCE(object_count, 0) AS value FROM error_context_budget WHERE day = ?',
)
.bind(utcDay(now)),
this.db.prepare(
'SELECT object_key AS value FROM error_context_reservations ORDER BY created_at DESC LIMIT 1',
),
])
latestDataDay =
(result[0].results[0] as { value?: string | null } | undefined)?.value ?? null
budget = Number((result[1].results[0] as { value?: number } | undefined)?.value ?? 0)
sampleKey = (result[2].results[0] as { value?: string | null } | undefined)?.value ?? null
d1 = service('available', '可查询', '只读遥测查询可用')
} catch {
d1 = service('unavailable', '不可用', 'D1 查询失败')
}
let r2 = service('unavailable', '不可用', '没有可读取的登记样本')
if (!this.options.storeErrorContext) {
r2 = service('degraded', '已停用', '错误上下文存储已停用')
} else if (this.r2 && sampleKey) {
try {
const sample = await this.r2.get(sampleKey, { range: { offset: 0, length: 1 } })
if (sample) {
try {
await sample.body.cancel()
} catch {
// The probe reads a single byte; cancellation failures are irrelevant.
}
r2 = service('available', '可读取', '已确认一个登记样本可读取')
}
} catch {
r2 = service('unavailable', '不可用', '登记样本无法读取')
}
} else if (this.r2) {
r2 = service('degraded', '暂无样本', 'R2 binding 正常,但当前没有登记样本')
}
const yesterday = daysAgoUtc(now, 1)
const cron = !latestDataDay
? service('unavailable', '不可用', '没有可用的聚合日期')
: latestDataDay >= yesterday
? service('available', '数据最新', `最近聚合日期:${latestDataDay}UTC`)
: service('degraded', '数据滞后', `最近聚合日期:${latestDataDay}UTC`)
const accountUsage = !this.options.analytics
? service(
'unavailable',
'未配置',
'尚未配置 Cloudflare Analytics API请为 dashboard Worker 设置 CLOUDFLARE_ACCOUNT_ID 与 CLOUDFLARE_ANALYTICS_TOKENAccount Analytics: Read',
)
: await this.accountUsage(this.options.analytics)
return {
generatedAt: now.toISOString(),
publicWorker,
d1,
r2,
storeErrorContext: this.options.storeErrorContext,
r2Budget: { used: budget, limit: 2000 },
limits: {
samplesPerGroup: 3,
dailyActiveRetentionDays: 35,
errorReportsRetentionDays: 30,
r2RetentionDays: 30,
errorAggregatesRetentionDays: 365,
},
latestDataDay,
cron,
accountUsage,
}
})
}
}
export { fillActivity }

View File

@ -0,0 +1,14 @@
export class AdminApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message)
this.name = 'AdminApiError'
}
}
export const unauthorized = () => new AdminApiError(401, 'unauthenticated', '需要登录')
export const forbidden = () => new AdminApiError(403, 'forbidden', '当前身份无权访问')
export const unavailable = () => new AdminApiError(503, 'service_unavailable', '遥测数据暂不可用')

View File

@ -0,0 +1,33 @@
import {
createError,
defineEventHandler,
type EventHandler,
type EventHandlerRequest,
isError,
} from 'h3'
import { toAdminHttpError } from './admin-http-error'
import { AdminApiError } from './errors'
export function defineAdminHandler<T>(
handler: EventHandler<EventHandlerRequest, T | Promise<T>>,
): EventHandler<EventHandlerRequest, Promise<T>> {
return defineEventHandler(async (event) => {
try {
return await handler(event)
} catch (error) {
if (error instanceof AdminApiError) throw toAdminHttpError(error)
if (isError(error) && 'statusCode' in error && Number(error.statusCode) < 500) throw error
throw createError({
statusCode: 503,
statusMessage: '遥测数据暂不可用',
data: {
error: {
code: 'service_unavailable',
message: '遥测数据暂不可用',
},
},
})
}
})
}

View File

@ -0,0 +1,232 @@
import type {
ActivityDto,
AdminRange,
DistributionsDto,
ErrorDetailDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { TelemetryAdminApi } from './admin-api'
import { unavailable } from './errors'
import type { ErrorQuery } from './validation'
import { rangeDays } from './validation'
const FIXTURE_NOW = new Date('2026-08-14T10:00:00.000Z')
const fixtureErrors: ErrorDetailDto[] = [
{
fingerprint: 'fixture-render-thread-01',
errorType: 'RenderInitializationError',
latestMessage: 'Fixture renderer could not initialize the selected backend.',
appVersion: '9.4.0-fixture',
firstSeen: '2026-08-03',
lastSeen: '2026-08-14',
occurrenceCount: 184,
affectedInstallations: 41,
hasSample: true,
route: null,
command: null,
stack: null,
},
{
fingerprint: 'fixture-metadata-timeout-02',
errorType: 'MetadataTimeout',
latestMessage: 'Fixture metadata request exceeded its deadline.',
appVersion: '9.3.2-fixture',
firstSeen: '2026-08-09',
lastSeen: '2026-08-13',
occurrenceCount: 57,
affectedInstallations: 19,
hasSample: false,
route: null,
command: null,
stack: null,
},
{
fingerprint: 'fixture-runtime-path-03',
errorType: 'RuntimePathError',
latestMessage: 'Fixture Java runtime path was not available.',
appVersion: '9.4.0-fixture',
firstSeen: '2026-08-12',
lastSeen: '2026-08-12',
occurrenceCount: 12,
affectedInstallations: 8,
hasSample: true,
route: null,
command: null,
stack: null,
},
]
export class MockTelemetryAdminApi implements TelemetryAdminApi {
constructor(private readonly scenario = 'normal') {}
private assertAvailable(): void {
if (this.scenario === 'api-error') throw unavailable()
}
async overview(range: AdminRange): Promise<OverviewDto> {
this.assertAvailable()
const empty = this.scenario === 'empty'
const value = (normal: number, label: string) => ({ value: empty ? 0 : normal, label })
return {
range,
generatedAt: FIXTURE_NOW.toISOString(),
metrics: {
totalInstallations: value(18_426, '历史累计主动同意遥测的安装'),
dau: value(2_184, '今日 UTC 唯一活跃安装'),
wau: value(7_932, '最近 7 天唯一活跃安装'),
mau: value(14_806, '最近 30 天唯一活跃安装'),
newInstallationsToday: value(318, '今日 UTC 首次出现'),
errorOccurrences: value(476, `${range} 范围内发生次数`),
distinctErrorGroups: value(39, `${range} 范围内错误指纹`),
r2SamplesToday: value(
this.scenario === 'budget-reached' ? 2_000 : 614,
'今日 UTC 已存储样本',
),
},
}
}
async activity(range: AdminRange): Promise<ActivityDto> {
this.assertAvailable()
const points = Array.from({ length: rangeDays(range) }, (_, index) => {
const day = new Date(FIXTURE_NOW)
day.setUTCDate(day.getUTCDate() - rangeDays(range) + index + 1)
const wave = Math.round(Math.sin(index / 3) * 90)
return {
day: day.toISOString().slice(0, 10),
activeInstallations: this.scenario === 'empty' ? 0 : 1_820 + index * 9 + wave,
newInstallations: this.scenario === 'empty' ? 0 : 230 + (index % 6) * 17,
errorOccurrences: this.scenario === 'empty' ? 0 : 20 + (index % 5) * 8,
}
})
return { range, points }
}
async distributions(range: AdminRange): Promise<DistributionsDto> {
this.assertAvailable()
if (this.scenario === 'empty') return { range, versions: [], platforms: [], architectures: [] }
return {
range,
versions: [
{ label: '9.4.0-fixture', value: 8_214 },
{ label: '9.3.2-fixture', value: 4_981 },
{ label: '9.3.1-fixture', value: 1_760 },
],
platforms: [
{ label: 'windows-fixture', value: 10_840 },
{ label: 'linux-fixture', value: 2_934 },
{ label: 'macos-fixture', value: 1_181 },
],
architectures: [
{ label: 'x86_64-fixture', value: 12_902 },
{ label: 'aarch64-fixture', value: 2_053 },
],
}
}
async errors(query: ErrorQuery): Promise<ErrorsPageDto> {
this.assertAvailable()
let rows = this.scenario === 'empty' ? [] : [...fixtureErrors]
if (this.scenario === 'no-sample') rows = rows.map((row) => ({ ...row, hasSample: false }))
const search = query.search.toLowerCase()
rows = rows.filter(
(row) =>
(!search ||
`${row.fingerprint} ${row.errorType} ${row.latestMessage}`
.toLowerCase()
.includes(search)) &&
(!query.version || row.appVersion === query.version) &&
(!query.errorType || row.errorType === query.errorType) &&
(query.hasSample === null || row.hasSample === query.hasSample),
)
const keys: Record<ErrorQuery['sort'], keyof ErrorDetailDto> = {
lastSeen: 'lastSeen',
firstSeen: 'firstSeen',
occurrences: 'occurrenceCount',
installations: 'affectedInstallations',
}
rows.sort((left, right) => {
const a = left[keys[query.sort]]
const b = right[keys[query.sort]]
const compared =
typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b))
return query.direction === 'asc' ? compared : -compared
})
const total = rows.length
const start = (query.page - 1) * query.pageSize
return {
items: rows.slice(start, start + query.pageSize),
page: query.page,
pageSize: query.pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / query.pageSize)),
filters: {
versions: ['9.4.0-fixture', '9.3.2-fixture', '9.3.1-fixture'],
platforms: ['windows-fixture', 'linux-fixture', 'macos-fixture'],
errorTypes: ['MetadataTimeout', 'RenderInitializationError', 'RuntimePathError'],
},
}
}
async errorDetail(fingerprint: string): Promise<ErrorDetailDto | null> {
this.assertAvailable()
return fixtureErrors.find((row) => row.fingerprint === fingerprint) ?? null
}
async errorSample(fingerprint: string): Promise<ErrorSampleDto | null> {
this.assertAvailable()
const error = fixtureErrors.find((row) => row.fingerprint === fingerprint)
if (!error?.hasSample || this.scenario === 'no-sample') return null
return {
fingerprint,
occurredAt: '2026-08-14T08:42:11.000Z',
appVersion: error.appVersion,
platform: 'windows-fixture',
architecture: 'x86_64-fixture',
errorType: error.errorType,
message: error.latestMessage,
stack: 'FixtureError: synthetic stack\n at fixture.operation (fixture.ts:14:2)',
route: '/fixture/library',
command: 'fixture-command',
context: '{"fixture":true,"note":"synthetic telemetry context"}',
}
}
async system(): Promise<SystemDto> {
this.assertAvailable()
const budget = this.scenario === 'budget-reached' ? 2_000 : 614
return {
generatedAt: FIXTURE_NOW.toISOString(),
publicWorker: {
status: 'available',
label: '运行正常',
detail: '模拟健康检查通过',
},
d1: { status: 'available', label: '可查询', detail: '模拟 D1 可查询' },
r2:
this.scenario === 'no-sample'
? { status: 'degraded', label: '暂无样本', detail: '没有登记模拟样本' }
: { status: 'available', label: '可读取', detail: '模拟样本可读取' },
storeErrorContext: true,
r2Budget: { used: budget, limit: 2_000 },
limits: {
samplesPerGroup: 3,
dailyActiveRetentionDays: 35,
errorReportsRetentionDays: 30,
r2RetentionDays: 30,
errorAggregatesRetentionDays: 365,
},
latestDataDay: '2026-08-14',
cron: { status: 'available', label: '数据最新', detail: '最近聚合日期2026-08-14UTC' },
accountUsage: {
status: 'unavailable',
label: '不可用',
detail: '尚未配置 Analytics API',
},
}
}
}

View File

@ -0,0 +1,48 @@
import type { H3Event } from 'h3'
import type { AdminSessionDto } from '../../shared/types/telemetry'
import type { TelemetryAdminApi } from './admin-api'
import { mockScenario } from './auth'
import { dashboardBindings } from './bindings'
import {
D1TelemetryAdminApi,
type TelemetryDatabase,
} from './d1-admin-api'
import { unavailable } from './errors'
import { MockTelemetryAdminApi } from './mock-admin-api'
import { remoteTelemetryDataSource } from './vercel-data-source'
export interface AdminEventContext {
adminSession?: AdminSessionDto
adminApi?: TelemetryAdminApi
}
export function requireSession(event: H3Event): AdminSessionDto {
const session = (event.context as AdminEventContext).adminSession
if (!session) throw unavailable()
return session
}
export function getAdminApi(event: H3Event): TelemetryAdminApi {
const context = event.context as AdminEventContext
if (context.adminApi) return context.adminApi
const config = useRuntimeConfig(event)
const bindings = dashboardBindings(event as unknown as { context: Record<string, unknown> })
const remote = remoteTelemetryDataSource()
const db = (bindings.DB as unknown as TelemetryDatabase | undefined) ?? remote?.db
const accountTag = String(bindings.CLOUDFLARE_ACCOUNT_ID ?? '').trim()
const analyticsToken = String(bindings.CLOUDFLARE_ANALYTICS_TOKEN ?? '').trim()
if (db) {
context.adminApi = new D1TelemetryAdminApi(db, undefined, {
storeErrorContext: false,
healthUrl: String(config.publicWorkerHealthUrl),
analytics:
accountTag && analyticsToken ? { accountTag, apiToken: analyticsToken } : undefined,
})
} else if (context.adminSession?.mock) {
context.adminApi = new MockTelemetryAdminApi(mockScenario(config))
} else {
throw unavailable()
}
return context.adminApi
}

View File

@ -0,0 +1,99 @@
import type { H3Event } from 'h3'
import {
ADMIN_RANGES,
type AdminRange,
type ErrorSort,
type SortDirection,
} from '../../shared/types/telemetry'
import { AdminApiError } from './errors'
const ERROR_SORTS = ['lastSeen', 'firstSeen', 'occurrences', 'installations'] as const
const SORT_DIRECTIONS = ['asc', 'desc'] as const
export interface ErrorQuery {
range: AdminRange
page: number
pageSize: number
search: string
version: string | null
platform: string | null
errorType: string | null
hasSample: boolean | null
sort: ErrorSort
direction: SortDirection
}
function single(value: unknown): string | undefined {
return Array.isArray(value) ? String(value[0]) : value == null ? undefined : String(value)
}
function integer(value: unknown, fallback: number, minimum: number, maximum: number): number {
const raw = single(value)
if (raw === undefined || raw === '') return fallback
if (!/^\d+$/.test(raw)) throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
const parsed = Number(raw)
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
return parsed
}
function limited(value: unknown, maximum: number): string | null {
const parsed = single(value)?.trim() ?? ''
if (!parsed) return null
if (parsed.length > maximum) {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
return parsed
}
export function parseRange(value: unknown): AdminRange {
const parsed = single(value) ?? '30d'
if (!ADMIN_RANGES.includes(parsed as AdminRange)) {
throw new AdminApiError(400, 'invalid_range', 'Range must be 7d, 30d, 90d, or 365d')
}
return parsed as AdminRange
}
export function parseErrorQuery(query: Record<string, unknown>): ErrorQuery {
const sort = single(query.sort) ?? 'lastSeen'
const direction = single(query.direction) ?? 'desc'
if (
!ERROR_SORTS.includes(sort as ErrorSort) ||
!SORT_DIRECTIONS.includes(direction as SortDirection)
) {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
const sample = single(query.hasSample)
if (sample !== undefined && sample !== 'true' && sample !== 'false') {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
return {
range: parseRange(query.range),
page: integer(query.page, 1, 1, 100_000),
pageSize: integer(query.pageSize, 25, 1, 100),
search: limited(query.search, 120) ?? '',
version: limited(query.version, 64),
platform: limited(query.platform, 32),
errorType: limited(query.errorType, 128),
hasSample: sample === undefined ? null : sample === 'true',
sort: sort as ErrorSort,
direction: direction as SortDirection,
}
}
export function getQueryRecord(event: H3Event): Record<string, unknown> {
const url = new URL(event.node.req.url ?? '/', 'http://localhost')
return Object.fromEntries(url.searchParams.entries())
}
export function rangeDays(range: AdminRange): number {
return Number.parseInt(range, 10)
}
export function startDay(range: AdminRange, now = new Date()): string {
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
date.setUTCDate(date.getUTCDate() - rangeDays(range) + 1)
return date.toISOString().slice(0, 10)
}

View File

@ -0,0 +1,153 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type {
TelemetryDatabase,
TelemetryObject,
TelemetryObjectStore,
TelemetryQueryResult,
TelemetryStatement,
} from './d1-admin-api'
interface D1ApiResponse<T> {
success: boolean
errors?: Array<{ message?: string }>
result?: Array<{ results?: T[]; success?: boolean }>
}
interface RemoteSettings {
accountId: string
databaseId: string
apiToken: string
r2AccessKeyId: string
r2SecretAccessKey: string
r2BucketName: string
}
function setting(name: string): string {
return String(process.env[name] ?? '').trim()
}
function remoteSettings(): RemoteSettings | null {
if (process.env.NODE_ENV !== 'production') return null
const settings = {
accountId: setting('CLOUDFLARE_ACCOUNT_ID'),
databaseId: setting('CLOUDFLARE_D1_DATABASE_ID'),
apiToken: setting('CLOUDFLARE_API_TOKEN'),
r2AccessKeyId: setting('CLOUDFLARE_R2_ACCESS_KEY_ID'),
r2SecretAccessKey: setting('CLOUDFLARE_R2_SECRET_ACCESS_KEY'),
r2BucketName: setting('CLOUDFLARE_R2_BUCKET_NAME'),
}
return Object.values(settings).every(Boolean) ? settings : null
}
function assertReadOnlySql(sql: string): void {
if (!/^\s*(SELECT|WITH)\b/i.test(sql)) throw new Error('Telemetry database is read-only')
}
class RemoteD1Statement implements TelemetryStatement {
constructor(
private readonly database: RemoteD1Database,
private readonly sql: string,
private readonly params: unknown[] = [],
) {}
bind(...values: unknown[]): TelemetryStatement {
return new RemoteD1Statement(this.database, this.sql, values)
}
async first<T = Record<string, unknown>>(): Promise<T | null> {
const result = await this.all<T>()
return result.results[0] ?? null
}
all<T = Record<string, unknown>>(): Promise<TelemetryQueryResult<T>> {
return this.database.query<T>(this.sql, this.params)
}
}
class RemoteD1Database implements TelemetryDatabase {
constructor(private readonly settings: RemoteSettings) {}
prepare(sql: string): TelemetryStatement {
return new RemoteD1Statement(this, sql)
}
async batch(statements: TelemetryStatement[]): Promise<TelemetryQueryResult[]> {
return Promise.all(statements.map((statement) => statement.all()))
}
async query<T>(sql: string, params: unknown[]): Promise<TelemetryQueryResult<T>> {
assertReadOnlySql(sql)
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(this.settings.accountId)}/d1/database/${encodeURIComponent(this.settings.databaseId)}/query`,
{
method: 'POST',
headers: {
authorization: `Bearer ${this.settings.apiToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ sql, params }),
signal: AbortSignal.timeout(10_000),
},
)
const body = (await response.json()) as D1ApiResponse<T>
const result = body.result?.[0]
if (!response.ok || !body.success || !result?.success || !result.results) {
throw new Error(body.errors?.[0]?.message || 'Cloudflare D1 query failed')
}
return { results: result.results }
}
}
class RemoteR2Store implements TelemetryObjectStore {
private readonly client: S3Client
constructor(private readonly settings: RemoteSettings) {
this.client = new S3Client({
region: 'auto',
endpoint: `https://${settings.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: settings.r2AccessKeyId,
secretAccessKey: settings.r2SecretAccessKey,
},
})
}
async get(
key: string,
options?: { range?: { offset: number; length: number } },
): Promise<TelemetryObject | null> {
const range = options?.range
try {
const object = await this.client.send(
new GetObjectCommand({
Bucket: this.settings.r2BucketName,
Key: key,
Range: range ? `bytes=${range.offset}-${range.offset + range.length - 1}` : undefined,
}),
)
if (!object.Body) return null
return {
body: object.Body.transformToWebStream(),
httpMetadata: { contentEncoding: object.ContentEncoding },
}
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata
?.httpStatusCode
if (status === 404) return null
throw error
}
}
}
let source: { db: TelemetryDatabase; r2: TelemetryObjectStore } | null | undefined
export function remoteTelemetryDataSource(): {
db: TelemetryDatabase
r2: TelemetryObjectStore
} | null {
if (source !== undefined) return source
const settings = remoteSettings()
source = settings ? { db: new RemoteD1Database(settings), r2: new RemoteR2Store(settings) } : null
return source
}

View File

@ -0,0 +1,141 @@
export const ADMIN_RANGES = ['7d', '30d', '90d', '365d'] as const
export type AdminRange = (typeof ADMIN_RANGES)[number]
export type Availability = 'available' | 'degraded' | 'unavailable'
export interface AdminSessionDto {
identity: {
name: string
email: string | null
}
organization: 'Axolotl-Launcher'
logoutUrl: string
mock: boolean
dataSource: 'production' | 'fixture'
}
export interface MetricDto {
value: number
label: string
}
export interface OverviewDto {
range: AdminRange
generatedAt: string
metrics: {
totalInstallations: MetricDto
dau: MetricDto
wau: MetricDto
mau: MetricDto
newInstallationsToday: MetricDto
errorOccurrences: MetricDto
distinctErrorGroups: MetricDto
r2SamplesToday: MetricDto
}
}
export interface DailyPointDto {
day: string
activeInstallations: number
newInstallations: number
errorOccurrences: number
}
export interface ActivityDto {
range: AdminRange
points: DailyPointDto[]
}
export interface DistributionItemDto {
label: string
value: number
}
export interface DistributionsDto {
range: AdminRange
versions: DistributionItemDto[]
platforms: DistributionItemDto[]
architectures: DistributionItemDto[]
}
export type ErrorSort = 'lastSeen' | 'firstSeen' | 'occurrences' | 'installations'
export type SortDirection = 'asc' | 'desc'
export interface ErrorFiltersDto {
versions: string[]
platforms: string[]
errorTypes: string[]
}
export interface ErrorRowDto {
fingerprint: string
errorType: string
latestMessage: string
appVersion: string
firstSeen: string
lastSeen: string
occurrenceCount: number
affectedInstallations: number
hasSample: boolean
}
export interface ErrorsPageDto {
items: ErrorRowDto[]
page: number
pageSize: number
total: number
totalPages: number
filters: ErrorFiltersDto
}
export interface ErrorDetailDto extends ErrorRowDto {
route: string | null
command: string | null
stack: string | null
}
export interface ErrorSampleDto {
fingerprint: string
occurredAt: string
appVersion: string
platform: string
architecture: string
errorType: string
message: string
stack: string | null
route: string | null
command: string | null
context: string | null
}
export interface ServiceCheckDto {
status: Availability
label: string
detail: string
}
export interface SystemDto {
generatedAt: string
publicWorker: ServiceCheckDto
d1: ServiceCheckDto
r2: ServiceCheckDto
storeErrorContext: boolean
r2Budget: { used: number; limit: 2000 }
limits: {
samplesPerGroup: 3
dailyActiveRetentionDays: 35
errorReportsRetentionDays: 30
r2RetentionDays: 30
errorAggregatesRetentionDays: 365
}
latestDataDay: string | null
cron: ServiceCheckDto
accountUsage: ServiceCheckDto
}
export interface ApiErrorDto {
error: {
code: string
message: string
}
}

View File

@ -0,0 +1,60 @@
import type { Config } from 'tailwindcss'
export default {
darkMode: ['class'],
content: [
'./app.vue',
'./components/**/*.{vue,ts}',
'./composables/**/*.ts',
'./layouts/**/*.vue',
'./pages/**/*.vue',
],
theme: {
extend: {
colors: {
surface: {
1: 'hsl(var(--surface-1))',
2: 'hsl(var(--surface-2))',
3: 'hsl(var(--surface-3))',
4: 'hsl(var(--surface-4))',
5: 'hsl(var(--surface-5))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
},
borderRadius: {
lg: '8px',
md: '6px',
sm: '4px',
},
fontFamily: {
sans: [
'Inter',
'PingFang SC',
'Microsoft YaHei',
'ui-sans-serif',
'system-ui',
'sans-serif',
],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
},
},
plugins: [],
} satisfies Config

View File

@ -0,0 +1,65 @@
import { generateKeyPair, SignJWT } from 'jose'
import { afterEach, describe, expect, it } from 'vitest'
import { authenticateAccessToken, mockAuthEnabled, verifyAccessJwt } from '../../server/utils/auth'
const settings = { teamDomain: 'fixture-team', audience: 'fixture-audience' }
const issuer = 'https://fixture-team.cloudflareaccess.com'
async function token(
privateKey: CryptoKey,
overrides: { audience?: string; expiresAt?: number } = {},
): Promise<string> {
const now = Math.floor(Date.now() / 1_000)
return new SignJWT({ email: 'fixture@example.invalid', name: 'Fixture Operator' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuer(issuer)
.setAudience(overrides.audience ?? settings.audience)
.setIssuedAt(now)
.setExpirationTime(overrides.expiresAt ?? now + 600)
.sign(privateKey)
}
describe('Cloudflare Access authentication', () => {
const previousNodeEnv = process.env.NODE_ENV
afterEach(() => {
process.env.NODE_ENV = previousNodeEnv
delete process.env.TELEMETRY_ADMIN_MOCK_AUTH
})
it('rejects a missing Access assertion', async () => {
await expect(authenticateAccessToken(null, settings)).rejects.toMatchObject({
statusCode: 401,
code: 'unauthenticated',
})
})
it('rejects the wrong audience and expired tokens, then accepts a valid token', async () => {
const { privateKey, publicKey } = (await generateKeyPair('RS256')) as CryptoKeyPair
await expect(
verifyAccessJwt(await token(privateKey, { audience: 'wrong-audience' }), settings, publicKey),
).rejects.toMatchObject({ statusCode: 401 })
await expect(
verifyAccessJwt(
await token(privateKey, { expiresAt: Math.floor(Date.now() / 1_000) - 10 }),
settings,
publicKey,
),
).rejects.toMatchObject({ statusCode: 401 })
const session = await verifyAccessJwt(await token(privateKey), settings, publicKey)
expect(session).toMatchObject({
identity: { name: 'Fixture Operator', email: 'fixture@example.invalid' },
organization: 'Axolotl-Launcher',
mock: false,
dataSource: 'production',
})
})
it('cannot enable MockAuthProvider in production', () => {
process.env.NODE_ENV = 'production'
process.env.TELEMETRY_ADMIN_MOCK_AUTH = 'true'
expect(mockAuthEnabled({ mockAuth: true } as ReturnType<typeof useRuntimeConfig>)).toBe(false)
})
})

View File

@ -0,0 +1,233 @@
import { env } from 'cloudflare:test'
import { beforeEach, describe, expect, it } from 'vitest'
import { D1TelemetryAdminApi } from '../../server/utils/d1-admin-api'
import { parseErrorQuery } from '../../server/utils/validation'
const NOW = new Date('2026-08-14T10:00:00.000Z')
const ANALYTICS_URL = 'https://api.cloudflare.com/client/v4/graphql'
const FIXTURE_ACCOUNT_TAG = 'a7659e62e4d157aba4a45e4829b24e91'
async function gzip(input: string): Promise<ArrayBuffer> {
return new Response(
new Blob([input]).stream().pipeThrough(new CompressionStream('gzip')),
).arrayBuffer()
}
async function seed(): Promise<void> {
await env.DB.batch([
env.DB.prepare(
`INSERT INTO installations
(installation_hash, first_seen_at, last_seen_at, first_seen_day, app_version, platform, arch)
VALUES ('fixture-hash-a', 1, 2, '2026-08-13', '9.4.0-fixture', 'windows-fixture', 'x86_64-fixture')`,
),
env.DB.prepare(
`INSERT INTO installations
(installation_hash, first_seen_at, last_seen_at, first_seen_day, app_version, platform, arch)
VALUES ('fixture-hash-b', 1, 2, '2026-08-14', '9.3.2-fixture', 'linux-fixture', 'aarch64-fixture')`,
),
env.DB.prepare(
`INSERT INTO daily_active (day, installation_hash, app_version, platform, arch)
VALUES ('2026-08-14', 'fixture-hash-a', '9.4.0-fixture', 'windows-fixture', 'x86_64-fixture')`,
),
env.DB.prepare(
`INSERT INTO daily_active (day, installation_hash, app_version, platform, arch)
VALUES ('2026-08-14', 'fixture-hash-b', '9.3.2-fixture', 'linux-fixture', 'aarch64-fixture')`,
),
env.DB.prepare(
`INSERT INTO error_reports
(event_id, installation_hash, day, occurred_at, fingerprint, app_version, platform, arch,
error_type, message, occurrence_count, object_key, created_at)
VALUES ('fixture-event-a', 'fixture-hash-a', '2026-08-14', '2026-08-14T08:00:00Z',
'fixture-render-01', '9.4.0-fixture', 'windows-fixture', 'x86_64-fixture',
'RenderFixtureError', 'Fixture render failure', 4,
'errors/2026-08-14/fixture-render-01/fixture-event-a.json.gz', 1)`,
),
env.DB.prepare(
`INSERT INTO error_context_reservations
(event_id, day, fingerprint, app_version, object_key, created_at)
VALUES ('fixture-event-a', '2026-08-14', 'fixture-render-01', '9.4.0-fixture',
'errors/2026-08-14/fixture-render-01/fixture-event-a.json.gz', 1)`,
),
env.DB.prepare(
`INSERT INTO error_daily
(day, fingerprint, app_version, occurrence_count, installation_count,
latest_error_type, latest_message, has_sample)
VALUES ('2026-08-14', 'fixture-render-01', '9.4.0-fixture', 4, 0,
'RenderFixtureError', 'Fixture render failure', 1)`,
),
env.DB.prepare(
`INSERT INTO error_daily_installations
(day, fingerprint, app_version, installation_hash)
VALUES ('2026-08-14', 'fixture-render-01', '9.4.0-fixture', 'fixture-hash-a')`,
),
env.DB.prepare(
`INSERT INTO daily_totals (day, new_installations, active_installations, error_occurrences, distinct_error_groups)
VALUES ('2026-08-13', 1, 0, 0, 0)`,
),
env.DB.prepare(`INSERT INTO platforms (platform) VALUES ('windows-fixture')`),
])
await env.ERROR_CONTEXTS.put(
'errors/2026-08-14/fixture-render-01/fixture-event-a.json.gz',
await gzip(
JSON.stringify({
occurred_at: '2026-08-14T08:00:00Z',
fingerprint: 'fixture-render-01',
app: {
version: '9.4.0-fixture',
platform: 'windows-fixture',
arch: 'x86_64-fixture',
},
error_type: 'RenderFixtureError',
message: 'Fixture render failure',
stack: 'Fixture stack',
route: '/fixture',
command: 'fixture-command',
context: 'synthetic fixture context',
installation_hash: 'must-not-leak',
object_key: 'must-not-leak',
}),
),
{ httpMetadata: { contentEncoding: 'gzip', contentType: 'application/json' } },
)
}
function api(analytics?: 'configured' | 'partial' | 'unconfigured'): D1TelemetryAdminApi {
const fetcher: typeof fetch = async (input, init) => {
const url = String(input)
if (url === 'https://fixture.invalid/health') {
return new Response(JSON.stringify({ status: 'ok' }))
}
if (url === ANALYTICS_URL) {
const query = JSON.parse(String(init?.body ?? '{}')).query as string
if (analytics === 'partial' && query.includes('workersInvocationsAdaptive')) {
return new Response(
JSON.stringify({ data: null, errors: [{ message: 'fixture analytics failure' }] }),
)
}
if (query.includes('workersInvocationsAdaptive')) {
return new Response(
JSON.stringify({
data: {
viewer: {
accounts: [{ workersInvocationsAdaptive: [{ sum: { requests: 12_345 } }] }],
},
},
}),
)
}
if (query.includes('d1AnalyticsAdaptiveGroups')) {
return new Response(
JSON.stringify({
data: {
viewer: {
accounts: [
{
d1AnalyticsAdaptiveGroups: [{ sum: { rowsRead: 45_012, rowsWritten: 3_201 } }],
},
],
},
},
}),
)
}
if (query.includes('r2OperationsAdaptiveGroups')) {
return new Response(
JSON.stringify({
data: {
viewer: {
accounts: [{ r2OperationsAdaptiveGroups: [{ sum: { requests: 8_412 } }] }],
},
},
}),
)
}
return new Response(
JSON.stringify({ data: null, errors: [{ message: 'unknown fixture query' }] }),
)
}
return new Response('not found', { status: 404 })
}
return new D1TelemetryAdminApi(env.DB, env.ERROR_CONTEXTS, {
storeErrorContext: true,
healthUrl: 'https://fixture.invalid/health',
fetcher,
now: () => NOW,
cacheTtlMs: 0,
analytics:
analytics === 'unconfigured'
? undefined
: { accountTag: FIXTURE_ACCOUNT_TAG, apiToken: 'fixture-analytics-token' },
})
}
describe('D1TelemetryAdminApi', () => {
beforeEach(seed)
it('maps D1 aggregates to stable overview and DAU/WAU/MAU DTOs', async () => {
const overview = await api().overview('30d')
expect(overview.metrics.totalInstallations.value).toBe(2)
expect(overview.metrics.dau.value).toBe(2)
expect(overview.metrics.wau.value).toBe(2)
expect(overview.metrics.mau.value).toBe(2)
expect(overview.metrics.errorOccurrences.value).toBe(4)
expect(JSON.stringify(overview)).not.toContain('installation_hash')
})
it('supports bound search, filters, sorting, and server pagination', async () => {
const result = await api().errors(
parseErrorQuery({
range: '30d',
search: 'render',
platform: 'windows-fixture',
page: '1',
pageSize: '25',
sort: 'occurrences',
direction: 'desc',
}),
)
expect(result.total).toBe(1)
expect(result.items[0]).toMatchObject({
fingerprint: 'fixture-render-01',
occurrenceCount: 4,
hasSample: true,
})
expect(JSON.stringify(result)).not.toMatch(/installation_hash|object_key/)
})
it('reads only a D1-registered sample and never returns internal keys', async () => {
await env.ERROR_CONTEXTS.put(
'errors/unregistered.json.gz',
await gzip('{"message":"unregistered"}'),
)
const registered = await api().errorSample('fixture-render-01')
expect(registered).toMatchObject({
fingerprint: 'fixture-render-01',
message: 'Fixture render failure',
})
expect(JSON.stringify(registered)).not.toMatch(/installation_hash|object_key|must-not-leak/)
expect(await api().errorSample('fixture-unregistered')).toBeNull()
})
it('reports the unconfigured state when analytics credentials are absent', async () => {
const system = await api('unconfigured').system()
expect(system.accountUsage).toMatchObject({ status: 'unavailable', label: '未配置' })
expect(system.accountUsage.detail).toContain('尚未配置 Cloudflare Analytics API')
})
it('aggregates Workers, D1, and R2 usage into one account usage check', async () => {
const system = await api('configured').system()
expect(system.accountUsage.status).toBe('available')
expect(system.accountUsage.detail).toContain('Workers 12,345/100,000 请求')
expect(system.accountUsage.detail).toContain('写 3,201/100,000 行')
expect(system.accountUsage.detail).toContain('R2 8,412/1,000,000 操作')
})
it('degrades when one analytics dataset fails but others respond', async () => {
const system = await api('partial').system()
expect(system.accountUsage).toMatchObject({ status: 'degraded', label: '部分可用' })
expect(system.accountUsage.detail).toContain('fixture analytics failure')
expect(system.accountUsage.detail).toContain('D1')
expect(system.accountUsage.detail).toContain('R2')
})
})

View File

@ -0,0 +1,31 @@
import { applyD1Migrations, type D1Migration, env } from 'cloudflare:test'
import { beforeAll, beforeEach } from 'vitest'
declare module 'cloudflare:test' {
interface ProvidedEnv {
DB: D1Database
ERROR_CONTEXTS: R2Bucket
TEST_MIGRATIONS: D1Migration[]
}
}
const tables = [
'error_context_reservations',
'error_context_samples',
'error_context_budget',
'error_reports',
'error_daily',
'error_groups',
'daily_active',
'daily_totals',
'accepted_batches',
'installations',
]
beforeAll(async () => {
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)
})
beforeEach(async () => {
await env.DB.batch(tables.map((table) => env.DB.prepare(`DELETE FROM ${table}`)))
})

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { parseErrorQuery, parseRange } from '../../server/utils/validation'
describe('admin query validation', () => {
it('allows only known UTC ranges', () => {
expect(parseRange('7d')).toBe('7d')
expect(parseRange(undefined)).toBe('30d')
expect(() => parseRange('31d')).toThrowError('Range must be 7d, 30d, 90d, or 365d')
})
it('bounds page size, search length, and sort fields', () => {
expect(parseErrorQuery({ page: '2', pageSize: '100', sort: 'occurrences' })).toMatchObject({
page: 2,
pageSize: 100,
sort: 'occurrences',
})
expect(() => parseErrorQuery({ pageSize: '101' })).toThrowError('Invalid query parameters')
expect(() => parseErrorQuery({ search: 'x'.repeat(121) })).toThrowError(
'Invalid query parameters',
)
expect(() => parseErrorQuery({ sort: 'object_key' })).toThrowError('Invalid query parameters')
})
})

View File

@ -0,0 +1,5 @@
export default {
fetch(): Response {
return new Response('fixture worker')
},
}

View File

@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppState from '../../components/AppState.vue'
import TrendChart from '../../components/charts/TrendChart.vue'
import Skeleton from '../../components/ui/Skeleton.vue'
describe('dashboard states', () => {
it.each([
['error', '遥测数据暂不可用'],
['unauthenticated', '需要登录'],
['forbidden', '无权访问'],
] as const)('renders the %s state', (kind, title) => {
const wrapper = mount(AppState, { props: { kind } })
expect(wrapper.attributes('data-state')).toBe(kind)
expect(wrapper.text()).toContain(title)
})
it('renders stable loading and empty chart states', () => {
expect(mount(Skeleton, { props: { class: 'h-14' } }).classes()).toContain('h-14')
const chart = mount(TrendChart, {
props: {
data: [],
series: [{ key: 'errorOccurrences', label: '错误次数', color: '#d85d4a' }],
},
})
expect(chart.get('[data-state="empty"]').text()).toContain('暂无数据')
})
})
describe('responsive theme contract', () => {
it('keeps dark tokens and narrow layouts from creating page overflow', () => {
const root = process.cwd()
const css = readFileSync(`${root}/assets/styles/tailwind.css`, 'utf8')
const layout = readFileSync(`${root}/layouts/default.vue`, 'utf8')
expect(css).toContain('.dark')
expect(css).toContain('overflow-x-hidden')
expect(layout).toContain('min-w-0 md:pl-60')
expect(layout).toContain('w-60 -translate-x-full')
expect(layout).toContain('生产数据')
})
})

View File

@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}

View File

@ -0,0 +1,7 @@
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
},
"exclude": ["test", "vitest.server.config.ts", "vitest.ui.config.ts"]
}

View File

@ -0,0 +1,7 @@
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers", "vitest/globals"]
},
"include": [".nuxt/**/*.d.ts", "server/**/*.ts", "shared/**/*.ts", "test/**/*.ts"]
}

View File

@ -0,0 +1,35 @@
export function formatNumber(value: number): string {
return new Intl.NumberFormat('zh-CN').format(value)
}
export function formatUtcDay(value: string): string {
const parsed = new Date(`${value}T00:00:00.000Z`)
return Number.isNaN(parsed.getTime())
? value
: new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
timeZone: 'UTC',
}).format(parsed)
}
export function formatUtcTimestamp(value: string): string {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime())
? value
: `${new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
}).format(parsed)}UTC`
}
export function statusCode(error: unknown): number | null {
if (!error || typeof error !== 'object') return null
const value = error as { statusCode?: number; status?: number }
return value.statusCode ?? value.status ?? null
}

View File

@ -0,0 +1,22 @@
import { defineWorkersConfig, readD1Migrations } from '@cloudflare/vitest-pool-workers/config'
const migrations = await readD1Migrations('../telemetry-worker/migrations')
export default defineWorkersConfig({
test: {
include: ['test/server/**/*.test.ts'],
setupFiles: ['./test/server/setup.ts'],
poolOptions: {
workers: {
main: './test/server/worker.ts',
singleWorker: true,
miniflare: {
compatibilityDate: '2025-08-13',
d1Databases: ['DB'],
r2Buckets: ['ERROR_CONTEXTS'],
bindings: { TEST_MIGRATIONS: migrations },
},
},
},
},
})

View File

@ -0,0 +1,17 @@
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'~': fileURLToPath(new URL('.', import.meta.url)),
},
},
test: {
environment: 'happy-dom',
include: ['test/ui/**/*.test.ts'],
},
})

View File

@ -0,0 +1,23 @@
name = "axolotl-telemetry-dashboard"
main = ".output/server/index.mjs"
compatibility_date = "2025-08-13"
compatibility_flags = ["nodejs_compat"]
workers_dev = false
[assets]
directory = ".output/public"
binding = "ASSETS"
[vars]
NUXT_PUBLIC_ADMIN_ORIGIN = "https://telemetry-api.axlmc.org"
NUXT_PUBLIC_WORKER_HEALTH_URL = "https://telemetry.axlmc.org/health"
[[d1_databases]]
binding = "DB"
database_name = "axolotl-telemetry"
database_id = "8692e2b1-6156-4b0b-94f5-6e7204c05dff"
remote = true
[observability]
enabled = true
head_sampling_rate = 0.01