mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(ui): update ui style
This commit is contained in:
parent
ca6dfd3f64
commit
47d7c9105b
35 changed files with 1577 additions and 591 deletions
11
AGENTS.md
11
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
|
- 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 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)
|
- 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
|
- 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)
|
- 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)
|
- 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)
|
├── app.vue # root component (NuxtPage)
|
||||||
├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue
|
├── 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)
|
├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA)
|
||||||
├── components/ # ConsolePage, RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel,
|
├── components/ # ConsolePage (sidebar shell + topbar), AdminHome (overview dashboard),
|
||||||
│ # SendLogs, AuditLog, AppToasts, LegalLayout
|
│ # RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel,
|
||||||
|
│ # SendLogs, AuditLog, MetricsPanel, AppToasts, LegalLayout
|
||||||
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook
|
├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook
|
||||||
├── types.ts # shared client types (Route, Group, Filter, ...)
|
├── types.ts # shared client types (Route, Group, Filter, ...)
|
||||||
└── utils/legal.ts # terms/privacy HTML bodies (zh/en)
|
└── 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
|
- 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)
|
- 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)
|
- 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
|
- 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` (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
|
- 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`)
|
- 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
|
- 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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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/me` — Current session / scope / roles
|
||||||
- `GET /admin/api/logs` — Send logs (scoped)
|
- `GET /admin/api/logs` — Send logs (scoped)
|
||||||
- `GET /admin/api/logs/:id` — Single send-log entry
|
- `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
|
- `GET /admin/api/delivery/:deliveryId` — All send-log attempts for one delivery
|
||||||
|
|
||||||
## Setup Guides
|
## Setup Guides
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ bunx wrangler dev # 启动本地开发服务器
|
||||||
- `GET /admin/api/me` — 当前会话 / 权限范围 / 角色
|
- `GET /admin/api/me` — 当前会话 / 权限范围 / 角色
|
||||||
- `GET /admin/api/logs` — 发送日志(按权限过滤)
|
- `GET /admin/api/logs` — 发送日志(按权限过滤)
|
||||||
- `GET /admin/api/logs/:id` — 单条发送日志
|
- `GET /admin/api/logs/:id` — 单条发送日志
|
||||||
- `GET /admin/api/metrics` — 投递指标(总计、失败率、按平台/事件/状态、最近失败)
|
- `GET /admin/api/metrics` — 投递统计(总计、失败率、按平台/事件/状态、最近失败);可选 `?groupId=` 按分组过滤
|
||||||
- `GET /admin/api/delivery/:deliveryId` — 单次投递的全部发送日志
|
- `GET /admin/api/delivery/:deliveryId` — 单次投递的全部发送日志
|
||||||
|
|
||||||
## 配置教程
|
## 配置教程
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@
|
||||||
--warn-dim: 254 243 199;
|
--warn-dim: 254 243 199;
|
||||||
--bad: 220 38 38;
|
--bad: 220 38 38;
|
||||||
--bad-dim: 254 242 242;
|
--bad-dim: 254 242 242;
|
||||||
|
--info: 37 99 235;
|
||||||
|
--info-dim: 239 246 255;
|
||||||
--body-text: 48 54 61;
|
--body-text: 48 54 61;
|
||||||
--code-bg: 240 241 243;
|
--code-bg: 240 241 243;
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +70,8 @@
|
||||||
--warn-dim: 42 36 16;
|
--warn-dim: 42 36 16;
|
||||||
--bad: 240 82 82;
|
--bad: 240 82 82;
|
||||||
--bad-dim: 42 22 24;
|
--bad-dim: 42 22 24;
|
||||||
|
--info: 59 130 246;
|
||||||
|
--info-dim: 23 37 84;
|
||||||
--body-text: 195 202 214;
|
--body-text: 195 202 214;
|
||||||
--code-bg: 26 34 48;
|
--code-bg: 26 34 48;
|
||||||
}
|
}
|
||||||
|
|
@ -241,6 +245,123 @@
|
||||||
@apply flex flex-shrink-0 gap-1.5;
|
@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 ---- */
|
/* ---- Toggle switch ---- */
|
||||||
.switch {
|
.switch {
|
||||||
@apply relative h-[22px] w-[38px] flex-shrink-0;
|
@apply relative h-[22px] w-[38px] flex-shrink-0;
|
||||||
|
|
@ -353,19 +474,49 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-head {
|
.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 {
|
.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 {
|
.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 {
|
.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 ---- */
|
/* ---- Forms ---- */
|
||||||
|
|
@ -822,6 +973,117 @@
|
||||||
.webhook-panel .wh-code {
|
.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;
|
@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) ---- */
|
/* ---- Vue transition glue (lifecycle classes) ---- */
|
||||||
|
|
|
||||||
256
app/components/AdminHome.vue
Normal file
256
app/components/AdminHome.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DeliveryMetrics, Group, Route, SendRecord } from "~/types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
groups: Group[];
|
||||||
|
logs: SendRecord[];
|
||||||
|
metrics: DeliveryMetrics | null;
|
||||||
|
groupsLoading: boolean;
|
||||||
|
logsLoading: boolean;
|
||||||
|
metricsLoading: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const routesByGroup = ref<Record<string, Route[]>>({});
|
||||||
|
const routesLoading = ref(false);
|
||||||
|
|
||||||
|
const allRoutes = computed(() => {
|
||||||
|
const out: Array<Route & { group?: Group }> = [];
|
||||||
|
for (const g of props.groups) {
|
||||||
|
for (const r of routesByGroup.value[g.id] ?? []) out.push({ ...r, group: g });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRoutes() {
|
||||||
|
if (!props.groups.length) return;
|
||||||
|
routesLoading.value = true;
|
||||||
|
const entries = await Promise.all(
|
||||||
|
props.groups.map(async (g) => {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ routes?: Route[] }>(
|
||||||
|
`/admin/api/groups/${encodeURIComponent(g.id)}/routes`,
|
||||||
|
);
|
||||||
|
return [g.id, data.routes ?? []] as const;
|
||||||
|
} catch {
|
||||||
|
return [g.id, []] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
routesByGroup.value = Object.fromEntries(entries);
|
||||||
|
routesLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRoutes);
|
||||||
|
watch(() => props.groups, loadRoutes);
|
||||||
|
|
||||||
|
const kpis = computed(() => {
|
||||||
|
const m = props.metrics;
|
||||||
|
const total = m?.total ?? 0;
|
||||||
|
const ok = m?.ok ?? 0;
|
||||||
|
const rate = total ? (ok / total) * 100 : 0;
|
||||||
|
return [
|
||||||
|
{ label: t("metrics.total"), value: total.toLocaleString(), tone: "accent" },
|
||||||
|
{ label: t("metrics.successRate"), value: `${rate.toFixed(1)}%`, tone: "ok" },
|
||||||
|
{ label: t("metrics.failed"), value: (m?.failed ?? 0).toLocaleString(), tone: "bad" },
|
||||||
|
{
|
||||||
|
label: t("metrics.avgDuration"),
|
||||||
|
value: `${(m?.avgDurationMs ?? 0).toFixed(0)}ms`,
|
||||||
|
tone: "muted",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const KPI_TEXT: Record<string, string> = {
|
||||||
|
accent: "text-accent",
|
||||||
|
ok: "text-ok",
|
||||||
|
bad: "text-bad",
|
||||||
|
muted: "text-text",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_TONES: Record<string, string> = {
|
||||||
|
push: "ok",
|
||||||
|
pull_request: "accent",
|
||||||
|
issues: "warn",
|
||||||
|
workflow_run: "info",
|
||||||
|
check_suite: "info",
|
||||||
|
check_run: "info",
|
||||||
|
release: "ok",
|
||||||
|
deployment: "accent",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_BADGE: Record<string, string> = {
|
||||||
|
ok: "bg-ok-dim text-ok",
|
||||||
|
accent: "bg-accent-dim text-accent",
|
||||||
|
warn: "bg-warn-dim text-warn",
|
||||||
|
info: "bg-info-dim text-info",
|
||||||
|
bad: "bg-bad-dim text-bad",
|
||||||
|
muted: "bg-surface-2 text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
function eventBadge(ev: string): string {
|
||||||
|
return EVENT_BADGE[EVENT_TONES[ev] ?? "muted"] ?? EVENT_BADGE.muted!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number): string {
|
||||||
|
const d = Date.now() - ts;
|
||||||
|
if (d < 60_000) return `${Math.max(1, Math.round(d / 1000))}s`;
|
||||||
|
if (d < 3_600_000) return `${Math.round(d / 60_000)}m`;
|
||||||
|
if (d < 86_400_000) return `${Math.round(d / 3_600_000)}h`;
|
||||||
|
return `${Math.round(d / 86_400_000)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(p?: string): string {
|
||||||
|
return p === "telegram" ? "TG" : p === "discord" ? "DC" : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeEvents(r: Route): string[] {
|
||||||
|
return r.filters
|
||||||
|
.filter((f) => f.type === "event")
|
||||||
|
.flatMap((f) => (Array.isArray(f.match) ? f.match : [f.match]));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- KPI cards -->
|
||||||
|
<section class="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||||
|
<div v-for="k in kpis" :key="k.label" class="rounded border border-border bg-surface p-5">
|
||||||
|
<div class="text-[11px] font-bold uppercase tracking-[1.5px] text-faint">{{ k.label }}</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 text-2xl font-extrabold tracking-tight [font-variant-numeric:tabular-nums]"
|
||||||
|
:class="KPI_TEXT[k.tone] ?? KPI_TEXT.muted"
|
||||||
|
>
|
||||||
|
{{ metricsLoading ? "—" : k.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
|
<!-- Routes overview -->
|
||||||
|
<section class="rounded border border-border bg-surface xl:col-span-2">
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<h2 class="text-sm font-bold tracking-tight">{{ t("overview.routes") }}</h2>
|
||||||
|
<span class="text-xs text-faint [font-variant-numeric:tabular-nums]">{{
|
||||||
|
allRoutes.length
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="routesLoading" class="px-5 py-12 text-center text-faint">
|
||||||
|
{{ t("status.loading") }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!allRoutes.length" class="px-5 py-12 text-center text-muted">
|
||||||
|
<p class="mb-3">{{ t("routes.emptyGroup") }}</p>
|
||||||
|
<button class="btn btn-accent btn-sm" @click="router.replace('/admin/groups')">
|
||||||
|
{{ t("routes.createFirst") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full min-w-[560px] text-left text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
class="border-b border-border text-[10px] font-bold uppercase tracking-[1.2px] text-faint"
|
||||||
|
>
|
||||||
|
<th class="px-5 py-2.5">Route</th>
|
||||||
|
<th class="px-3 py-2.5">Group</th>
|
||||||
|
<th class="px-3 py-2.5">Events</th>
|
||||||
|
<th class="px-3 py-2.5">Targets</th>
|
||||||
|
<th class="px-5 py-2.5 text-right">State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="r in allRoutes.slice(0, 8)"
|
||||||
|
:key="r.id"
|
||||||
|
class="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-surface-2"
|
||||||
|
@click="router.replace('/admin/groups')"
|
||||||
|
>
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<div class="font-semibold text-text">{{ r.name || r.id }}</div>
|
||||||
|
<div class="font-mono text-[11px] text-faint">{{ r.id }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3 text-muted">{{ r.group?.name || "—" }}</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="ev in routeEvents(r).slice(0, 2)"
|
||||||
|
:key="ev"
|
||||||
|
class="rounded-full bg-accent-dim px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-accent"
|
||||||
|
>
|
||||||
|
{{ ev }}
|
||||||
|
</span>
|
||||||
|
<span v-if="routeEvents(r).length > 2" class="text-[10px] text-faint">
|
||||||
|
+{{ routeEvents(r).length - 2 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-3">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
v-for="tg in r.targets.slice(0, 3)"
|
||||||
|
:key="tg.channelId ?? tg.chatId ?? platformLabel(tg.platform)"
|
||||||
|
class="rounded border border-border bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
||||||
|
>
|
||||||
|
{{ platformLabel(tg.platform) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="r.targets.length > 3" class="text-[10px] text-faint">
|
||||||
|
+{{ r.targets.length - 3 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-5 py-3 text-right">
|
||||||
|
<span class="dot" :class="r.enabled ? 'ok' : 'bad'" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent send logs -->
|
||||||
|
<section class="rounded border border-border bg-surface">
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<h2 class="text-sm font-bold tracking-tight">{{ t("overview.recent") }}</h2>
|
||||||
|
<button class="text-xs font-semibold text-accent" @click="router.replace('/admin/logs')">
|
||||||
|
{{ t("overview.viewAll") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="logsLoading" class="px-5 py-12 text-center text-faint">
|
||||||
|
{{ t("status.loading") }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!logs.length" class="px-5 py-12 text-center text-faint">
|
||||||
|
{{ t("metrics.empty") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="divide-y divide-border">
|
||||||
|
<li
|
||||||
|
v-for="log in logs.slice(0, 10)"
|
||||||
|
:key="log.id ?? `${log.ts}-${log.target}`"
|
||||||
|
class="flex items-center gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<span class="dot" :class="log.ok ? 'ok' : 'bad'" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide"
|
||||||
|
:class="eventBadge(log.event)"
|
||||||
|
>
|
||||||
|
{{ log.event }}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-[12px] text-muted">{{ log.repo || log.target }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate font-mono text-[11px] text-faint">{{ log.target }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] font-medium text-faint [font-variant-numeric:tabular-nums]">
|
||||||
|
{{ fmtTime(log.ts) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,123 +1,181 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="shell">
|
<div class="flex min-h-screen bg-bg text-text">
|
||||||
<header class="header">
|
<!-- Sidebar -->
|
||||||
<div class="brand">
|
<aside
|
||||||
|
class="sticky top-0 z-30 flex h-screen w-[240px] flex-shrink-0 flex-col border-r border-border bg-surface max-lg:hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 px-5 py-5">
|
||||||
<div class="brand-mark">WH</div>
|
<div class="brand-mark">WH</div>
|
||||||
<div>
|
<div>
|
||||||
<h1>WebHooker</h1>
|
<div class="text-sm font-extrabold tracking-tight">WebHooker</div>
|
||||||
<span class="tagline">{{ t("app.tagline") }}</span>
|
<div class="text-[10px] font-semibold uppercase tracking-[2px] text-faint">
|
||||||
</div>
|
{{ t("app.tagline") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
|
|
||||||
<button
|
|
||||||
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
|
|
||||||
class="btn btn-accent"
|
|
||||||
@click="openNew"
|
|
||||||
>
|
|
||||||
{{ t("app.newRoute") }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="!needLogin && !selectedGroup && view === 'groups' && isSuper"
|
|
||||||
class="btn btn-accent"
|
|
||||||
@click="openNewGroup"
|
|
||||||
>
|
|
||||||
{{ t("app.newGroup") }}
|
|
||||||
</button>
|
|
||||||
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">{{ t("app.signOut") }}</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<div v-if="needLogin" class="login">
|
|
||||||
<div class="login-card">
|
|
||||||
<h2>{{ t("login.title") }}</h2>
|
|
||||||
<p v-if="forbidden">{{ t("login.forbidden") }}</p>
|
|
||||||
<p v-else>{{ t("login.prompt") }}</p>
|
|
||||||
<a class="btn btn-accent btn-lg" href="/admin/login">{{ t("login.button") }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<nav class="flex-1 space-y-0.5 px-3">
|
||||||
<!-- Group detail view -->
|
<button
|
||||||
<template v-if="selectedGroup">
|
v-for="n in nav"
|
||||||
<section class="toolbar">
|
:key="n.id"
|
||||||
<div class="crumbs">
|
class="flex w-full items-center gap-3 rounded-[8px] px-3 py-2 text-[13px] font-semibold transition-colors"
|
||||||
<button class="btn btn-ghost btn-sm" @click="exitGroup">{{ t("group.back") }}</button>
|
:class="
|
||||||
<span class="kpi-label">{{ t("group.routesIn", { name: selectedGroup.name }) }}</span>
|
n.id === activeNav
|
||||||
<span class="kpi">{{ groupRoutes.length }}</span>
|
? 'bg-accent-dim text-accent'
|
||||||
</div>
|
: 'text-muted hover:bg-surface-2 hover:text-text'
|
||||||
<div class="status">
|
"
|
||||||
<span class="dot" :class="groupRoutesLoading ? '' : 'ok'"></span>
|
@click="switchView(n.id)"
|
||||||
<span>{{ groupRoutesLoading ? t("status.loading") : t("status.connected") }}</span>
|
>
|
||||||
</div>
|
<span
|
||||||
</section>
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="n.id === activeNav ? 'bg-accent' : 'bg-border-strong'"
|
||||||
<p v-if="groupRoutesError" class="err">{{ groupRoutesError }}</p>
|
|
||||||
|
|
||||||
<section class="routes">
|
|
||||||
<RouteCard
|
|
||||||
v-for="(r, i) in groupRoutes"
|
|
||||||
:key="r.id"
|
|
||||||
:route="r"
|
|
||||||
:at-first="i === 0"
|
|
||||||
:at-last="i === groupRoutes.length - 1"
|
|
||||||
:readonly="!canEditRoutes(selectedGroup.id)"
|
|
||||||
:style="{ animationDelay: i * 45 + 'ms' }"
|
|
||||||
@toggle="onToggle"
|
|
||||||
@edit="openEdit"
|
|
||||||
@delete="onDelete"
|
|
||||||
@move="onMove"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
|
|
||||||
<p>{{ t("routes.emptyGroup") }}</p>
|
|
||||||
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
|
|
||||||
{{ t("routes.createFirst") }}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<MembersPanel
|
|
||||||
:group="selectedGroup"
|
|
||||||
:can-edit="canEditGroup(selectedGroup.id)"
|
|
||||||
:saving="savingGroup"
|
|
||||||
@save="onSaveGroupFromPanel"
|
|
||||||
/>
|
/>
|
||||||
|
{{ n.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<WebhookPanel
|
<div class="border-t border-border px-3 py-4">
|
||||||
v-if="canEditGroup(selectedGroup.id)"
|
<a
|
||||||
:group-id="selectedGroup.id"
|
class="block rounded-[8px] px-3 py-2 text-[13px] font-semibold text-muted transition-colors hover:bg-surface-2"
|
||||||
:can-edit="canEditGroup(selectedGroup.id)"
|
href="/admin/logout"
|
||||||
/>
|
>
|
||||||
</template>
|
{{ t("app.signOut") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main column -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-20 flex items-center justify-between gap-4 border-b border-border px-6 py-3"
|
||||||
|
style="background: var(--header-bg); backdrop-filter: blur(12px)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-[15px] font-extrabold tracking-tight">{{ pageTitle }}</h1>
|
||||||
|
<p v-if="needLogin" class="text-[11px] text-faint">{{ t("login.title") }}</p>
|
||||||
|
<p v-else class="text-[11px] text-faint">
|
||||||
|
{{ loadingAny ? t("status.loading") : t("status.connected") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="view === 'overview'"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
@click="refreshOverview"
|
||||||
|
>
|
||||||
|
{{ t("metrics.refresh") }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
|
||||||
|
<button
|
||||||
|
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
|
||||||
|
class="btn btn-accent btn-sm"
|
||||||
|
@click="openNew"
|
||||||
|
>
|
||||||
|
{{ t("app.newRoute") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!needLogin && !selectedGroup && view === 'groups' && isSuper"
|
||||||
|
class="btn btn-accent btn-sm"
|
||||||
|
@click="openNewGroup"
|
||||||
|
>
|
||||||
|
{{ t("app.newGroup") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Mobile nav -->
|
||||||
|
<nav class="flex gap-1 overflow-x-auto border-b border-border px-4 py-2 lg:hidden">
|
||||||
|
<button
|
||||||
|
v-for="n in nav"
|
||||||
|
:key="n.id"
|
||||||
|
class="whitespace-nowrap rounded-[7px] px-3 py-1.5 text-[13px] font-semibold transition-colors"
|
||||||
|
:class="n.id === activeNav ? 'bg-accent-dim text-accent' : 'text-muted'"
|
||||||
|
@click="switchView(n.id)"
|
||||||
|
>
|
||||||
|
{{ n.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="mx-auto w-full max-w-[1180px] px-6 pb-24 pt-6 max-sm:px-4">
|
||||||
|
<div v-if="needLogin" class="login">
|
||||||
|
<div class="login-card">
|
||||||
|
<h2>{{ t("login.title") }}</h2>
|
||||||
|
<p v-if="forbidden">{{ t("login.forbidden") }}</p>
|
||||||
|
<p v-else>{{ t("login.prompt") }}</p>
|
||||||
|
<a class="btn btn-accent btn-lg" href="/admin/login">{{ t("login.button") }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Top-level views -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<nav class="tabs">
|
<!-- Overview dashboard -->
|
||||||
<button
|
<AdminHome
|
||||||
class="tab"
|
v-if="view === 'overview'"
|
||||||
:class="{ active: view === 'groups' }"
|
:key="refreshKey"
|
||||||
@click="switchView('groups')"
|
:groups="groups"
|
||||||
>
|
:logs="logs"
|
||||||
{{ t("tab.groups") }}
|
:metrics="metrics"
|
||||||
</button>
|
:groups-loading="groupsLoading"
|
||||||
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
|
:logs-loading="logsLoading"
|
||||||
{{ t("tab.logs") }}
|
:metrics-loading="metricsLoading"
|
||||||
</button>
|
/>
|
||||||
<button class="tab" :class="{ active: view === 'audit' }" @click="switchView('audit')">
|
|
||||||
{{ t("tab.audit") }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
:class="{ active: view === 'metrics' }"
|
|
||||||
@click="switchView('metrics')"
|
|
||||||
>
|
|
||||||
{{ t("tab.metrics") }}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<template v-if="view === 'groups'">
|
<!-- Group detail view -->
|
||||||
|
<template v-else-if="selectedGroup">
|
||||||
|
<section class="toolbar">
|
||||||
|
<div class="crumbs">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="exitGroup">
|
||||||
|
{{ t("group.back") }}
|
||||||
|
</button>
|
||||||
|
<span class="kpi-label">{{ t("group.routesIn", { name: selectedGroup.name }) }}</span>
|
||||||
|
<span class="kpi">{{ groupRoutes.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">
|
||||||
|
<span class="dot" :class="groupRoutesLoading ? '' : 'ok'"></span>
|
||||||
|
<span>{{ groupRoutesLoading ? t("status.loading") : t("status.connected") }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="groupRoutesError" class="err">{{ groupRoutesError }}</p>
|
||||||
|
|
||||||
|
<section class="routes">
|
||||||
|
<RouteCard
|
||||||
|
v-for="(r, i) in groupRoutes"
|
||||||
|
:key="r.id"
|
||||||
|
:route="r"
|
||||||
|
:at-first="i === 0"
|
||||||
|
:at-last="i === groupRoutes.length - 1"
|
||||||
|
:readonly="!canEditRoutes(selectedGroup.id)"
|
||||||
|
:style="{ animationDelay: i * 45 + 'ms' }"
|
||||||
|
@toggle="onToggle"
|
||||||
|
@edit="openEdit"
|
||||||
|
@delete="onDelete"
|
||||||
|
@move="onMove"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
|
||||||
|
<p>{{ t("routes.emptyGroup") }}</p>
|
||||||
|
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
|
||||||
|
{{ t("routes.createFirst") }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MembersPanel
|
||||||
|
:group="selectedGroup"
|
||||||
|
:can-edit="canEditGroup(selectedGroup.id)"
|
||||||
|
:saving="savingGroup"
|
||||||
|
@save="onSaveGroupFromPanel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebhookPanel
|
||||||
|
v-if="canEditGroup(selectedGroup.id)"
|
||||||
|
:group-id="selectedGroup.id"
|
||||||
|
:can-edit="canEditGroup(selectedGroup.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Top-level views -->
|
||||||
|
<template v-else-if="view === 'groups'">
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
<span class="kpi-label">{{ t("kpi.groups") }}</span>
|
<span class="kpi-label">{{ t("kpi.groups") }}</span>
|
||||||
|
|
@ -223,12 +281,16 @@
|
||||||
:metrics="metrics"
|
:metrics="metrics"
|
||||||
:loading="metricsLoading"
|
:loading="metricsLoading"
|
||||||
:error="metricsError"
|
:error="metricsError"
|
||||||
@refresh="loadMetrics"
|
:groups="groups"
|
||||||
|
:selected-group-id="metricsFilterGroup"
|
||||||
|
@refresh="loadMetrics(metricsFilterGroup || undefined)"
|
||||||
|
@filter="loadMetrics(metricsFilterGroup || undefined)"
|
||||||
|
@update:selected-group-id="metricsFilterGroup = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</main>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
<RouteEditor
|
<RouteEditor
|
||||||
:open="editorOpen"
|
:open="editorOpen"
|
||||||
|
|
@ -253,6 +315,7 @@
|
||||||
import type { Group, Route } from "~/types";
|
import type { Group, Route } from "~/types";
|
||||||
import { useAuditApi } from "~/composables/useAudit";
|
import { useAuditApi } from "~/composables/useAudit";
|
||||||
import WebhookPanel from "~/components/WebhookPanel.vue";
|
import WebhookPanel from "~/components/WebhookPanel.vue";
|
||||||
|
import AdminHome from "~/components/AdminHome.vue";
|
||||||
|
|
||||||
const { t, toggle } = useI18n();
|
const { t, toggle } = useI18n();
|
||||||
const { push } = useToasts();
|
const { push } = useToasts();
|
||||||
|
|
@ -260,13 +323,16 @@ const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console view mirrors the URL path so tabs are deep-linkable:
|
* The console view mirrors the URL path so navigation is deep-linkable:
|
||||||
* /admin (groups) · /admin/logs · /admin/audit. Unknown slugs 404.
|
* /admin (overview) · /admin/groups · /admin/logs · /admin/audit · /admin/metrics.
|
||||||
|
* Unknown slugs 404.
|
||||||
*/
|
*/
|
||||||
const view = computed<"groups" | "logs" | "audit" | "metrics" | null>(() => {
|
type View = "overview" | "groups" | "logs" | "audit" | "metrics";
|
||||||
|
const view = computed<View | null>(() => {
|
||||||
const seg = route.path.split("/").filter(Boolean)[1];
|
const seg = route.path.split("/").filter(Boolean)[1];
|
||||||
if (!seg) return "groups";
|
if (!seg) return "overview";
|
||||||
if (seg === "groups" || seg === "logs" || seg === "audit" || seg === "metrics") return seg;
|
if (seg === "overview" || seg === "groups" || seg === "logs" || seg === "audit" || seg === "metrics")
|
||||||
|
return seg;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -274,6 +340,43 @@ if (view.value === null) {
|
||||||
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
|
throw createError({ statusCode: 404, statusMessage: "Page not found", fatal: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nav = computed(() => [
|
||||||
|
{ id: "overview" as View, label: t("tab.overview"), path: "/admin" },
|
||||||
|
{ id: "groups" as View, label: t("tab.groups"), path: "/admin/groups" },
|
||||||
|
{ id: "logs" as View, label: t("tab.logs"), path: "/admin/logs" },
|
||||||
|
{ id: "audit" as View, label: t("tab.audit"), path: "/admin/audit" },
|
||||||
|
{ id: "metrics" as View, label: t("tab.metrics"), path: "/admin/metrics" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeNav = computed<View>(() => view.value ?? "overview");
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
if (selectedGroup.value) return t("group.routesIn", { name: selectedGroup.value.name });
|
||||||
|
switch (view.value) {
|
||||||
|
case "overview":
|
||||||
|
return t("tab.overview");
|
||||||
|
case "groups":
|
||||||
|
return t("tab.groups");
|
||||||
|
case "logs":
|
||||||
|
return t("tab.logs");
|
||||||
|
case "audit":
|
||||||
|
return t("tab.audit");
|
||||||
|
case "metrics":
|
||||||
|
return t("tab.metrics");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingAny = computed(
|
||||||
|
() =>
|
||||||
|
groupsLoading.value ||
|
||||||
|
logsLoading.value ||
|
||||||
|
metricsLoading.value ||
|
||||||
|
groupRoutesLoading.value ||
|
||||||
|
auditLoading.value,
|
||||||
|
);
|
||||||
|
|
||||||
const { logs, loading: logsLoading, error: logsError, load: loadLogs } = useSendLogs();
|
const { logs, loading: logsLoading, error: logsError, load: loadLogs } = useSendLogs();
|
||||||
const {
|
const {
|
||||||
entries: auditEntries,
|
entries: auditEntries,
|
||||||
|
|
@ -316,6 +419,8 @@ const savingGroup = ref(false);
|
||||||
|
|
||||||
const logFilterGroup = ref("");
|
const logFilterGroup = ref("");
|
||||||
const auditFilterGroup = ref("");
|
const auditFilterGroup = ref("");
|
||||||
|
const metricsFilterGroup = ref("");
|
||||||
|
const refreshKey = ref(0);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
|
@ -328,6 +433,7 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
loadGroups();
|
loadGroups();
|
||||||
loadLogs(50, logFilterGroup.value || undefined);
|
loadLogs(50, logFilterGroup.value || undefined);
|
||||||
|
loadMetrics();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tab switches are client-side navigations (no remount), so load the audit
|
// Tab switches are client-side navigations (no remount), so load the audit
|
||||||
|
|
@ -336,18 +442,25 @@ watch(
|
||||||
view,
|
view,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v === "audit") loadAudit(50, auditFilterGroup.value || undefined);
|
if (v === "audit") loadAudit(50, auditFilterGroup.value || undefined);
|
||||||
if (v === "metrics") loadMetrics();
|
if (v === "metrics") loadMetrics(metricsFilterGroup.value || undefined);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
function switchView(next: "groups" | "logs" | "audit" | "metrics"): void {
|
function switchView(next: View): void {
|
||||||
const path = next === "groups" ? "/admin" : `/admin/${next}`;
|
const path = next === "overview" ? "/admin" : `/admin/${next}`;
|
||||||
if (route.path !== path) router.replace(path);
|
if (route.path !== path) router.replace(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshOverview(): Promise<void> {
|
||||||
|
refreshKey.value++;
|
||||||
|
await Promise.all([loadGroups(), loadLogs(50, logFilterGroup.value || undefined), loadMetrics()]);
|
||||||
|
push(t("overview.refreshed"));
|
||||||
|
}
|
||||||
|
|
||||||
function enterGroup(group: Group): void {
|
function enterGroup(group: Group): void {
|
||||||
selectedGroup.value = group;
|
selectedGroup.value = group;
|
||||||
|
groupRoutes.value = [];
|
||||||
loadGroupRoutes(group.id);
|
loadGroupRoutes(group.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,55 +6,64 @@
|
||||||
<Transition name="slide">
|
<Transition name="slide">
|
||||||
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
||||||
<div class="editor-head">
|
<div class="editor-head">
|
||||||
<h2>{{ isEdit ? t("groupEditor.editTitle") : t("groupEditor.newTitle") }}</h2>
|
<div class="editor-heading">
|
||||||
|
<span class="editor-eyebrow">{{ t("groupEditor.eyebrow") }}</span>
|
||||||
|
<h2>{{ isEdit ? t("groupEditor.editTitle") : t("groupEditor.newTitle") }}</h2>
|
||||||
|
</div>
|
||||||
<button class="icon-btn" :title="t('groupEditor.close')" @click="close">✕</button>
|
<button class="icon-btn" :title="t('groupEditor.close')" @click="close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="editor-body" @submit.prevent="save">
|
<form class="editor-body" @submit.prevent="save">
|
||||||
<div class="row2">
|
<section class="editor-section">
|
||||||
<div class="field">
|
<h3 class="editor-section-title">{{ t("groupEditor.sectionBasic") }}</h3>
|
||||||
<label>{{ t("groupEditor.name") }}</label>
|
<div class="row2">
|
||||||
<input
|
<div class="field">
|
||||||
v-model="form.name"
|
<label>{{ t("groupEditor.name") }}</label>
|
||||||
type="text"
|
<input
|
||||||
class="input"
|
v-model="form.name"
|
||||||
:placeholder="t('groupEditor.namePlaceholder')"
|
type="text"
|
||||||
required
|
class="input"
|
||||||
/>
|
:placeholder="t('groupEditor.namePlaceholder')"
|
||||||
</div>
|
required
|
||||||
<div class="field">
|
/>
|
||||||
<label>{{ t("groupEditor.id") }}</label>
|
</div>
|
||||||
<input
|
<div class="field">
|
||||||
v-model="form.id"
|
<label>{{ t("groupEditor.id") }}</label>
|
||||||
type="text"
|
<input
|
||||||
class="input"
|
v-model="form.id"
|
||||||
:placeholder="t('groupEditor.idPlaceholder')"
|
type="text"
|
||||||
required
|
class="input"
|
||||||
/>
|
:placeholder="t('groupEditor.idPlaceholder')"
|
||||||
<div class="hint">
|
required
|
||||||
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
|
/>
|
||||||
|
<div class="hint">
|
||||||
|
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div class="row2">
|
<section class="editor-section">
|
||||||
<div class="field">
|
<h3 class="editor-section-title">{{ t("groupEditor.sectionPreferences") }}</h3>
|
||||||
<label>{{ t("groupEditor.language") }}</label>
|
<div class="row2">
|
||||||
<input
|
<div class="field">
|
||||||
v-model="form.lang"
|
<label>{{ t("groupEditor.language") }}</label>
|
||||||
type="text"
|
<input
|
||||||
class="input"
|
v-model="form.lang"
|
||||||
:placeholder="t('groupEditor.langPlaceholder')"
|
type="text"
|
||||||
/>
|
class="input"
|
||||||
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
:placeholder="t('groupEditor.langPlaceholder')"
|
||||||
</div>
|
/>
|
||||||
<div class="field">
|
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
||||||
<label
|
</div>
|
||||||
>{{ t("groupEditor.emoji") }}
|
<div class="field">
|
||||||
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
|
<label
|
||||||
>
|
>{{ t("groupEditor.emoji") }}
|
||||||
<label class="inline">
|
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
|
||||||
<input v-model="form.emoji" type="checkbox" />
|
>
|
||||||
<span>{{ t("groupEditor.emojiLabel") }}</span>
|
<label class="inline">
|
||||||
</label>
|
<input v-model="form.emoji" type="checkbox" />
|
||||||
|
<span>{{ t("groupEditor.emojiLabel") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
|
|
@ -91,115 +100,124 @@
|
||||||
</button>
|
</button>
|
||||||
<div class="hint">{{ t("groupEditor.forgeSourcesHint") }}</div>
|
<div class="hint">{{ t("groupEditor.forgeSourcesHint") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div class="field">
|
<section class="editor-section">
|
||||||
<label
|
<h3 class="editor-section-title">{{ t("groupEditor.sectionAccess") }}</h3>
|
||||||
>{{ t("groupEditor.membersNote") }}
|
<div class="field">
|
||||||
<span class="lbl-note">{{ t("groupEditor.membersHint") }}</span></label
|
<label
|
||||||
>
|
>{{ t("groupEditor.membersNote") }}
|
||||||
<p class="hint">
|
<span class="lbl-note">{{ t("groupEditor.membersHint") }}</span></label
|
||||||
{{ t("groupEditor.membersGoPanel") }}
|
>
|
||||||
</p>
|
<p class="hint">
|
||||||
</div>
|
{{ t("groupEditor.membersGoPanel") }}
|
||||||
<div v-if="superAdmin" class="field">
|
</p>
|
||||||
<label
|
|
||||||
>{{ t("groupEditor.owners") }}
|
|
||||||
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.owners"
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
:placeholder="t('groupEditor.ownersPlaceholder')"
|
|
||||||
/>
|
|
||||||
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("groupEditor.owners") }}
|
|
||||||
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
|
|
||||||
>
|
|
||||||
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("groupEditor.providers") }}
|
|
||||||
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<label class="inline">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.providers.includes('github')"
|
|
||||||
@change="toggleProvider('github', $event)"
|
|
||||||
/>
|
|
||||||
<span>GitHub</span>
|
|
||||||
</label>
|
|
||||||
<label class="inline">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="form.providers.includes('gitea')"
|
|
||||||
@change="toggleProvider('gitea', $event)"
|
|
||||||
/>
|
|
||||||
<span>Gitea</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
|
<div v-if="superAdmin" class="field">
|
||||||
</div>
|
<label
|
||||||
<div class="field">
|
>{{ t("groupEditor.owners") }}
|
||||||
<label
|
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
|
||||||
>{{ t("groupEditor.installationId") }}
|
>
|
||||||
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.installationId"
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
inputmode="numeric"
|
|
||||||
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
|
||||||
/>
|
|
||||||
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("groupEditor.logTarget") }}
|
|
||||||
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
|
|
||||||
>
|
|
||||||
<select v-model="form.logPlatform" class="select">
|
|
||||||
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
|
||||||
<option value="discord">Discord</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
</select>
|
|
||||||
<template v-if="form.logPlatform === 'discord'">
|
|
||||||
<input
|
<input
|
||||||
v-model="form.logChannelId"
|
v-model="form.owners"
|
||||||
type="text"
|
type="text"
|
||||||
class="input mt-2"
|
class="input"
|
||||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
:placeholder="t('groupEditor.ownersPlaceholder')"
|
||||||
/>
|
/>
|
||||||
|
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("groupEditor.owners") }}
|
||||||
|
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
|
||||||
|
>
|
||||||
|
<input v-model="ownersReadonly" type="text" class="input opacity-60" disabled />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("groupEditor.sectionProviders") }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("groupEditor.providers") }}
|
||||||
|
<span class="lbl-note">{{ t("groupEditor.providersNote") }}</span></label
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.providers.includes('github')"
|
||||||
|
@change="toggleProvider('github', $event)"
|
||||||
|
/>
|
||||||
|
<span>GitHub</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.providers.includes('gitea')"
|
||||||
|
@change="toggleProvider('gitea', $event)"
|
||||||
|
/>
|
||||||
|
<span>Gitea</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("groupEditor.installationId") }}
|
||||||
|
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.logThreadId"
|
v-model="form.installationId"
|
||||||
type="text"
|
type="text"
|
||||||
class="input mt-2"
|
class="input"
|
||||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
inputmode="numeric"
|
||||||
|
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</template>
|
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
|
||||||
<template v-else-if="form.logPlatform === 'telegram'">
|
</div>
|
||||||
<input
|
</section>
|
||||||
v-model="form.logChatId"
|
<section class="editor-section">
|
||||||
type="text"
|
<h3 class="editor-section-title">{{ t("groupEditor.sectionLog") }}</h3>
|
||||||
class="input mt-2"
|
<div class="field">
|
||||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
<label
|
||||||
/>
|
>{{ t("groupEditor.logTarget") }}
|
||||||
<input
|
<span class="lbl-note">{{ t("groupEditor.logTargetNote") }}</span></label
|
||||||
v-model="form.logTopicId"
|
>
|
||||||
type="text"
|
<select v-model="form.logPlatform" class="select">
|
||||||
class="input mt-2"
|
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
<option value="discord">Discord</option>
|
||||||
/>
|
<option value="telegram">Telegram</option>
|
||||||
</template>
|
</select>
|
||||||
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
|
<template v-if="form.logPlatform === 'discord'">
|
||||||
</div>
|
<input
|
||||||
|
v-model="form.logChannelId"
|
||||||
|
type="text"
|
||||||
|
class="input mt-2"
|
||||||
|
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="form.logThreadId"
|
||||||
|
type="text"
|
||||||
|
class="input mt-2"
|
||||||
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="form.logPlatform === 'telegram'">
|
||||||
|
<input
|
||||||
|
v-model="form.logChatId"
|
||||||
|
type="text"
|
||||||
|
class="input mt-2"
|
||||||
|
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="form.logTopicId"
|
||||||
|
type="text"
|
||||||
|
class="input mt-2"
|
||||||
|
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="hint">{{ t("groupEditor.logTargetHint") }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="err">{{ formError }}</div>
|
<div class="err">{{ formError }}</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="editor-foot">
|
<div class="editor-foot">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DeliveryMetrics } from "~/types";
|
import type { DeliveryMetrics, Group, MetricsBreakdown } from "~/types";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -7,74 +7,229 @@ defineProps<{
|
||||||
metrics: DeliveryMetrics | null;
|
metrics: DeliveryMetrics | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
groups: Group[];
|
||||||
|
selectedGroupId: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ refresh: [] }>();
|
const emit = defineEmits<{
|
||||||
|
(e: "refresh"): void;
|
||||||
|
(e: "filter"): void;
|
||||||
|
(e: "update:selectedGroupId", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sortBy = ref<"total" | "failed" | "rate">("total");
|
||||||
|
|
||||||
function pct(value: number): string {
|
function pct(value: number): string {
|
||||||
return `${(value * 100).toFixed(1)}%`;
|
return `${(value * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pctOf(count: number, total: number): string {
|
||||||
|
if (total <= 0) return "0%";
|
||||||
|
return `${((count / total) * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGroupFilter(e: Event): void {
|
||||||
|
emit("update:selectedGroupId", (e.target as HTMLSelectElement).value);
|
||||||
|
emit("filter");
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLabel(raw: string): string {
|
||||||
|
const key = `event.${raw}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? raw : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
accent: "metric-kpi accent",
|
||||||
|
ok: "metric-kpi ok",
|
||||||
|
bad: "metric-kpi bad",
|
||||||
|
warn: "metric-kpi warn",
|
||||||
|
info: "metric-kpi info",
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = (m: DeliveryMetrics) => [
|
||||||
|
{
|
||||||
|
label: t("metrics.total"),
|
||||||
|
value: m.total.toLocaleString(),
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("metrics.successRate"),
|
||||||
|
value: pct(1 - m.failureRate),
|
||||||
|
tone: "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("metrics.failed"),
|
||||||
|
value: m.failed.toLocaleString(),
|
||||||
|
tone: "bad",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("metrics.avgDuration"),
|
||||||
|
value: `${m.avgDurationMs.toFixed(0)}ms`,
|
||||||
|
tone: "warn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("metrics.retryRate"),
|
||||||
|
value: m.totalAttempts > m.total ? pct((m.totalAttempts - m.total) / m.total) : "0%",
|
||||||
|
tone: "info",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function rows(list: MetricsBreakdown[], key: (r: MetricsBreakdown) => string) {
|
||||||
|
const raw = list.map((r) => ({
|
||||||
|
key: key(r),
|
||||||
|
rawKey: key(r),
|
||||||
|
total: r.total,
|
||||||
|
ok: r.ok,
|
||||||
|
failed: r.failed,
|
||||||
|
rate: r.total > 0 ? r.failed / r.total : 0,
|
||||||
|
}));
|
||||||
|
if (sortBy.value === "total") {
|
||||||
|
raw.sort((a, b) => b.total - a.total);
|
||||||
|
} else if (sortBy.value === "failed") {
|
||||||
|
raw.sort((a, b) => b.failed - a.failed || b.total - a.total);
|
||||||
|
} else {
|
||||||
|
raw.sort((a, b) => b.rate - a.rate || b.failed - a.failed);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLabel(): string {
|
||||||
|
if (sortBy.value === "total") return t("metrics.sortTotal");
|
||||||
|
if (sortBy.value === "failed") return t("metrics.sortFailed");
|
||||||
|
return t("metrics.sortRate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleSort(): void {
|
||||||
|
if (sortBy.value === "total") sortBy.value = "failed";
|
||||||
|
else if (sortBy.value === "failed") sortBy.value = "rate";
|
||||||
|
else sortBy.value = "total";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="log-toolbar">
|
<div>
|
||||||
<span class="kpi-label">{{ t("metrics.title") }}</span>
|
<div class="log-toolbar">
|
||||||
<button class="btn btn-ghost btn-sm" @click="emit('refresh')">
|
<span class="kpi-label">{{ t("metrics.title") }}</span>
|
||||||
{{ t("metrics.refresh") }}
|
<div class="log-filters">
|
||||||
</button>
|
<label class="filter-label">{{ t("metrics.filterGroup") }}</label>
|
||||||
</div>
|
<select
|
||||||
|
class="filter-select"
|
||||||
|
:value="selectedGroupId"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="onGroupFilter"
|
||||||
|
>
|
||||||
|
<option value="">{{ t("metrics.allGroups") }}</option>
|
||||||
|
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||||
|
{{ g.name || g.id }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="emit('refresh')">
|
||||||
|
{{ t("metrics.refresh") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="err">{{ error }}</p>
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
<template v-else-if="metrics">
|
<p v-else-if="!loading && !metrics" class="empty-log">{{ t("metrics.empty") }}</p>
|
||||||
<div class="log-list">
|
|
||||||
<article class="log-entry">
|
<template v-else-if="metrics">
|
||||||
<div class="log-meta">
|
<div class="metric-kpis">
|
||||||
<span class="kpi-label">{{ t("metrics.total") }}: {{ metrics.total }}</span>
|
<div
|
||||||
<span class="kpi-label">{{ t("metrics.ok") }}: {{ metrics.ok }}</span>
|
v-for="k in summary(metrics)"
|
||||||
<span class="kpi-label">{{ t("metrics.failed") }}: {{ metrics.failed }}</span>
|
:key="k.label"
|
||||||
<span class="kpi-label"
|
class="metric-kpi"
|
||||||
>{{ t("metrics.failureRate") }}: {{ pct(metrics.failureRate) }}</span
|
:class="TONE[k.tone] ?? ''"
|
||||||
|
>
|
||||||
|
<span class="metric-kpi-value">{{ k.value }}</span>
|
||||||
|
<span class="metric-kpi-label">{{ k.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-if="metrics.byPlatform.length" class="metric-block">
|
||||||
|
<h3 class="metric-block-title">{{ t("metrics.byPlatform") }}</h3>
|
||||||
|
<div
|
||||||
|
v-for="r in rows(metrics.byPlatform, (r) => r.platform ?? 'unknown')"
|
||||||
|
:key="r.key"
|
||||||
|
class="metric-row"
|
||||||
|
>
|
||||||
|
<span class="metric-key">{{ r.key }}</span>
|
||||||
|
<div class="metric-bar">
|
||||||
|
<span
|
||||||
|
v-if="r.ok > 0"
|
||||||
|
class="metric-fill ok"
|
||||||
|
:style="{ flex: String(r.ok) }"
|
||||||
|
:title="`${t('metrics.ok')} ${r.ok} (${pctOf(r.ok, r.total)})`"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-if="r.failed > 0"
|
||||||
|
class="metric-fill bad"
|
||||||
|
:style="{ flex: String(r.failed), minWidth: '4px' }"
|
||||||
|
:title="`${t('metrics.failed')} ${r.failed} (${pctOf(r.failed, r.total)})`"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span class="metric-total">{{ r.total }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-if="metrics.byEvent.length" class="metric-block">
|
||||||
|
<div class="metric-block-head">
|
||||||
|
<h3 class="metric-block-title">{{ t("metrics.byEvent") }}</h3>
|
||||||
|
<button class="metric-sort-toggle" @click="cycleSort">{{ sortLabel() }}</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="r in rows(metrics.byEvent, (r) => r.event ?? 'unknown')"
|
||||||
|
:key="r.key"
|
||||||
|
class="metric-row"
|
||||||
|
>
|
||||||
|
<span class="metric-key" :title="r.rawKey">{{ eventLabel(r.rawKey) }}</span>
|
||||||
|
<div class="metric-bar">
|
||||||
|
<span
|
||||||
|
v-if="r.ok > 0"
|
||||||
|
class="metric-fill ok"
|
||||||
|
:style="{ flex: String(r.ok), minWidth: r.ok > 0 ? '4px' : '0' }"
|
||||||
|
:title="`${t('metrics.ok')} ${r.ok} (${pctOf(r.ok, r.total)})`"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-if="r.failed > 0"
|
||||||
|
class="metric-fill bad"
|
||||||
|
:style="{ flex: String(r.failed), minWidth: '4px' }"
|
||||||
|
:title="`${t('metrics.failed')} ${r.failed} (${pctOf(r.failed, r.total)})`"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<span class="metric-total">{{ r.total }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-if="metrics.byStatus.length" class="metric-block">
|
||||||
|
<h3 class="metric-block-title">{{ t("metrics.byStatus") }}</h3>
|
||||||
|
<div class="metric-status-row">
|
||||||
|
<span
|
||||||
|
v-for="s in metrics.byStatus"
|
||||||
|
:key="s.status"
|
||||||
|
class="metric-status"
|
||||||
|
:class="{ ok: !s.status.startsWith('5') && s.status !== '0', bad: s.status.startsWith('5') || s.status === '0' }"
|
||||||
>
|
>
|
||||||
<span class="kpi-label">
|
{{ s.status }} · {{ s.count }}
|
||||||
{{ t("metrics.avgDuration") }}: {{ metrics.avgDurationMs.toFixed(0) }}ms
|
|
||||||
</span>
|
|
||||||
<span class="kpi-label">
|
|
||||||
{{ t("metrics.avgAttempts") }}: {{ metrics.avgAttempts.toFixed(1) }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article v-if="metrics.byPlatform.length" class="log-entry">
|
<article v-if="metrics.recentFailures.length" class="metric-block">
|
||||||
<h3 class="log-head">{{ t("metrics.byPlatform") }}</h3>
|
<h3 class="metric-block-title">{{ t("metrics.recentFailures") }}</h3>
|
||||||
<div v-for="p in metrics.byPlatform" :key="p.platform" class="log-meta">
|
|
||||||
<span class="kpi-label">{{ p.platform ?? "unknown" }}</span>
|
|
||||||
<span class="kpi-label">{{ t("metrics.ok") }}: {{ p.ok }}</span>
|
|
||||||
<span class="kpi-label">{{ t("metrics.failed") }}: {{ p.failed }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article v-if="metrics.byEvent.length" class="log-entry">
|
|
||||||
<h3 class="log-head">{{ t("metrics.byEvent") }}</h3>
|
|
||||||
<div v-for="e in metrics.byEvent" :key="e.event" class="log-meta">
|
|
||||||
<span class="kpi-label">{{ e.event ?? "unknown" }}</span>
|
|
||||||
<span class="kpi-label">{{ t("metrics.ok") }}: {{ e.ok }}</span>
|
|
||||||
<span class="kpi-label">{{ t("metrics.failed") }}: {{ e.failed }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article v-if="metrics.recentFailures.length" class="log-entry">
|
|
||||||
<h3 class="log-head">{{ t("metrics.recentFailures") }}</h3>
|
|
||||||
<div
|
<div
|
||||||
v-for="f in metrics.recentFailures"
|
v-for="f in metrics.recentFailures"
|
||||||
:key="f.id ?? `${f.ts}-${f.target}`"
|
:key="f.id ?? `${f.ts}-${f.target}`"
|
||||||
class="log-entry"
|
class="log-entry fail"
|
||||||
>
|
>
|
||||||
<span class="log-route">{{ f.event }}</span>
|
<div class="log-head">
|
||||||
<span class="log-meta">{{ f.target }}</span>
|
<span class="dot bad"></span>
|
||||||
<span class="log-status ok" v-if="f.errorCode">{{ f.errorCode }}</span>
|
<span class="log-route">{{ f.event }}</span>
|
||||||
|
<span v-if="f.errorCode" class="log-status bad">{{ f.errorCode }}</span>
|
||||||
|
<span class="log-time">{{ f.target }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
<p v-else-if="!loading" class="empty-log">{{ t("metrics.empty") }}</p>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
@ -1,86 +1,96 @@
|
||||||
<template>
|
<template>
|
||||||
<article class="card" :class="{ disabled: !route.enabled }">
|
<article class="route-card" :class="{ disabled: !route.enabled }">
|
||||||
<div class="card-head">
|
<div class="route-card-main">
|
||||||
<div class="card-title">
|
<div class="route-card-header">
|
||||||
<label v-if="!readonly" class="switch">
|
<label v-if="!readonly" class="switch">
|
||||||
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
|
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
</label>
|
</label>
|
||||||
<span v-if="readonly" class="dot" :class="route.enabled ? 'ok' : 'bad'"></span>
|
<span v-if="readonly" class="dot" :class="route.enabled ? 'ok' : 'bad'"></span>
|
||||||
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
<div class="route-card-title">
|
||||||
<span class="route-id">{{ route.id }}</span>
|
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
|
||||||
|
<span class="route-id">{{ route.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="route-card-badges">
|
||||||
|
<span
|
||||||
|
v-for="(tg, i) in route.targets"
|
||||||
|
:key="i"
|
||||||
|
class="route-badge"
|
||||||
|
:class="tg.platform === 'telegram' ? 'route-badge-tg' : 'route-badge-dc'"
|
||||||
|
>
|
||||||
|
<span class="route-badge-dot" :class="tg.platform === 'telegram' ? 'bg-info' : 'bg-accent'"></span>
|
||||||
|
{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}
|
||||||
|
</span>
|
||||||
|
<span v-if="route.fallback" class="route-badge route-badge-fallback">{{ t("route.fallback") }}</span>
|
||||||
|
<span v-if="route.stop" class="route-badge route-badge-stop">{{ t("route.stop") }}</span>
|
||||||
|
<span v-if="route.discordRoleIds?.length" class="route-badge route-badge-role">@roles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="route-card-filters">
|
||||||
<span
|
<span
|
||||||
v-for="(tg, i) in route.targets"
|
v-for="(f, i) in route.filters"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="badge"
|
class="route-chip"
|
||||||
:class="tg.platform === 'telegram' ? 'fallback' : 'lang'"
|
:class="{ exclude: f.exclude }"
|
||||||
>{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}</span
|
|
||||||
>
|
>
|
||||||
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
|
<span class="route-chip-type">{{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }}</span>
|
||||||
<span v-if="route.stop" class="badge stop">{{ t("route.stop") }}</span>
|
<span class="route-chip-val">{{ fmtMatch(f.match) }}</span>
|
||||||
<span v-if="route.discordRoleIds?.length" class="badge lang">@roles</span>
|
</span>
|
||||||
|
<span v-if="!route.filters.length" class="route-chip route-chip-empty">
|
||||||
|
<span class="route-chip-type">{{ t("route.noFilters") }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
|
||||||
<template v-if="!readonly">
|
<div class="route-card-targets" v-if="route.targets.length">
|
||||||
<button
|
<div v-for="(tg, i) in route.targets" :key="i" class="route-target">
|
||||||
class="icon-btn"
|
<div class="route-target-row">
|
||||||
:disabled="atFirst"
|
<span class="route-target-label">
|
||||||
:title="t('route.moveUp')"
|
<template v-if="tg.platform === 'telegram'">{{ t("route.chat") }}</template>
|
||||||
@click="$emit('move', route, -1)"
|
<template v-else-if="tg.threadId">{{ t("route.thread") }}</template>
|
||||||
>
|
<template v-else>{{ t("route.channel") }}</template>
|
||||||
↑
|
</span>
|
||||||
</button>
|
<code class="route-target-id">
|
||||||
<button
|
<template v-if="tg.platform === 'telegram'">{{ tg.chatId }}</template>
|
||||||
class="icon-btn"
|
<template v-else-if="tg.threadId">{{ tg.threadId }}</template>
|
||||||
:disabled="atLast"
|
<template v-else>{{ tg.channelId }}</template>
|
||||||
:title="t('route.moveDown')"
|
</code>
|
||||||
@click="$emit('move', route, 1)"
|
</div>
|
||||||
>
|
</div>
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="icon-btn"
|
|
||||||
:title="t('routeEditor.editTitle')"
|
|
||||||
@click="$emit('edit', route)"
|
|
||||||
>
|
|
||||||
✎
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="icon-btn danger"
|
|
||||||
:title="t('routeEditor.close')"
|
|
||||||
@click="$emit('delete', route)"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chips">
|
|
||||||
<span v-for="(f, i) in route.filters" :key="i" class="chip" :class="{ exclude: f.exclude }">
|
<div v-if="!readonly" class="route-card-actions">
|
||||||
<span class="f-type"
|
<button
|
||||||
>{{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }}</span
|
class="route-action-btn"
|
||||||
>
|
:disabled="atFirst"
|
||||||
<span class="f-val">{{ fmtMatch(f.match) }}</span>
|
:title="t('route.moveUp')"
|
||||||
</span>
|
@click="$emit('move', route, -1)"
|
||||||
<span v-if="!route.filters.length" class="chip"
|
|
||||||
><span class="f-type">{{ t("route.noFilters") }}</span></span
|
|
||||||
>
|
>
|
||||||
</div>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>
|
||||||
<div class="target">
|
</button>
|
||||||
<div v-for="(tg, i) in route.targets" :key="i" class="flex flex-wrap gap-x-[18px] gap-y-2">
|
<button
|
||||||
<span
|
class="route-action-btn"
|
||||||
><b>{{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}</b
|
:disabled="atLast"
|
||||||
><code>{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }}</code></span
|
:title="t('route.moveDown')"
|
||||||
>
|
@click="$emit('move', route, 1)"
|
||||||
<span v-if="tg.platform === 'telegram' && tg.topicId"
|
>
|
||||||
><b>{{ t("route.topic") }}</b
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
><code>{{ tg.topicId }}</code></span
|
</button>
|
||||||
>
|
<button
|
||||||
<span v-else-if="tg.platform !== 'telegram' && tg.threadId"
|
class="route-action-btn"
|
||||||
><b>{{ t("route.thread") }}</b
|
:title="t('routeEditor.editTitle')"
|
||||||
><code>{{ tg.threadId }}</code></span
|
@click="$emit('edit', route)"
|
||||||
>
|
>
|
||||||
</div>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="route-action-btn route-action-btn-danger"
|
||||||
|
:title="t('routeEditor.close')"
|
||||||
|
@click="$emit('delete', route)"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -108,4 +118,4 @@ function onToggle(event: Event): void {
|
||||||
const next = { ...props.route, enabled: (event.target as HTMLInputElement).checked };
|
const next = { ...props.route, enabled: (event.target as HTMLInputElement).checked };
|
||||||
emit("toggle", next);
|
emit("toggle", next);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -6,149 +6,166 @@
|
||||||
<Transition name="slide">
|
<Transition name="slide">
|
||||||
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
|
||||||
<div class="editor-head">
|
<div class="editor-head">
|
||||||
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
|
<div class="editor-heading">
|
||||||
|
<span class="editor-eyebrow">{{ t("routeEditor.eyebrow") }}</span>
|
||||||
|
<h2>{{ isEdit ? t("routeEditor.editTitle") : t("routeEditor.newTitle") }}</h2>
|
||||||
|
</div>
|
||||||
<button class="icon-btn" :title="t('routeEditor.close')" @click="close">✕</button>
|
<button class="icon-btn" :title="t('routeEditor.close')" @click="close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="editor-body" @submit.prevent="save">
|
<form class="editor-body" @submit.prevent="save">
|
||||||
<div v-if="!isEdit" class="field">
|
<section class="editor-section">
|
||||||
<label
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionBasic") }}</h3>
|
||||||
>{{ t("routeEditor.templates") }}
|
<div v-if="!isEdit" class="field">
|
||||||
<span class="lbl-note">{{ t("routeEditor.templatesNote") }}</span></label
|
<label
|
||||||
>
|
>{{ t("routeEditor.templates") }}
|
||||||
<div class="templates">
|
<span class="lbl-note">{{ t("routeEditor.templatesNote") }}</span></label
|
||||||
<button
|
|
||||||
v-for="tmpl in ROUTE_TEMPLATES"
|
|
||||||
:key="tmpl.id"
|
|
||||||
type="button"
|
|
||||||
class="template-chip"
|
|
||||||
:class="{ active: form.id === tmpl.id }"
|
|
||||||
@click="applyTemplate(tmpl)"
|
|
||||||
>
|
>
|
||||||
{{ t(tmpl.nameKey) }}
|
<div class="templates">
|
||||||
</button>
|
<button
|
||||||
|
v-for="tmpl in ROUTE_TEMPLATES"
|
||||||
|
:key="tmpl.id"
|
||||||
|
type="button"
|
||||||
|
class="template-chip"
|
||||||
|
:class="{ active: form.id === tmpl.id }"
|
||||||
|
@click="applyTemplate(tmpl)"
|
||||||
|
>
|
||||||
|
{{ t(tmpl.nameKey) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="field">
|
||||||
<div class="field">
|
<label>{{ t("routeEditor.name") }}</label>
|
||||||
<label>{{ t("routeEditor.name") }}</label>
|
|
||||||
<input
|
|
||||||
v-model="form.name"
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
:placeholder="t('routeEditor.namePlaceholder')"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>{{ t("routeEditor.id") }}</label>
|
|
||||||
<input v-model="form.id" type="text" class="input" placeholder="my-route" required />
|
|
||||||
<div class="hint">{{ t("routeEditor.idHint") }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="field inline">
|
|
||||||
<input v-model="form.enabled" type="checkbox" />
|
|
||||||
<span>{{ t("routeEditor.enabled") }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="field inline">
|
|
||||||
<input v-model="form.fallback" type="checkbox" />
|
|
||||||
<span
|
|
||||||
>{{ t("routeEditor.fallback") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="field inline">
|
|
||||||
<input v-model="form.stop" type="checkbox" />
|
|
||||||
<span
|
|
||||||
>{{ t("routeEditor.stop") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.discordRoles") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.discordRolesText"
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
:placeholder="t('routeEditor.discordRolesPlaceholder')"
|
|
||||||
/>
|
|
||||||
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.filters") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label
|
|
||||||
>
|
|
||||||
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
|
||||||
<select v-model="f.type">
|
|
||||||
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">
|
|
||||||
{{ t("filter." + ft) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
v-model="f.matchText"
|
v-model="form.name"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="t('routeEditor.matchPlaceholder')"
|
class="input"
|
||||||
|
:placeholder="t('routeEditor.namePlaceholder')"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<label class="inline">
|
|
||||||
<input v-model="f.exclude" type="checkbox" /><span>{{ t("routeEditor.not") }}</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="icon-btn danger" @click="form.filters.splice(i, 1)">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">
|
<div class="field">
|
||||||
{{ t("routeEditor.addFilter") }}
|
<label>{{ t("routeEditor.id") }}</label>
|
||||||
</button>
|
<input v-model="form.id" type="text" class="input" placeholder="my-route" required />
|
||||||
<div class="err">{{ filterError }}</div>
|
<div class="hint">{{ t("routeEditor.idHint") }}</div>
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
>{{ t("routeEditor.targets") }}
|
|
||||||
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
|
|
||||||
>
|
|
||||||
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
|
|
||||||
<select v-model="tg.platform">
|
|
||||||
<option value="discord">Discord</option>
|
|
||||||
<option value="telegram">Telegram</option>
|
|
||||||
</select>
|
|
||||||
<template v-if="tg.platform === 'discord'">
|
|
||||||
<input
|
|
||||||
v-model="tg.channelId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in1"
|
|
||||||
:placeholder="t('routeEditor.channelPlaceholder')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="tg.threadId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in2"
|
|
||||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<input
|
|
||||||
v-model="tg.chatId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in1"
|
|
||||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="tg.topicId"
|
|
||||||
type="text"
|
|
||||||
class="tg-in2"
|
|
||||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
|
</section>
|
||||||
{{ t("routeEditor.addTarget") }}
|
<section class="editor-section">
|
||||||
</button>
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionOptions") }}</h3>
|
||||||
<div class="err">{{ targetError }}</div>
|
<div class="field inline">
|
||||||
</div>
|
<input v-model="form.enabled" type="checkbox" />
|
||||||
|
<span>{{ t("routeEditor.enabled") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<input v-model="form.fallback" type="checkbox" />
|
||||||
|
<span
|
||||||
|
>{{ t("routeEditor.fallback") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.fallbackHint") }}</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<input v-model="form.stop" type="checkbox" />
|
||||||
|
<span
|
||||||
|
>{{ t("routeEditor.stop") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.stopHint") }}</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("routeEditor.discordRoles") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.discordRolesNote") }}</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.discordRolesText"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('routeEditor.discordRolesPlaceholder')"
|
||||||
|
/>
|
||||||
|
<div class="hint">{{ t("routeEditor.discordRolesHint") }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionFilters") }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("routeEditor.filters") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.filtersNote") }}</span></label
|
||||||
|
>
|
||||||
|
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
|
||||||
|
<select v-model="f.type">
|
||||||
|
<option v-for="ft in FILTER_TYPES" :key="ft" :value="ft">
|
||||||
|
{{ t("filter." + ft) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="f.matchText"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('routeEditor.matchPlaceholder')"
|
||||||
|
/>
|
||||||
|
<label class="inline">
|
||||||
|
<input v-model="f.exclude" type="checkbox" /><span
|
||||||
|
>{{ t("routeEditor.not") }}</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="icon-btn danger" @click="form.filters.splice(i, 1)">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">
|
||||||
|
{{ t("routeEditor.addFilter") }}
|
||||||
|
</button>
|
||||||
|
<div class="err">{{ filterError }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="editor-section">
|
||||||
|
<h3 class="editor-section-title">{{ t("routeEditor.sectionTargets") }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
>{{ t("routeEditor.targets") }}
|
||||||
|
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
|
||||||
|
>
|
||||||
|
<div v-for="(tg, i) in form.targets" :key="i" class="target-row">
|
||||||
|
<select v-model="tg.platform">
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
</select>
|
||||||
|
<template v-if="tg.platform === 'discord'">
|
||||||
|
<input
|
||||||
|
v-model="tg.channelId"
|
||||||
|
type="text"
|
||||||
|
class="tg-in1"
|
||||||
|
:placeholder="t('routeEditor.channelPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="tg.threadId"
|
||||||
|
type="text"
|
||||||
|
class="tg-in2"
|
||||||
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="tg.chatId"
|
||||||
|
type="text"
|
||||||
|
class="tg-in1"
|
||||||
|
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="tg.topicId"
|
||||||
|
type="text"
|
||||||
|
class="tg-in2"
|
||||||
|
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
|
||||||
|
{{ t("routeEditor.addTarget") }}
|
||||||
|
</button>
|
||||||
|
<div class="err">{{ targetError }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="err">{{ formError }}</div>
|
<div class="err">{{ formError }}</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="editor-foot">
|
<div class="editor-foot">
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,17 @@ const en: Dict = {
|
||||||
"login.forbidden": "Access denied — this GitHub account is not an admin or group admin.",
|
"login.forbidden": "Access denied — this GitHub account is not an admin or group admin.",
|
||||||
"login.prompt": "Sign in with GitHub to manage routes.",
|
"login.prompt": "Sign in with GitHub to manage routes.",
|
||||||
"login.button": "Sign in with GitHub",
|
"login.button": "Sign in with GitHub",
|
||||||
|
"tab.overview": "Overview",
|
||||||
"tab.groups": "Groups",
|
"tab.groups": "Groups",
|
||||||
"tab.logs": "Send Logs",
|
"tab.logs": "Send Logs",
|
||||||
"tab.audit": "Audit Log",
|
"tab.audit": "Audit Log",
|
||||||
"tab.metrics": "Metrics",
|
"tab.metrics": "Stats",
|
||||||
"metrics.title": "Delivery Metrics",
|
"metrics.title": "Delivery Stats",
|
||||||
|
"metrics.successRate": "Success rate",
|
||||||
|
"overview.routes": "Routes",
|
||||||
|
"overview.recent": "Recent deliveries",
|
||||||
|
"overview.viewAll": "View all",
|
||||||
|
"overview.refreshed": "Refreshed",
|
||||||
"metrics.refresh": "Refresh",
|
"metrics.refresh": "Refresh",
|
||||||
"metrics.total": "Total",
|
"metrics.total": "Total",
|
||||||
"metrics.ok": "OK",
|
"metrics.ok": "OK",
|
||||||
|
|
@ -28,8 +34,44 @@ const en: Dict = {
|
||||||
"metrics.avgAttempts": "Avg attempts",
|
"metrics.avgAttempts": "Avg attempts",
|
||||||
"metrics.byPlatform": "By platform",
|
"metrics.byPlatform": "By platform",
|
||||||
"metrics.byEvent": "By event",
|
"metrics.byEvent": "By event",
|
||||||
|
"metrics.byStatus": "By status",
|
||||||
"metrics.recentFailures": "Recent failures",
|
"metrics.recentFailures": "Recent failures",
|
||||||
"metrics.empty": "No delivery data yet",
|
"metrics.empty": "No delivery data yet",
|
||||||
|
"metrics.filterGroup": "Group",
|
||||||
|
"metrics.allGroups": "All groups",
|
||||||
|
"metrics.sortTotal": "By total",
|
||||||
|
"metrics.sortFailed": "By failures",
|
||||||
|
"metrics.sortRate": "By failure rate",
|
||||||
|
"metrics.retryRate": "Retry rate",
|
||||||
|
"event.push": "push",
|
||||||
|
"event.pull_request": "pull_request",
|
||||||
|
"event.pull_request_review": "pull_request_review",
|
||||||
|
"event.pull_request_review_comment": "pull_request_review_comment",
|
||||||
|
"event.issues": "issues",
|
||||||
|
"event.issue_comment": "issue_comment",
|
||||||
|
"event.workflow_run": "workflow_run",
|
||||||
|
"event.workflow_job": "workflow_job",
|
||||||
|
"event.status": "status",
|
||||||
|
"event.deployment": "deployment",
|
||||||
|
"event.deployment_status": "deployment_status",
|
||||||
|
"event.ping": "ping",
|
||||||
|
"event.release": "release",
|
||||||
|
"event.create": "create",
|
||||||
|
"event.delete": "delete",
|
||||||
|
"event.star": "star",
|
||||||
|
"event.fork": "fork",
|
||||||
|
"event.check_run": "check_run",
|
||||||
|
"event.check_suite": "check_suite",
|
||||||
|
"event.commit_comment": "commit_comment",
|
||||||
|
"event.member": "member",
|
||||||
|
"event.label": "label",
|
||||||
|
"event.milestone": "milestone",
|
||||||
|
"event.discussion": "discussion",
|
||||||
|
"event.discussion_comment": "discussion_comment",
|
||||||
|
"event.repository": "repository",
|
||||||
|
"event.code_scanning_alert": "code_scanning_alert",
|
||||||
|
"event.dependabot_alert": "dependabot_alert",
|
||||||
|
"event.custom": "custom",
|
||||||
"status.loading": "loading",
|
"status.loading": "loading",
|
||||||
"status.connected": "connected",
|
"status.connected": "connected",
|
||||||
"kpi.groups": "GROUPS",
|
"kpi.groups": "GROUPS",
|
||||||
|
|
@ -113,7 +155,12 @@ const en: Dict = {
|
||||||
"filter.keyword": "Keyword",
|
"filter.keyword": "Keyword",
|
||||||
"routeEditor.editTitle": "Edit route",
|
"routeEditor.editTitle": "Edit route",
|
||||||
"routeEditor.newTitle": "New route",
|
"routeEditor.newTitle": "New route",
|
||||||
|
"routeEditor.eyebrow": "Route",
|
||||||
"routeEditor.close": "Close",
|
"routeEditor.close": "Close",
|
||||||
|
"routeEditor.sectionBasic": "Basic",
|
||||||
|
"routeEditor.sectionOptions": "Options",
|
||||||
|
"routeEditor.sectionFilters": "Filters",
|
||||||
|
"routeEditor.sectionTargets": "Targets",
|
||||||
"routeEditor.templates": "Templates",
|
"routeEditor.templates": "Templates",
|
||||||
"routeEditor.templatesNote": "(click to prefill filters)",
|
"routeEditor.templatesNote": "(click to prefill filters)",
|
||||||
"templates.push": "Push",
|
"templates.push": "Push",
|
||||||
|
|
@ -174,7 +221,13 @@ const en: Dict = {
|
||||||
"routeEditor.errChannel": "Target {n} channel ID is required",
|
"routeEditor.errChannel": "Target {n} channel ID is required",
|
||||||
"groupEditor.editTitle": "Edit group",
|
"groupEditor.editTitle": "Edit group",
|
||||||
"groupEditor.newTitle": "New group",
|
"groupEditor.newTitle": "New group",
|
||||||
|
"groupEditor.eyebrow": "Group",
|
||||||
"groupEditor.close": "Close",
|
"groupEditor.close": "Close",
|
||||||
|
"groupEditor.sectionBasic": "Basic",
|
||||||
|
"groupEditor.sectionPreferences": "Preferences",
|
||||||
|
"groupEditor.sectionAccess": "Access & members",
|
||||||
|
"groupEditor.sectionProviders": "Providers",
|
||||||
|
"groupEditor.sectionLog": "Logging",
|
||||||
"groupEditor.name": "Name",
|
"groupEditor.name": "Name",
|
||||||
"groupEditor.namePlaceholder": "My Team",
|
"groupEditor.namePlaceholder": "My Team",
|
||||||
"groupEditor.id": "ID",
|
"groupEditor.id": "ID",
|
||||||
|
|
@ -291,11 +344,17 @@ const zh: Dict = {
|
||||||
"login.forbidden": "访问被拒绝 —— 该 GitHub 账号不是超级管理员或分组管理员。",
|
"login.forbidden": "访问被拒绝 —— 该 GitHub 账号不是超级管理员或分组管理员。",
|
||||||
"login.prompt": "使用 GitHub 登录以管理路由。",
|
"login.prompt": "使用 GitHub 登录以管理路由。",
|
||||||
"login.button": "使用 GitHub 登录",
|
"login.button": "使用 GitHub 登录",
|
||||||
|
"tab.overview": "概览",
|
||||||
"tab.groups": "分组",
|
"tab.groups": "分组",
|
||||||
"tab.logs": "发送日志",
|
"tab.logs": "发送日志",
|
||||||
"tab.audit": "审计日志",
|
"tab.audit": "审计日志",
|
||||||
"tab.metrics": "指标",
|
"tab.metrics": "统计",
|
||||||
"metrics.title": "投递指标",
|
"metrics.title": "投递统计",
|
||||||
|
"metrics.successRate": "成功率",
|
||||||
|
"overview.routes": "路由",
|
||||||
|
"overview.recent": "最近投递",
|
||||||
|
"overview.viewAll": "查看全部",
|
||||||
|
"overview.refreshed": "已刷新",
|
||||||
"metrics.refresh": "刷新",
|
"metrics.refresh": "刷新",
|
||||||
"metrics.total": "总计",
|
"metrics.total": "总计",
|
||||||
"metrics.ok": "成功",
|
"metrics.ok": "成功",
|
||||||
|
|
@ -305,8 +364,44 @@ const zh: Dict = {
|
||||||
"metrics.avgAttempts": "平均尝试次数",
|
"metrics.avgAttempts": "平均尝试次数",
|
||||||
"metrics.byPlatform": "按平台",
|
"metrics.byPlatform": "按平台",
|
||||||
"metrics.byEvent": "按事件",
|
"metrics.byEvent": "按事件",
|
||||||
|
"metrics.byStatus": "按状态",
|
||||||
"metrics.recentFailures": "最近失败",
|
"metrics.recentFailures": "最近失败",
|
||||||
"metrics.empty": "暂无投递数据",
|
"metrics.empty": "暂无投递数据",
|
||||||
|
"metrics.filterGroup": "分组",
|
||||||
|
"metrics.allGroups": "全部分组",
|
||||||
|
"metrics.sortTotal": "按总量",
|
||||||
|
"metrics.sortFailed": "按失败",
|
||||||
|
"metrics.sortRate": "按失败率",
|
||||||
|
"metrics.retryRate": "重试率",
|
||||||
|
"event.push": "推送",
|
||||||
|
"event.pull_request": "拉取请求",
|
||||||
|
"event.pull_request_review": "PR 审查",
|
||||||
|
"event.pull_request_review_comment": "PR 审查评论",
|
||||||
|
"event.issues": "议题",
|
||||||
|
"event.issue_comment": "议题评论",
|
||||||
|
"event.workflow_run": "工作流运行",
|
||||||
|
"event.workflow_job": "工作流任务",
|
||||||
|
"event.status": "提交状态",
|
||||||
|
"event.deployment": "部署",
|
||||||
|
"event.deployment_status": "部署状态",
|
||||||
|
"event.ping": "心跳",
|
||||||
|
"event.release": "发布",
|
||||||
|
"event.create": "创建",
|
||||||
|
"event.delete": "删除",
|
||||||
|
"event.star": "星标",
|
||||||
|
"event.fork": "复刻",
|
||||||
|
"event.check_run": "检查运行",
|
||||||
|
"event.check_suite": "检查套件",
|
||||||
|
"event.commit_comment": "提交评论",
|
||||||
|
"event.member": "成员",
|
||||||
|
"event.label": "标签",
|
||||||
|
"event.milestone": "里程碑",
|
||||||
|
"event.discussion": "讨论",
|
||||||
|
"event.discussion_comment": "讨论评论",
|
||||||
|
"event.repository": "仓库",
|
||||||
|
"event.code_scanning_alert": "代码扫描",
|
||||||
|
"event.dependabot_alert": "依赖告警",
|
||||||
|
"event.custom": "自定义",
|
||||||
"status.loading": "加载中",
|
"status.loading": "加载中",
|
||||||
"status.connected": "已连接",
|
"status.connected": "已连接",
|
||||||
"kpi.groups": "分组",
|
"kpi.groups": "分组",
|
||||||
|
|
@ -386,7 +481,12 @@ const zh: Dict = {
|
||||||
"filter.keyword": "关键词",
|
"filter.keyword": "关键词",
|
||||||
"routeEditor.editTitle": "编辑路由",
|
"routeEditor.editTitle": "编辑路由",
|
||||||
"routeEditor.newTitle": "新建路由",
|
"routeEditor.newTitle": "新建路由",
|
||||||
|
"routeEditor.eyebrow": "路由",
|
||||||
"routeEditor.close": "关闭",
|
"routeEditor.close": "关闭",
|
||||||
|
"routeEditor.sectionBasic": "基本信息",
|
||||||
|
"routeEditor.sectionOptions": "选项",
|
||||||
|
"routeEditor.sectionFilters": "过滤器",
|
||||||
|
"routeEditor.sectionTargets": "目标",
|
||||||
"routeEditor.templates": "模板",
|
"routeEditor.templates": "模板",
|
||||||
"routeEditor.templatesNote": "(点击可预填过滤器)",
|
"routeEditor.templatesNote": "(点击可预填过滤器)",
|
||||||
"templates.push": "推送",
|
"templates.push": "推送",
|
||||||
|
|
@ -446,7 +546,13 @@ const zh: Dict = {
|
||||||
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
|
||||||
"groupEditor.editTitle": "编辑分组",
|
"groupEditor.editTitle": "编辑分组",
|
||||||
"groupEditor.newTitle": "新建分组",
|
"groupEditor.newTitle": "新建分组",
|
||||||
|
"groupEditor.eyebrow": "分组",
|
||||||
"groupEditor.close": "关闭",
|
"groupEditor.close": "关闭",
|
||||||
|
"groupEditor.sectionBasic": "基本信息",
|
||||||
|
"groupEditor.sectionPreferences": "偏好",
|
||||||
|
"groupEditor.sectionAccess": "访问与成员",
|
||||||
|
"groupEditor.sectionProviders": "提供方",
|
||||||
|
"groupEditor.sectionLog": "日志",
|
||||||
"groupEditor.name": "名称",
|
"groupEditor.name": "名称",
|
||||||
"groupEditor.namePlaceholder": "我的团队",
|
"groupEditor.namePlaceholder": "我的团队",
|
||||||
"groupEditor.id": "ID",
|
"groupEditor.id": "ID",
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ export function useMetrics() {
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(groupId?: string): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
needLogin.value = false;
|
needLogin.value = false;
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ metrics?: DeliveryMetrics }>("/admin/api/metrics");
|
const qs = groupId ? `?groupId=${encodeURIComponent(groupId)}` : "";
|
||||||
|
const data = await apiFetch<{ metrics?: DeliveryMetrics }>(`/admin/api/metrics${qs}`);
|
||||||
metrics.value = data.metrics ?? null;
|
metrics.value = data.metrics ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err);
|
if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ The console itself is served at `/admin`; its tabs are deep-linkable via the URL
|
||||||
| `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
|
| `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
|
||||||
| `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
|
| `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
|
||||||
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
|
||||||
| `GET /admin/api/metrics` | Delivery metrics (totals, failure rate, per platform/event/status, recent failures; recent failures scoped to accessible groups for non-super) |
|
| `GET /admin/api/metrics` | Delivery stats (totals, failure rate, per platform/event/status, recent failures); optional `?groupId=` scope; recent failures scoped to accessible groups for non-super |
|
||||||
| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) |
|
| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) |
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
| `GET /admin/api/logs` | 发送日志(按可访问的路由过滤) |
|
| `GET /admin/api/logs` | 发送日志(按可访问的路由过滤) |
|
||||||
| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) |
|
| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) |
|
||||||
| `GET /admin/api/audit` | 审计日志(按可访问的分组过滤) |
|
| `GET /admin/api/audit` | 审计日志(按可访问的分组过滤) |
|
||||||
| `GET /admin/api/metrics` | 投递指标(总计、失败率、按平台/事件/状态、最近失败;非超管按可访问分组过滤最近失败) |
|
| `GET /admin/api/metrics` | 投递统计(总计、失败率、按平台/事件/状态、最近失败);可选 `?groupId=` 按分组过滤;非超管按可访问分组过滤最近失败 |
|
||||||
| `GET /admin/api/delivery/:deliveryId` | 单次投递的全部发送日志(按分组过滤) |
|
| `GET /admin/api/delivery/:deliveryId` | 单次投递的全部发送日志(按分组过滤) |
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Env, Config, Route } from "./types";
|
||||||
import { log } from "./lib/log";
|
import { log } from "./lib/log";
|
||||||
import { migrateRoutes, validateRoutes } from "./config/schema";
|
import { migrateRoutes, validateRoutes } from "./config/schema";
|
||||||
|
|
||||||
const CONFIG_CACHE_TTL = 60_000;
|
const CONFIG_CACHE_TTL = 300_000;
|
||||||
const ROUTES_KEY = "config:routes";
|
const ROUTES_KEY = "config:routes";
|
||||||
let configCache: { config: Config; expiresAt: number } | null = null;
|
let configCache: { config: Config; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export interface MessageTracker {
|
||||||
delete(eventId: string, targetId: string): Promise<void>;
|
delete(eventId: string, targetId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MESSAGE_KEY_TTL_SECONDS = 604800;
|
const MESSAGE_KEY_TTL_SECONDS = 86400;
|
||||||
|
|
||||||
export function kvMessageTracker(kv: KVNamespace): MessageTracker {
|
export function kvMessageTracker(kv: KVNamespace): MessageTracker {
|
||||||
const key = (eventId: string, targetId: string): string => `msg:${eventId}:${targetId}`;
|
const key = (eventId: string, targetId: string): string => `msg:${eventId}:${targetId}`;
|
||||||
|
|
|
||||||
|
|
@ -147,12 +147,20 @@ export async function getSendLogByDelivery(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFailedSendLog(db: D1Database, limit = 20): Promise<SendRecord[]> {
|
export async function getFailedSendLog(
|
||||||
|
db: D1Database,
|
||||||
|
limit = 20,
|
||||||
|
groupId?: string,
|
||||||
|
): Promise<SendRecord[]> {
|
||||||
try {
|
try {
|
||||||
const { results } = await db
|
const stmt = groupId
|
||||||
.prepare(`SELECT ${COLUMNS} FROM send_logs WHERE ok = 0 ORDER BY ts DESC LIMIT ?`)
|
? db
|
||||||
.bind(limit)
|
.prepare(
|
||||||
.all<LogRow>();
|
`SELECT ${COLUMNS} FROM send_logs WHERE ok = 0 AND group_id = ? ORDER BY ts DESC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.bind(groupId, limit)
|
||||||
|
: db.prepare(`SELECT ${COLUMNS} FROM send_logs WHERE ok = 0 ORDER BY ts DESC LIMIT ?`).bind(limit);
|
||||||
|
const { results } = await stmt.all<LogRow>();
|
||||||
return results.map(toRecord);
|
return results.map(toRecord);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ err }, "Failed to load failed send log");
|
log.warn({ err }, "Failed to load failed send log");
|
||||||
|
|
|
||||||
|
|
@ -54,42 +54,47 @@ const EMPTY: DeliveryMetrics = {
|
||||||
recentFailures: [],
|
recentFailures: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDeliveryMetrics(db: D1Database): Promise<DeliveryMetrics> {
|
export async function getDeliveryMetrics(db: D1Database, groupId?: string): Promise<DeliveryMetrics> {
|
||||||
|
const where = groupId ? " WHERE group_id = ?" : "";
|
||||||
|
const and = groupId ? " AND group_id = ?" : "";
|
||||||
|
const bindGroup = (stmt: D1PreparedStatement): D1PreparedStatement =>
|
||||||
|
groupId ? stmt.bind(groupId) : stmt;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const totals = await db
|
const totals = await bindGroup(
|
||||||
.prepare(`SELECT COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok FROM send_logs`)
|
db.prepare(`SELECT COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok FROM send_logs${where}`),
|
||||||
.all<TotalsRow>();
|
).all<TotalsRow>();
|
||||||
const total = Number(totals.results[0]?.total) || 0;
|
const total = Number(totals.results[0]?.total) || 0;
|
||||||
const ok = Number(totals.results[0]?.ok) || 0;
|
const ok = Number(totals.results[0]?.ok) || 0;
|
||||||
|
|
||||||
const byPlatform = await db
|
const byPlatform = await bindGroup(
|
||||||
.prepare(
|
db.prepare(
|
||||||
`SELECT platform AS key, COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok
|
`SELECT platform AS key, COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok
|
||||||
FROM send_logs GROUP BY platform`,
|
FROM send_logs${where} GROUP BY platform`,
|
||||||
)
|
),
|
||||||
.all<BreakdownRow>();
|
).all<BreakdownRow>();
|
||||||
const byEvent = await db
|
const byEvent = await bindGroup(
|
||||||
.prepare(
|
db.prepare(
|
||||||
`SELECT event AS key, COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok
|
`SELECT event AS key, COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok
|
||||||
FROM send_logs GROUP BY event`,
|
FROM send_logs${where} GROUP BY event`,
|
||||||
)
|
),
|
||||||
.all<BreakdownRow>();
|
).all<BreakdownRow>();
|
||||||
const byStatus = await db
|
const byStatus = await bindGroup(
|
||||||
.prepare(
|
db.prepare(
|
||||||
`SELECT status, COUNT(*) AS count FROM send_logs
|
`SELECT status, COUNT(*) AS count FROM send_logs
|
||||||
WHERE status IS NOT NULL GROUP BY status`,
|
WHERE status IS NOT NULL${and} GROUP BY status`,
|
||||||
)
|
),
|
||||||
.all<StatusRow>();
|
).all<StatusRow>();
|
||||||
const duration = await db
|
const duration = await bindGroup(
|
||||||
.prepare(`SELECT AVG(duration_ms) AS avg FROM send_logs WHERE duration_ms IS NOT NULL`)
|
db.prepare(`SELECT AVG(duration_ms) AS avg FROM send_logs WHERE duration_ms IS NOT NULL${and}`),
|
||||||
.all<AvgRow>();
|
).all<AvgRow>();
|
||||||
const attempts = await db
|
const attempts = await bindGroup(
|
||||||
.prepare(
|
db.prepare(
|
||||||
`SELECT COALESCE(SUM(attempts), 0) AS total, AVG(attempts) AS avg
|
`SELECT COALESCE(SUM(attempts), 0) AS total, AVG(attempts) AS avg
|
||||||
FROM send_logs WHERE attempts IS NOT NULL`,
|
FROM send_logs WHERE attempts IS NOT NULL${and}`,
|
||||||
)
|
),
|
||||||
.all<AttemptsRow>();
|
).all<AttemptsRow>();
|
||||||
const recentFailures = await getFailedSendLog(db, 20);
|
const recentFailures = await getFailedSendLog(db, 20, groupId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export const DELIVERY_QUEUE = "webhooker-delivery";
|
||||||
export const DELIVERY_DLQ = "webhooker-delivery-dlq";
|
export const DELIVERY_DLQ = "webhooker-delivery-dlq";
|
||||||
|
|
||||||
const MAX_QUEUE_MESSAGE_BYTES = 100_000;
|
const MAX_QUEUE_MESSAGE_BYTES = 100_000;
|
||||||
const PAYLOAD_KV_TTL_SECONDS = 60 * 60 * 24;
|
const PAYLOAD_KV_TTL_SECONDS = 3600;
|
||||||
const STATE_KV_TTL_SECONDS = 60 * 60 * 24;
|
const STATE_KV_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
const RETRYABLE_ERROR_CODES = new Set(["DISCORD_5XX", "TELEGRAM_5XX", "NETWORK", "RETRIES"]);
|
const RETRYABLE_ERROR_CODES = new Set(["DISCORD_5XX", "TELEGRAM_5XX", "NETWORK", "RETRIES"]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1018,7 +1018,12 @@ export async function adminAudit(event: H3Event): Promise<Record<string, unknown
|
||||||
export async function adminApiMetrics(event: H3Event): Promise<Record<string, unknown>> {
|
export async function adminApiMetrics(event: H3Event): Promise<Record<string, unknown>> {
|
||||||
const auth = await requireAnyAccess(event);
|
const auth = await requireAnyAccess(event);
|
||||||
const env = cfEnv(event);
|
const env = cfEnv(event);
|
||||||
const metrics = await getDeliveryMetrics(env.DB);
|
const query = getQuery(event);
|
||||||
|
const groupId = String(query["groupId"] ?? "") || undefined;
|
||||||
|
if (groupId && !auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
|
||||||
|
return respondError(event, 403, "Forbidden");
|
||||||
|
}
|
||||||
|
const metrics = await getDeliveryMetrics(env.DB, groupId);
|
||||||
if (auth.scope.isSuper) return { metrics };
|
if (auth.scope.isSuper) return { metrics };
|
||||||
return {
|
return {
|
||||||
metrics: {
|
metrics: {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { log } from "../lib/log";
|
||||||
import { migrateGroups, validateGroups } from "../config/schema";
|
import { migrateGroups, validateGroups } from "../config/schema";
|
||||||
|
|
||||||
const GROUPS_KEY = "config:groups";
|
const GROUPS_KEY = "config:groups";
|
||||||
|
const GROUPS_CACHE_TTL = 300_000;
|
||||||
|
let groupsCache: { groups: Group[]; expiresAt: number } | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a group's member list. Groups stored with the legacy `adminIds`
|
* Normalizes a group's member list. Groups stored with the legacy `adminIds`
|
||||||
|
|
@ -38,9 +40,14 @@ export function normalizeGroupMembers(group: Group): GroupMember[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
||||||
|
if (groupsCache && Date.now() < groupsCache.expiresAt) {
|
||||||
|
return groupsCache.groups;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
|
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
|
||||||
if (Array.isArray(stored)) return validateGroups(migrateGroups(stored));
|
const groups = Array.isArray(stored) ? validateGroups(migrateGroups(stored)) : [];
|
||||||
|
groupsCache = { groups, expiresAt: Date.now() + GROUPS_CACHE_TTL };
|
||||||
|
return groups;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ err }, "Failed to load groups from KV");
|
log.warn({ err }, "Failed to load groups from KV");
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +56,11 @@ export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
||||||
|
|
||||||
export async function saveGroups(kv: KVNamespace, groups: Group[]): Promise<void> {
|
export async function saveGroups(kv: KVNamespace, groups: Group[]): Promise<void> {
|
||||||
await kv.put(GROUPS_KEY, JSON.stringify(groups));
|
await kv.put(GROUPS_KEY, JSON.stringify(groups));
|
||||||
|
groupsCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateGroupsCache(): void {
|
||||||
|
groupsCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Case-insensitive match of a GitHub userId or login against a list of ids/logins. */
|
/** Case-insensitive match of a GitHub userId or login against a list of ids/logins. */
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export interface Invite {
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INVITE_TTL = 7 * 24 * 3600;
|
const INVITE_TTL = 24 * 3600;
|
||||||
|
|
||||||
function generateInviteToken(): string {
|
function generateInviteToken(): string {
|
||||||
const bytes = new Uint8Array(32);
|
const bytes = new Uint8Array(32);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Env } from "../types";
|
import type { Env } from "../types";
|
||||||
|
|
||||||
const SESSION_COOKIE = "wh_admin_session";
|
const SESSION_COOKIE = "wh_admin_session";
|
||||||
const SESSION_TTL = 7 * 24 * 3600;
|
const SESSION_TTL = 24 * 3600;
|
||||||
|
|
||||||
export interface AdminSession {
|
export interface AdminSession {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export async function processWebhook(
|
||||||
// retries of the same delivery never dispatch twice.
|
// retries of the same delivery never dispatch twice.
|
||||||
const store = kvIdempotencyStore(env.KV);
|
const store = kvIdempotencyStore(env.KV);
|
||||||
const key = deliveryKey(provider.id, tenantId, event.deliveryId);
|
const key = deliveryKey(provider.id, tenantId, event.deliveryId);
|
||||||
if (!(await store.claim(key, 300))) {
|
if (!(await store.claim(key, 120))) {
|
||||||
return { status: 200, body: { ok: true, duplicate: true, requestId } };
|
return { status: 200, body: { ok: true, duplicate: true, requestId } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ const themeColors = {
|
||||||
"warn-dim": "rgb(var(--warn-dim) / <alpha-value>)",
|
"warn-dim": "rgb(var(--warn-dim) / <alpha-value>)",
|
||||||
bad: "rgb(var(--bad) / <alpha-value>)",
|
bad: "rgb(var(--bad) / <alpha-value>)",
|
||||||
"bad-dim": "rgb(var(--bad-dim) / <alpha-value>)",
|
"bad-dim": "rgb(var(--bad-dim) / <alpha-value>)",
|
||||||
|
info: "rgb(var(--info) / <alpha-value>)",
|
||||||
|
"info-dim": "rgb(var(--info-dim) / <alpha-value>)",
|
||||||
"body-text": "rgb(var(--body-text) / <alpha-value>)",
|
"body-text": "rgb(var(--body-text) / <alpha-value>)",
|
||||||
"code-bg": "rgb(var(--code-bg) / <alpha-value>)",
|
"code-bg": "rgb(var(--code-bg) / <alpha-value>)",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect, beforeEach } from "bun:test";
|
||||||
import {
|
import {
|
||||||
adminGroupRename,
|
adminGroupRename,
|
||||||
adminGroupRoutesGet,
|
adminGroupRoutesGet,
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
adminApiMe,
|
adminApiMe,
|
||||||
} from "../server/lib/web/admin";
|
} from "../server/lib/web/admin";
|
||||||
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
||||||
import { loadGroups } from "../server/lib/web/groups";
|
import { loadGroups, invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
import { loadRoutes } from "../server/lib/config";
|
import { loadRoutes } from "../server/lib/config";
|
||||||
import { createInvite, listInvites } from "../server/lib/web/invites";
|
import { createInvite, listInvites } from "../server/lib/web/invites";
|
||||||
import { getTenantSecret, setTenantSecret } from "../server/lib/web/tenants";
|
import { getTenantSecret, setTenantSecret } from "../server/lib/web/tenants";
|
||||||
|
|
@ -57,6 +57,8 @@ function createEnv(overrides: Partial<Env> = {}): Env {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("admin handlers (h3)", () => {
|
describe("admin handlers (h3)", () => {
|
||||||
|
beforeEach(() => invalidateGroupsCache());
|
||||||
|
|
||||||
it("renames an owned group and follows routes, secret and invites", async () => {
|
it("renames an owned group and follows routes, secret and invites", async () => {
|
||||||
const kv = createMockKV();
|
const kv = createMockKV();
|
||||||
await kv.put(
|
await kv.put(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
ensureInstallationGroup,
|
ensureInstallationGroup,
|
||||||
loadGroups,
|
loadGroups,
|
||||||
saveGroups,
|
saveGroups,
|
||||||
|
invalidateGroupsCache,
|
||||||
} from "../server/lib/web/groups";
|
} from "../server/lib/web/groups";
|
||||||
import { validateGroups } from "../server/lib/web/admin";
|
import { validateGroups } from "../server/lib/web/admin";
|
||||||
import {
|
import {
|
||||||
|
|
@ -97,6 +98,7 @@ describe("admin-session", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
kv = createMockKV();
|
kv = createMockKV();
|
||||||
|
invalidateGroupsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and retrieves a session from cookie", async () => {
|
it("creates and retrieves a session from cookie", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect, beforeEach } from "bun:test";
|
||||||
import { getDeliveryMetrics } from "../server/lib/observability/metrics";
|
import { getDeliveryMetrics } from "../server/lib/observability/metrics";
|
||||||
import {
|
import {
|
||||||
getSendLogByDelivery,
|
getSendLogByDelivery,
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "../server/lib/lib/send-log";
|
} from "../server/lib/lib/send-log";
|
||||||
import { adminApiMetrics, adminApiDelivery } from "../server/lib/web/admin";
|
import { adminApiMetrics, adminApiDelivery } from "../server/lib/web/admin";
|
||||||
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
||||||
|
import { invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
import { makeEvent, responseStatus } from "./helpers";
|
import { makeEvent, responseStatus } from "./helpers";
|
||||||
import type { Env } from "../server/lib/types";
|
import type { Env } from "../server/lib/types";
|
||||||
|
|
||||||
|
|
@ -156,6 +157,7 @@ describe("delivery metrics", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("admin metrics/delivery handlers", () => {
|
describe("admin metrics/delivery handlers", () => {
|
||||||
|
beforeEach(() => invalidateGroupsCache());
|
||||||
it("returns global metrics for super admins", async () => {
|
it("returns global metrics for super admins", async () => {
|
||||||
const kv = createMockKV();
|
const kv = createMockKV();
|
||||||
const db = createMetricsDB({ "COUNT(*) AS total": [{ total: 5, ok: 4 }] });
|
const db = createMetricsDB({ "COUNT(*) AS total": [{ total: 5, ok: 4 }] });
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { sendMessage } from "../server/lib/drivers/discord/rest";
|
import { sendMessage } from "../server/lib/drivers/discord/rest";
|
||||||
import { renderNeutralMessage } from "../server/lib/drivers/discord/render";
|
import { renderNeutralMessage } from "../server/lib/drivers/discord/render";
|
||||||
import { dispatchEvent } from "../server/lib/core/dispatch";
|
import { dispatchEvent } from "../server/lib/core/dispatch";
|
||||||
|
import { invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
import type { Env, Route } from "../server/lib/types";
|
import type { Env, Route } from "../server/lib/types";
|
||||||
|
|
||||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
|
||||||
|
|
@ -102,6 +103,7 @@ describe("discord role mentions", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("dispatchEvent fallback routing", () => {
|
describe("dispatchEvent fallback routing", () => {
|
||||||
|
beforeEach(() => invalidateGroupsCache());
|
||||||
function createMockKV(): KVNamespace {
|
function createMockKV(): KVNamespace {
|
||||||
const store = new Map<string, string>();
|
const store = new Map<string, string>();
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
handleTokenDelete,
|
handleTokenDelete,
|
||||||
} from "../server/lib/web/oauth";
|
} from "../server/lib/web/oauth";
|
||||||
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
||||||
import { loadGroups } from "../server/lib/web/groups";
|
import { loadGroups, invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
import { getInstallationAccount } from "../server/lib/github/oauth";
|
import { getInstallationAccount } from "../server/lib/github/oauth";
|
||||||
import { makeEvent, responseStatus, responseHeader } from "./helpers";
|
import { makeEvent, responseStatus, responseHeader } from "./helpers";
|
||||||
import type { Env } from "../server/lib/types";
|
import type { Env } from "../server/lib/types";
|
||||||
|
|
@ -123,6 +123,7 @@ describe("GET /auth/github/install", () => {
|
||||||
const restoredFetch = globalThis.fetch;
|
const restoredFetch = globalThis.fetch;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
invalidateGroupsCache();
|
||||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
void init;
|
void init;
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
|
|
@ -195,6 +196,7 @@ describe("POST /auth/github/install/bind", () => {
|
||||||
const restoredFetch = globalThis.fetch;
|
const restoredFetch = globalThis.fetch;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
invalidateGroupsCache();
|
||||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
void init;
|
void init;
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
revokeInvite,
|
revokeInvite,
|
||||||
acceptInvite,
|
acceptInvite,
|
||||||
} from "../server/lib/web/invites";
|
} from "../server/lib/web/invites";
|
||||||
import { saveGroups, loadGroups } from "../server/lib/web/groups";
|
import { saveGroups, loadGroups, invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
import type { Group } from "../server/lib/types";
|
import type { Group } from "../server/lib/types";
|
||||||
|
|
||||||
function createMockKV(): KVNamespace {
|
function createMockKV(): KVNamespace {
|
||||||
|
|
@ -49,6 +49,7 @@ describe("invites", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
kv = createMockKV();
|
kv = createMockKV();
|
||||||
|
invalidateGroupsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and reads back an invite", async () => {
|
it("creates and reads back an invite", async () => {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("kvMessageTracker", () => {
|
||||||
expect(await tracker.get("missing", "channel-1")).toBeNull();
|
expect(await tracker.get("missing", "channel-1")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the msg:{eventId}:{targetId} key with a 7-day TTL", async () => {
|
it("uses the msg:{eventId}:{targetId} key with a 24h TTL", async () => {
|
||||||
const kv = createMockKV();
|
const kv = createMockKV();
|
||||||
const keys = new Map<string, string>();
|
const keys = new Map<string, string>();
|
||||||
const ttls = new Map<string, number>();
|
const ttls = new Map<string, number>();
|
||||||
|
|
@ -46,7 +46,7 @@ describe("kvMessageTracker", () => {
|
||||||
const tracker = kvMessageTracker(kv);
|
const tracker = kvMessageTracker(kv);
|
||||||
await tracker.set("event-1", "target-1", "msg-7");
|
await tracker.set("event-1", "target-1", "msg-7");
|
||||||
expect(keys.has("msg:event-1:target-1")).toBe(true);
|
expect(keys.has("msg:event-1:target-1")).toBe(true);
|
||||||
expect(ttls.get("msg:event-1:target-1")).toBe(604800);
|
expect(ttls.get("msg:event-1:target-1")).toBe(86400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes tracked entries", async () => {
|
it("deletes tracked entries", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||||
import { handleQueueBatch } from "../server/lib/queue/consumer";
|
import { handleQueueBatch } from "../server/lib/queue/consumer";
|
||||||
import { invalidateConfigCache } from "../server/lib/config";
|
import { invalidateConfigCache } from "../server/lib/config";
|
||||||
|
import { invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
import type { Env } from "../server/lib/types";
|
import type { Env } from "../server/lib/types";
|
||||||
import type { DeliveryMessage, DispatchSummary } from "../server/lib/queue/delivery";
|
import type { DeliveryMessage, DispatchSummary } from "../server/lib/queue/delivery";
|
||||||
|
|
||||||
|
|
@ -86,6 +87,7 @@ const STATE_KEY = "delivery-state:github:global:d1";
|
||||||
describe("handleQueueBatch", () => {
|
describe("handleQueueBatch", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
invalidateConfigCache();
|
invalidateConfigCache();
|
||||||
|
invalidateGroupsCache();
|
||||||
summary = { attempts: 0, failures: [] };
|
summary = { attempts: 0, failures: [] };
|
||||||
dispatchCalls = 0;
|
dispatchCalls = 0;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { createHmac } from "crypto";
|
import { createHmac } from "crypto";
|
||||||
import { processWebhook } from "../server/lib/webhook";
|
import { processWebhook } from "../server/lib/webhook";
|
||||||
import { invalidateConfigCache } from "../server/lib/config";
|
import { invalidateConfigCache } from "../server/lib/config";
|
||||||
import { loadGroups } from "../server/lib/web/groups";
|
import { invalidateGroupsCache, loadGroups } from "../server/lib/web/groups";
|
||||||
import type { Env, Route } from "../server/lib/types";
|
import type { Env, Route } from "../server/lib/types";
|
||||||
|
|
||||||
function githubSign(body: string, secret: string): string {
|
function githubSign(body: string, secret: string): string {
|
||||||
|
|
@ -70,6 +70,7 @@ describe("processWebhook", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
invalidateConfigCache();
|
invalidateConfigCache();
|
||||||
|
invalidateGroupsCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** waitUntil collector: lets the test await the dispatched work. */
|
/** waitUntil collector: lets the test await the dispatched work. */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "webhooker",
|
"name": "webhooker",
|
||||||
|
"account_id": "d157d30ef8fd945995f22bf99c5c67b9",
|
||||||
"main": ".output/server/index.mjs",
|
"main": ".output/server/index.mjs",
|
||||||
"compatibility_date": "2026-08-04",
|
"compatibility_date": "2026-08-04",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
|
|
@ -54,4 +55,4 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue