feat:移除了弹窗,服务器添加sls
This commit is contained in:
9
apps/telemetry-dashboard/.dev.vars.example
Normal file
9
apps/telemetry-dashboard/.dev.vars.example
Normal 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
2
apps/telemetry-dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.vercel
|
||||
.env*
|
||||
5
apps/telemetry-dashboard/.prettierignore
Normal file
5
apps/telemetry-dashboard/.prettierignore
Normal file
@ -0,0 +1,5 @@
|
||||
.nuxt/
|
||||
.output/
|
||||
.wrangler/
|
||||
node_modules/
|
||||
coverage/
|
||||
106
apps/telemetry-dashboard/README.md
Normal file
106
apps/telemetry-dashboard/README.md
Normal 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.
|
||||
6
apps/telemetry-dashboard/app.vue
Normal file
6
apps/telemetry-dashboard/app.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
162
apps/telemetry-dashboard/assets/styles/tailwind.css
Normal file
162
apps/telemetry-dashboard/assets/styles/tailwind.css
Normal 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));
|
||||
}
|
||||
16
apps/telemetry-dashboard/components.json
Normal file
16
apps/telemetry-dashboard/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
apps/telemetry-dashboard/components/AppState.vue
Normal file
57
apps/telemetry-dashboard/components/AppState.vue
Normal 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>
|
||||
50
apps/telemetry-dashboard/components/MetricCard.vue
Normal file
50
apps/telemetry-dashboard/components/MetricCard.vue
Normal 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>
|
||||
13
apps/telemetry-dashboard/components/PageHeader.vue
Normal file
13
apps/telemetry-dashboard/components/PageHeader.vue
Normal 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>
|
||||
@ -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>
|
||||
87
apps/telemetry-dashboard/components/charts/TrendChart.vue
Normal file
87
apps/telemetry-dashboard/components/charts/TrendChart.vue
Normal 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>
|
||||
34
apps/telemetry-dashboard/components/system/StatusRow.vue
Normal file
34
apps/telemetry-dashboard/components/system/StatusRow.vue
Normal 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>
|
||||
30
apps/telemetry-dashboard/components/ui/Alert.vue
Normal file
30
apps/telemetry-dashboard/components/ui/Alert.vue
Normal 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>
|
||||
33
apps/telemetry-dashboard/components/ui/Badge.vue
Normal file
33
apps/telemetry-dashboard/components/ui/Badge.vue
Normal 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>
|
||||
45
apps/telemetry-dashboard/components/ui/Button.vue
Normal file
45
apps/telemetry-dashboard/components/ui/Button.vue
Normal 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>
|
||||
20
apps/telemetry-dashboard/components/ui/Card.vue
Normal file
20
apps/telemetry-dashboard/components/ui/Card.vue
Normal 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>
|
||||
66
apps/telemetry-dashboard/components/ui/Command.vue
Normal file
66
apps/telemetry-dashboard/components/ui/Command.vue
Normal 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>
|
||||
79
apps/telemetry-dashboard/components/ui/DropdownMenu.vue
Normal file
79
apps/telemetry-dashboard/components/ui/DropdownMenu.vue
Normal 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>
|
||||
27
apps/telemetry-dashboard/components/ui/Select.vue
Normal file
27
apps/telemetry-dashboard/components/ui/Select.vue
Normal 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>
|
||||
44
apps/telemetry-dashboard/components/ui/Sheet.vue
Normal file
44
apps/telemetry-dashboard/components/ui/Sheet.vue
Normal 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>
|
||||
9
apps/telemetry-dashboard/components/ui/Skeleton.vue
Normal file
9
apps/telemetry-dashboard/components/ui/Skeleton.vue
Normal 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>
|
||||
30
apps/telemetry-dashboard/components/ui/Tabs.vue
Normal file
30
apps/telemetry-dashboard/components/ui/Tabs.vue
Normal 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>
|
||||
15
apps/telemetry-dashboard/components/ui/Tooltip.vue
Normal file
15
apps/telemetry-dashboard/components/ui/Tooltip.vue
Normal 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>
|
||||
9
apps/telemetry-dashboard/composables/use-dashboard.ts
Normal file
9
apps/telemetry-dashboard/composables/use-dashboard.ts
Normal 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)
|
||||
}
|
||||
23
apps/telemetry-dashboard/composables/use-theme.ts
Normal file
23
apps/telemetry-dashboard/composables/use-theme.ts
Normal 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 }
|
||||
}
|
||||
10
apps/telemetry-dashboard/eslint.config.mjs
Normal file
10
apps/telemetry-dashboard/eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
])
|
||||
229
apps/telemetry-dashboard/layouts/default.vue
Normal file
229
apps/telemetry-dashboard/layouts/default.vue
Normal 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>
|
||||
6
apps/telemetry-dashboard/lib/utils.ts
Normal file
6
apps/telemetry-dashboard/lib/utils.ts
Normal 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))
|
||||
}
|
||||
70
apps/telemetry-dashboard/nuxt.config.ts
Normal file
70
apps/telemetry-dashboard/nuxt.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
51
apps/telemetry-dashboard/package.json
Normal file
51
apps/telemetry-dashboard/package.json
Normal 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"
|
||||
}
|
||||
159
apps/telemetry-dashboard/pages/index.vue
Normal file
159
apps/telemetry-dashboard/pages/index.vue
Normal 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>
|
||||
52
apps/telemetry-dashboard/pages/system.vue
Normal file
52
apps/telemetry-dashboard/pages/system.vue
Normal 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>
|
||||
@ -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)),
|
||||
)
|
||||
@ -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)),
|
||||
)
|
||||
@ -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)),
|
||||
)
|
||||
8
apps/telemetry-dashboard/server/api/admin/session.get.ts
Normal file
8
apps/telemetry-dashboard/server/api/admin/session.get.ts
Normal 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' }
|
||||
})
|
||||
4
apps/telemetry-dashboard/server/api/admin/system.get.ts
Normal file
4
apps/telemetry-dashboard/server/api/admin/system.get.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { defineAdminHandler } from '../../utils/handler'
|
||||
import { getAdminApi } from '../../utils/service'
|
||||
|
||||
export default defineAdminHandler(async (event) => getAdminApi(event).system())
|
||||
26
apps/telemetry-dashboard/server/dev/remote-bindings.ts
Normal file
26
apps/telemetry-dashboard/server/dev/remote-bindings.ts
Normal 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
|
||||
})
|
||||
})
|
||||
36
apps/telemetry-dashboard/server/middleware/admin-auth.ts
Normal file
36
apps/telemetry-dashboard/server/middleware/admin-auth.ts
Normal 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
|
||||
}
|
||||
})
|
||||
21
apps/telemetry-dashboard/server/utils/admin-api.ts
Normal file
21
apps/telemetry-dashboard/server/utils/admin-api.ts
Normal 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>
|
||||
}
|
||||
11
apps/telemetry-dashboard/server/utils/admin-http-error.ts
Normal file
11
apps/telemetry-dashboard/server/utils/admin-http-error.ts
Normal 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 } },
|
||||
})
|
||||
}
|
||||
78
apps/telemetry-dashboard/server/utils/auth.ts
Normal file
78
apps/telemetry-dashboard/server/utils/auth.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
11
apps/telemetry-dashboard/server/utils/bindings.ts
Normal file
11
apps/telemetry-dashboard/server/utils/bindings.ts
Normal 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) ?? {}
|
||||
}
|
||||
838
apps/telemetry-dashboard/server/utils/d1-admin-api.ts
Normal file
838
apps/telemetry-dashboard/server/utils/d1-admin-api.ts
Normal 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_TOKEN(Account 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 }
|
||||
14
apps/telemetry-dashboard/server/utils/errors.ts
Normal file
14
apps/telemetry-dashboard/server/utils/errors.ts
Normal 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', '遥测数据暂不可用')
|
||||
33
apps/telemetry-dashboard/server/utils/handler.ts
Normal file
33
apps/telemetry-dashboard/server/utils/handler.ts
Normal 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: '遥测数据暂不可用',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
232
apps/telemetry-dashboard/server/utils/mock-admin-api.ts
Normal file
232
apps/telemetry-dashboard/server/utils/mock-admin-api.ts
Normal 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-14(UTC)' },
|
||||
accountUsage: {
|
||||
status: 'unavailable',
|
||||
label: '不可用',
|
||||
detail: '尚未配置 Analytics API',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apps/telemetry-dashboard/server/utils/service.ts
Normal file
48
apps/telemetry-dashboard/server/utils/service.ts
Normal 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
|
||||
}
|
||||
99
apps/telemetry-dashboard/server/utils/validation.ts
Normal file
99
apps/telemetry-dashboard/server/utils/validation.ts
Normal 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)
|
||||
}
|
||||
153
apps/telemetry-dashboard/server/utils/vercel-data-source.ts
Normal file
153
apps/telemetry-dashboard/server/utils/vercel-data-source.ts
Normal 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
|
||||
}
|
||||
141
apps/telemetry-dashboard/shared/types/telemetry.ts
Normal file
141
apps/telemetry-dashboard/shared/types/telemetry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
60
apps/telemetry-dashboard/tailwind.config.ts
Normal file
60
apps/telemetry-dashboard/tailwind.config.ts
Normal 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
|
||||
65
apps/telemetry-dashboard/test/server/auth.test.ts
Normal file
65
apps/telemetry-dashboard/test/server/auth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
233
apps/telemetry-dashboard/test/server/d1-admin-api.test.ts
Normal file
233
apps/telemetry-dashboard/test/server/d1-admin-api.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
31
apps/telemetry-dashboard/test/server/setup.ts
Normal file
31
apps/telemetry-dashboard/test/server/setup.ts
Normal 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}`)))
|
||||
})
|
||||
24
apps/telemetry-dashboard/test/server/validation.test.ts
Normal file
24
apps/telemetry-dashboard/test/server/validation.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
5
apps/telemetry-dashboard/test/server/worker.ts
Normal file
5
apps/telemetry-dashboard/test/server/worker.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
fetch(): Response {
|
||||
return new Response('fixture worker')
|
||||
},
|
||||
}
|
||||
44
apps/telemetry-dashboard/test/ui/states.test.ts
Normal file
44
apps/telemetry-dashboard/test/ui/states.test.ts
Normal 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('生产数据')
|
||||
})
|
||||
})
|
||||
6
apps/telemetry-dashboard/test/vue-shim.d.ts
vendored
Normal file
6
apps/telemetry-dashboard/test/vue-shim.d.ts
vendored
Normal 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
|
||||
}
|
||||
7
apps/telemetry-dashboard/tsconfig.json
Normal file
7
apps/telemetry-dashboard/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"exclude": ["test", "vitest.server.config.ts", "vitest.ui.config.ts"]
|
||||
}
|
||||
7
apps/telemetry-dashboard/tsconfig.test.json
Normal file
7
apps/telemetry-dashboard/tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
35
apps/telemetry-dashboard/utils/format.ts
Normal file
35
apps/telemetry-dashboard/utils/format.ts
Normal 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
|
||||
}
|
||||
22
apps/telemetry-dashboard/vitest.server.config.ts
Normal file
22
apps/telemetry-dashboard/vitest.server.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
17
apps/telemetry-dashboard/vitest.ui.config.ts
Normal file
17
apps/telemetry-dashboard/vitest.ui.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
23
apps/telemetry-dashboard/wrangler.toml
Normal file
23
apps/telemetry-dashboard/wrangler.toml
Normal 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
|
||||
Reference in New Issue
Block a user