From 47d7c9105b72cfb06933c4bb4e36bfba002182d7 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 16 Aug 2026 21:25:57 +0800 Subject: [PATCH] feat(ui): update ui style --- AGENTS.md | 11 +- README.md | 2 +- README.zh.md | 2 +- app/assets/css/main.css | 270 ++++++++++++++++++++- app/components/AdminHome.vue | 256 ++++++++++++++++++++ app/components/ConsolePage.vue | 353 ++++++++++++++++++---------- app/components/GroupEditor.vue | 310 ++++++++++++------------ app/components/MetricsPanel.vue | 257 ++++++++++++++++---- app/components/RouteCard.vue | 152 ++++++------ app/components/RouteEditor.vue | 283 +++++++++++----------- app/composables/useI18n.ts | 114 ++++++++- app/composables/useMetrics.ts | 5 +- docs/api/admin.md | 2 +- docs/zh/api/admin.md | 2 +- server/lib/config.ts | 2 +- server/lib/lib/message-tracker.ts | 2 +- server/lib/lib/send-log.ts | 18 +- server/lib/observability/metrics.ts | 61 ++--- server/lib/queue/delivery.ts | 4 +- server/lib/web/admin.ts | 7 +- server/lib/web/groups.ts | 14 +- server/lib/web/invites.ts | 2 +- server/lib/web/session.ts | 2 +- server/lib/webhook.ts | 2 +- tailwind.config.ts | 2 + tests/admin-api.test.ts | 6 +- tests/admin.test.ts | 2 + tests/delivery-metrics.test.ts | 4 +- tests/discord.test.ts | 2 + tests/install.test.ts | 4 +- tests/invites.test.ts | 3 +- tests/message-tracker.test.ts | 4 +- tests/queue-consumer.test.ts | 2 + tests/webhook-tenant.test.ts | 3 +- wrangler.jsonc | 3 +- 35 files changed, 1577 insertions(+), 591 deletions(-) create mode 100644 app/components/AdminHome.vue diff --git a/AGENTS.md b/AGENTS.md index 6dc6a12..42f20ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord - Per-group webhook ingress: optional `POST /webhook/{groupId}` with a per-group secret in KV (`tenant:{groupId}`) — Gitea/classic-GitHub/custom webhooks are verified against the group's secret instead of the operator's global ones; only that group's routes fire. The legacy `POST /webhook` (global secrets, all routes) stays untouched - GitHub App tenant isolation: `Group.installationId` binds a group to one GitHub App installation; events whose `payload.installation.id` differs are rejected at dispatch (hard isolation on top of the optional `owners` list). The App's Setup URL points at `GET /auth/github/install`, which renders a choice page (create `inst-{installationId}` or bind to a group the signed-in user owns, verified by role); `POST /auth/github/install/bind` performs the provisioning. `installation.created` webhook events auto-provision as a fallback (create `inst-{installationId}` or bind existing groups whose `owners` match the installing account) - GitHub OAuth: octokit (token is stored hashed for reverse lookup) -- Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist +- Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist; left sidebar navigation (Overview / Groups / Send Logs / Audit / Stats) + topbar, deep-linkable routes (`/admin` overview · `/admin/groups` · `/admin/logs` · `/admin/audit` · `/admin/metrics`); the overview dashboard (`AdminHome.vue`) aggregates delivery KPIs, a routes table and recent send logs; the stats page (`MetricsPanel.vue`) renders KPI cards plus per-platform/per-event ok/failed bar legends and a per-status breakdown, and filters by group (`?groupId=`); the route/group editors (`RouteEditor.vue` / `GroupEditor.vue`) are slide-in drawers with an eyebrow header and sectioned form bodies - Access control: every group has role-based members (`owner` / `admin` / `viewer`); super admins bypass; legacy `adminIds` are read as owners (backward compatible); owners manage members + invites; `owners` field stays super-only - Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry) - Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90) @@ -34,8 +34,9 @@ app/ # Vue 3 UI (Nuxt app dir) ├── app.vue # root component (NuxtPage) ├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue ├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA) -├── components/ # ConsolePage, RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel, -│ # SendLogs, AuditLog, AppToasts, LegalLayout +├── components/ # ConsolePage (sidebar shell + topbar), AdminHome (overview dashboard), +│ # RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel, +│ # SendLogs, AuditLog, MetricsPanel, AppToasts, LegalLayout ├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook ├── types.ts # shared client types (Route, Group, Filter, ...) └── utils/legal.ts # terms/privacy HTML bodies (zh/en) @@ -148,8 +149,8 @@ tests/__snapshots__/ # formatter snapshot golden files (toMatchSnapshot) - Route messages to Discord channels/threads and Telegram chats/topics via REST - Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking) - Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code) -- Aggregate delivery metrics (`server/lib/observability/metrics.ts`) from `send_logs` — totals, ok/failed counts + failure rate, per-platform/per-event/per-status breakdowns, average duration and attempts, recent failures -- Expose admin observability endpoints — `GET /admin/api/metrics` (delivery metrics, recent failures group-scoped for non-super) and `GET /admin/api/delivery/:deliveryId` (all send-log attempts for one delivery, group-scoped) — through the `/admin/api/[...slug]` catch-all route (`server/routes/admin/api/[...slug].ts`) that wires every admin API handler to its method+path +- Aggregate delivery metrics (`server/lib/observability/metrics.ts`) from `send_logs` — totals, ok/failed counts + failure rate, per-platform/per-event/per-status breakdowns, average duration and attempts, recent failures; `getDeliveryMetrics(db, groupId?)` scopes every query by `group_id` when a group is passed +- Expose admin observability endpoints — `GET /admin/api/metrics?groupId=` (delivery metrics, optional group scope; recent failures group-scoped for non-super) and `GET /admin/api/delivery/:deliveryId` (all send-log attempts for one delivery, group-scoped) — through the `/admin/api/[...slug]` catch-all route (`server/routes/admin/api/[...slug].ts`) that wires every admin API handler to its method+path - Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}` via `kvIdempotencyStore`) - Issue a per-request correlation id (`requestId`) in webhook responses and dispatch logs - When the `QUEUE` binding is present, enqueue each verified webhook as a single Queue message (`webhooker-delivery`) instead of dispatching inline; the consumer resolves the payload, re-scopes routes to the tenant group, and dispatches; retryable failures (5xx/network/429-exhaustion) are retried with exponential backoff (5s/30s/2m/10m) up to the queue `max_retries`, then the DLQ marks the delivery dead diff --git a/README.md b/README.md index 0e1a2d2..6f88f67 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-in - `GET /admin/api/me` — Current session / scope / roles - `GET /admin/api/logs` — Send logs (scoped) - `GET /admin/api/logs/:id` — Single send-log entry -- `GET /admin/api/metrics` — Delivery metrics (totals, failure rate, per-platform/event/status, recent failures) +- `GET /admin/api/metrics` — Delivery stats (totals, failure rate, per-platform/event/status, recent failures); optional `?groupId=` scope - `GET /admin/api/delivery/:deliveryId` — All send-log attempts for one delivery ## Setup Guides diff --git a/README.zh.md b/README.zh.md index 75f9b3f..59e3d13 100644 --- a/README.zh.md +++ b/README.zh.md @@ -152,7 +152,7 @@ bunx wrangler dev # 启动本地开发服务器 - `GET /admin/api/me` — 当前会话 / 权限范围 / 角色 - `GET /admin/api/logs` — 发送日志(按权限过滤) - `GET /admin/api/logs/:id` — 单条发送日志 -- `GET /admin/api/metrics` — 投递指标(总计、失败率、按平台/事件/状态、最近失败) +- `GET /admin/api/metrics` — 投递统计(总计、失败率、按平台/事件/状态、最近失败);可选 `?groupId=` 按分组过滤 - `GET /admin/api/delivery/:deliveryId` — 单次投递的全部发送日志 ## 配置教程 diff --git a/app/assets/css/main.css b/app/assets/css/main.css index f806797..c32e6da 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -35,6 +35,8 @@ --warn-dim: 254 243 199; --bad: 220 38 38; --bad-dim: 254 242 242; + --info: 37 99 235; + --info-dim: 239 246 255; --body-text: 48 54 61; --code-bg: 240 241 243; } @@ -68,6 +70,8 @@ --warn-dim: 42 36 16; --bad: 240 82 82; --bad-dim: 42 22 24; + --info: 59 130 246; + --info-dim: 23 37 84; --body-text: 195 202 214; --code-bg: 26 34 48; } @@ -241,6 +245,123 @@ @apply flex flex-shrink-0 gap-1.5; } + /* ---- Route cards ---- */ + .route-card { + @apply flex animate-rise items-start gap-4 rounded border border-border bg-surface p-4 transition-[border-color,opacity] duration-150; + } + + .route-card:hover { + @apply border-border-strong; + } + + .route-card.disabled { + @apply opacity-50; + } + + .route-card-main { + @apply min-w-0 flex-1 space-y-3; + } + + .route-card-header { + @apply flex flex-wrap items-center gap-2.5; + } + + .route-card-title { + @apply flex min-w-0 items-center gap-2; + } + + .route-card-badges { + @apply ml-auto flex flex-wrap items-center gap-1.5; + } + + .route-badge { + @apply inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[10.5px] font-semibold; + } + + .route-badge-dot { + @apply inline-block h-1.5 w-1.5 rounded-full; + } + + .route-badge-dc { + @apply bg-accent-dim/30 text-accent-text; + } + + .route-badge-tg { + @apply bg-info-dim/30 text-info; + } + + .route-badge-fallback { + @apply border-warn/30 bg-warn-dim/20 text-warn; + } + + .route-badge-stop { + @apply border-bad/30 bg-bad-dim/20 text-bad; + } + + .route-badge-role { + @apply border-accent/30 bg-accent-dim/20 text-accent-text; + } + + .route-card-filters { + @apply flex flex-wrap gap-1.5; + } + + .route-chip { + @apply inline-flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-0.5 text-[11px]; + } + + .route-chip-type { + @apply font-bold uppercase tracking-wider text-faint; + } + + .route-chip-val { + @apply font-medium text-text; + } + + .route-chip.exclude { + @apply border-bad/20 bg-bad-dim/20; + } + + .route-chip.exclude .route-chip-type { + @apply text-bad; + } + + .route-chip-empty { + @apply italic; + } + + .route-card-targets { + @apply space-y-1.5; + } + + .route-target { + @apply space-y-1; + } + + .route-target-row { + @apply flex items-center gap-2 text-xs; + } + + .route-target-label { + @apply shrink-0 rounded border border-border bg-surface-2 px-1.5 py-px text-[10px] font-bold uppercase tracking-wider text-faint; + } + + .route-target-id { + @apply rounded-[5px] bg-surface-2 px-1.5 py-px font-mono text-[11.5px] leading-relaxed text-text; + } + + .route-card-actions { + @apply flex shrink-0 flex-col gap-0.5; + } + + .route-action-btn { + @apply flex h-7 w-7 items-center justify-center rounded text-muted transition-colors hover:bg-surface-2 hover:text-text disabled:pointer-events-none disabled:opacity-25; + } + + .route-action-btn-danger:hover { + @apply bg-bad-dim/30 text-bad; + } + /* ---- Toggle switch ---- */ .switch { @apply relative h-[22px] w-[38px] flex-shrink-0; @@ -353,19 +474,49 @@ } .editor-head { - @apply flex items-center justify-between border-b border-border px-6 py-5; + @apply flex items-center justify-between gap-3 border-b border-border px-6 py-4; + background: var(--header-bg); + backdrop-filter: blur(12px); + } + + .editor-heading { + @apply flex min-w-0 flex-col gap-0.5; + } + + .editor-eyebrow { + @apply text-[10px] font-bold uppercase tracking-[2px] text-accent; } .editor-head h2 { - @apply text-lg font-extrabold tracking-tight; + @apply truncate text-base font-extrabold tracking-tight; + } + + .editor-head .icon-btn { + @apply h-8 w-8 rounded-full; } .editor-body { - @apply flex-1 overflow-y-auto px-6 py-[22px]; + @apply flex-1 overflow-y-auto px-6 py-5; + scrollbar-width: thin; + scrollbar-color: var(--scrim) transparent; + } + + .editor-section { + @apply mb-7; + } + + .editor-section-title { + @apply mb-3.5 flex items-center gap-2.5 text-[10.5px] font-bold uppercase tracking-[1.5px] text-faint; + } + + .editor-section-title::after { + content: ""; + @apply h-px flex-1 bg-border; } .editor-foot { - @apply flex justify-end gap-2 border-t border-border px-6 py-4; + @apply flex justify-end gap-2 border-t border-border bg-surface px-6 py-4; + box-shadow: 0 -8px 24px -16px rgba(15, 23, 42, 0.18); } /* ---- Forms ---- */ @@ -822,6 +973,117 @@ .webhook-panel .wh-code { @apply mt-2.5 overflow-x-auto rounded-sm border border-border bg-surface-2 p-3 text-[11.5px] leading-relaxed text-muted; } + + /* ---- Metrics / stats ---- */ + .metric-kpis { + @apply mb-6 grid grid-cols-2 gap-3 max-md:grid-cols-1 sm:grid-cols-3; + } + + .metric-kpi { + @apply rounded-sm border border-border bg-surface p-4; + } + + .metric-kpi-value { + @apply block text-2xl font-extrabold tracking-tight tabular-nums; + } + + .metric-kpi-label { + @apply mt-1 block text-[11px] font-bold uppercase tracking-[1.2px] text-faint; + } + + .metric-kpi.accent .metric-kpi-value { + @apply text-accent; + } + + .metric-kpi.ok .metric-kpi-value { + @apply text-ok; + } + + .metric-kpi.bad { + @apply border-bad/30 bg-bad/5; + } + + .metric-kpi.bad .metric-kpi-value { + @apply text-bad; + } + + .metric-kpi.warn .metric-kpi-value { + @apply text-warn; + } + + .metric-kpi.info .metric-kpi-value { + @apply text-info; + } + + .metric-block { + @apply mb-4 rounded border border-border bg-surface p-5; + } + + .metric-block-title { + @apply m-0 text-[11px] font-bold uppercase tracking-[1.2px] text-faint; + } + + .metric-block-head { + @apply mb-3 flex items-center justify-between gap-2; + } + + .metric-block-head .metric-block-title { + @apply mb-0; + } + + .metric-sort-toggle { + @apply rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-faint transition-colors; + } + + .metric-sort-toggle:hover { + @apply bg-surface-3 text-text; + } + + .metric-row { + @apply flex items-center gap-3 border-t border-border py-2.5 first:border-t-0 max-sm:flex-wrap; + } + + .metric-key { + @apply w-[110px] shrink-0 truncate font-mono text-[12.5px] text-text; + } + + .metric-total { + @apply w-12 shrink-0 text-right text-xs font-semibold tabular-nums text-muted; + } + + .metric-bar { + @apply flex h-5 flex-1 overflow-hidden rounded bg-surface-3; + } + + .metric-fill { + @apply flex items-center justify-center text-[11px] font-bold tabular-nums leading-none text-white/90; + min-width: 0; + transition: flex 0.3s; + } + + .metric-fill.ok { + @apply bg-ok; + } + + .metric-fill.bad { + @apply bg-bad; + } + + .metric-status-row { + @apply flex flex-wrap gap-1.5; + } + + .metric-status { + @apply rounded-full px-2.5 py-0.5 text-[11px] font-bold tabular-nums tracking-wide; + } + + .metric-status.ok { + @apply bg-ok-dim text-ok; + } + + .metric-status.bad { + @apply bg-bad-dim text-bad; + } } /* ---- Vue transition glue (lifecycle classes) ---- */ diff --git a/app/components/AdminHome.vue b/app/components/AdminHome.vue new file mode 100644 index 0000000..3b4fbf6 --- /dev/null +++ b/app/components/AdminHome.vue @@ -0,0 +1,256 @@ + + + diff --git a/app/components/ConsolePage.vue b/app/components/ConsolePage.vue index 3f3b0ed..1bc701f 100644 --- a/app/components/ConsolePage.vue +++ b/app/components/ConsolePage.vue @@ -1,123 +1,181 @@