diff --git a/AGENTS.md b/AGENTS.md index ab212a4..0106e6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord - Per-group forge branding: optional `Group.forgeSources` (a list of `{ host, type: "github" | "gitea", name? }` the group defines itself) labels each message's footer with the first entry whose type matches the event's provider and whose host matches the repository URL's hostname (GitHub matches `github.com`, so two Gitea instances can be `git1.example.com`/`git2.example.com`); the label is the entry's optional `name` (fallback: host); links are derived from the repo URL and footer icons use raster PNGs Discord renders (GitHub's `fluidicon.png`, Gitea's `/assets/img/favicon.png` — `.ico` favicons are silently ignored); Discord shows the footer icon + name, Telegram a linked name - Delivery queue: when the `QUEUE` binding is present, webhook ingress enqueues one message per event (not per target) to a Cloudflare Queue (`webhooker-delivery`); a Nitro `cloudflare:queue` plugin consumes batches, dispatches, and retries retryable failures (5xx/network/429-exhaustion) with exponential backoff (5s/30s/2m/10m) up to the queue `max_retries`, after which the DLQ (`webhooker-delivery-dlq`) marks the delivery dead. Oversized payloads (>~100 KB) are parked in R2 (`webhooks/YYYY/MM/DD/*.json`, falling back to KV `queue:payload:*` without the R2 binding) and resolved by the consumer; delivery state is tracked as the D1 `delivery_state` table (KV `delivery-state:*` fallback). Without the `QUEUE` binding, dispatch stays inline (existing behavior) - Storage quotas: the design avoids KV write pressure (Workers Free KV allows ~1,000 writes/day) by keeping high-frequency ephemeral writes in D1 instead — webhook dedup (`dedup_keys`, atomic `ON CONFLICT` UPSERT), delivery state and message tracking all use D1 rows via `canUseD1` (prepare+batch probe) with automatic KV fallback when D1 is unavailable or unmigrated. D1 Free allows 100,000 rows written/day, so the per-event KV write cost drops to ~0; config stays cached in memory + KV with D1 authoritative +- Filter DSL: a route's flat `filters` can be replaced by a nested `ast` (`all`/`any`/`not` nodes, `server/lib/events/filter-ast.ts`) that takes precedence when present; the `field` filter type reads any payload value by JSONPath (`path`, arrays expand so any element matches) and 12 comparison operators (`op`: `eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`) extend the legacy glob/regex/case-insensitive-exact semantics; named filter fragments (D1 `d1_fragments` via `server/lib/fragments.ts`, per-group) are editor-side templates inlined into the route's `ast` on insert — the matcher never resolves fragment references; the route editor (`RouteEditor.vue`) exposes a recursive AST builder (`FilterNodeEditor.vue`), chip multi-value input (`TagInput.vue`), live explanation and a stateless `POST /admin/api/test-match` dry-run - Local dev: wrangler + Miniflare ## Architecture @@ -36,9 +37,10 @@ app/ # Vue 3 UI (Nuxt app dir) ├── assets/css/main.css # Tailwind entry: theme tokens (RGB-triplet vars) + @layer components (@apply) + Vue transition glue ├── pages/ # index (landing), terms, privacy, admin/[...slug] (console SPA) ├── components/ # ConsolePage (sidebar shell + topbar), AdminHome (overview dashboard), -│ # RouteCard/Editor, GroupEditor, MembersPanel, WebhookPanel, +│ # RouteCard/Editor (RouteEditor has FilterNodeEditor AST builder + TagInput chips), +│ # GroupEditor, MembersPanel, WebhookPanel, │ # SendLogs, AuditLog, MetricsPanel, AppToasts, LegalLayout -├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useLogs, useAudit, useInvites, useWebhook +├── composables/ # useI18n, useToasts, useGroups, useGroupRoutes, useFilterNode (AST helpers), useFragments, useLogs, useAudit, useInvites, useWebhook ├── types.ts # shared client types (Route, Group, Filter, ...) └── utils/legal.ts # terms/privacy HTML bodies (zh/en) server/ # Nitro server @@ -50,6 +52,7 @@ server/ # Nitro server └── lib/ ├── types.ts # Env, Config, Route, Filter, Group, WebhookEvent, NeutralMessage ├── config.ts # loadRoutes/saveRoutes (delegates to D1 ConfigStore when available, else KV config:routes), loadConfig from env + ├── fragments.ts # named filter fragments (loadFragments/saveFragments — D1 d1_fragments, no KV) ├── config/ # config schema + migration + validation │ └── schema.ts # CONFIG_SCHEMA_VERSION, valibot route/group/filter schemas, migrateRoutes/Groups, validateRoutes/Groups (non-destructive), explainRoute ├── cf.ts # cfEnv(event) — env bindings from event.context.cloudflare @@ -62,7 +65,7 @@ server/ # Nitro server │ └── dispatch.ts # Platform-neutral dispatch: match routes → formatEvent → driver.send/edit (batched recordSend + group filter + per-group webhook log) ├── events/ │ ├── match.ts # matchRoute, eventOwners; unified pattern syntax (*/? globs + //-wrapped regex) - │ └── filter-ast.ts # FilterNode evaluator (all/any/not), containsKeyword, explainFilter/explainFilterNode; pattern helpers (regex/glob compile) + │ └── filter-ast.ts # FilterNode evaluator (all/any/not + field JSONPath + 12 ops), containsKeyword, explainFilter/explainFilterNode; pattern helpers (regex/glob compile) ├── providers/ # Forge webhook providers (verify + parse/normalize to GitHub-shaped events) │ ├── types.ts # Provider interface (matches/verify/parse) │ ├── hmac.ts # HMAC-SHA256 + timing-safe compare helpers @@ -141,8 +144,10 @@ tests/__snapshots__/ # formatter snapshot golden files (toMatchSnapshot) - Normalize Gitea webhook payloads to a GitHub-shaped `WebhookEvent` (push `compare_url` → `compare`, `pull_request_comment` → `pull_request_review_comment`, ...) - Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body) - Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) -- Filter events by: event type, repo name, actor, action, branch, keyword — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive) +- Filter events by: event type, repo name, actor, action, branch, keyword, or any payload field (`field` with a JSONPath `path` — arrays expand so any element matches) — every filter type supports `*`/`?` glob matching and `//`-wrapped regular expressions (case-insensitive) plus 12 comparison operators (`op`: `eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`) - Combine filters into an AST (`Route.ast`: `all`/`any`/`not` nodes) that overrides the flat `filters` AND-list; `explainRoute`/`explainFilterNode` render a human-readable description of the tree +- Store named filter fragments (D1 `d1_fragments`, per group, `GET`/`PUT /admin/api/groups/:groupId/fragments`) as editor-side templates; inserting a fragment inlines its node into the route's `ast` — dispatch never resolves fragment references +- Offer a stateless filter dry-run (`POST /admin/api/test-match`, body `{ node | filters, event?, payload }`) that evaluates in memory and returns `{ matched, explanation }` — no event payload is stored - Validate route/group config against valibot schemas on load (non-destructive: invalid entries log a warning but still load); `CONFIG_SCHEMA_VERSION` marks the schema version and `migrateRoutes`/`migrateGroups` migrate legacy shapes (e.g. `target` → `targets`) - Filter routes by group owner restriction (`Group.owners`), group source-platform restriction (`Group.providers`: github/gitea), GitHub App installation restriction (`Group.installationId`), and skip fallback routes whenever a regular route matched; stop evaluating further routes when a matched route has `stop: true` - Auto-provision GitHub App installs: the App's Setup URL flow (`/auth/github/install` choice page + `POST /auth/github/install/bind`, owner-role verified for existing groups) and the `installation.created` webhook fallback both create `inst-{installationId}` groups or bind existing groups @@ -236,7 +241,7 @@ Rule: no functional change ships without its documentation; docs and code must n - **Production**: `wrangler secret put ` for each secret - **Routes**: config in D1 `d1_routes`/`d1_groups` (authoritative), seeded from legacy KV `config:routes`/`config:groups` on first load; cache-only KV keys - **KV namespace**: Required binding for token/state/session/cache storage (dedup/delivery-state/message-tracking fall back to KV when D1 is unavailable) -- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `audit_logs` + `discord_links` + `telegram_links` + `d1_groups` + `d1_routes` + `dedup_keys` + `delivery_state` + `message_tracking` tables +- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `audit_logs` + `discord_links` + `telegram_links` + `d1_groups` + `d1_routes` + `d1_fragments` + `dedup_keys` + `delivery_state` + `message_tracking` tables - **Queue**: optional `QUEUE` producer binding plus consumers `webhooker-delivery` and its DLQ `webhooker-delivery-dlq` (declared in `wrangler.jsonc`); when absent, webhook dispatch stays inline - **R2**: optional `PAYLOAD` binding (bucket `webhooker-payloads`) for oversized queue payloads; without it, oversized payloads fall back to KV `queue:payload:*` - **Access control**: `ADMIN_USER_IDS` (super admins), `ALLOW_SELF_SIGNUP` (optional personal group on first login), `AUDIT_RETENTION_DAYS` (default 90) — all plain env vars, not secrets @@ -257,7 +262,7 @@ bunx wrangler d1 create webhooker bunx wrangler queues create webhooker-delivery bunx wrangler queues create webhooker-delivery-dlq # Queues are declared in wrangler.jsonc (QUEUE binding); no env var needed -bun run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0008) +bun run db:migrate:prod # wrangler d1 migrations apply webhooker --remote (migrations/0001..0010) # R2 bucket for oversized payloads (P0): bunx wrangler r2 bucket create webhooker-payloads bunx wrangler deploy ``` diff --git a/README.md b/README.md index 726cb54..16c0e94 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event - **Per-group webhook ingress** — every group can get its own `POST /webhook/{groupId}` URL + secret (Gitea, classic GitHub webhooks, and arbitrary custom JSON posts signed with `X-WebHooker-Signature`, with optional timestamp+nonce replay protection) - **GitHub App tenant isolation** — bind a group to a GitHub App installation id so only that org/user's events enter it - HMAC-SHA256 signature verification (Web Crypto API) -- Filter by event type, repo, actor, action, branch, keyword (supports `*`/`?` globs and `/regex/`) +- Filter by event type, repo, actor, action, branch, keyword, or any payload field (`field` with a JSONPath `path`, e.g. `pull_request.user.login`); supports `*`/`?` globs and `/regex/` patterns plus 12 comparison operators (`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`) +- Combine filters into a boolean AST (`all` / `any` / `not` nodes) via the route editor's visual builder; reuse named filter fragments and dry-run any filter against a pasted JSON payload without storing it - Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML - Route to Discord channels/threads and Telegram chats/topics (multi-target routes) - `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms @@ -39,7 +40,7 @@ GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro) - **Cloudflare Worker** — HTTP ingress, signature verification, routing, platform dispatch - **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API - **KV** — cache + ephemeral state: token storage (`token:{userId}`), OAuth state (`state:{hex}`), admin sessions (`session:{id}`), per-group webhook secrets (`tenant:{groupId}`), invites, config cache, delivery dedup/state/message-tracking **fallback** (`delivery:*`, `delivery-state:*`, `msg:*` used only when D1 is unavailable) and message-update locks (`msg:lock:*`) -- **D1** — source of truth for routes/groups (`d1_routes`/`d1_groups`), send logs (`send_logs`), audit logs (`audit_logs`), dedup (`dedup_keys`), delivery state (`delivery_state`), message-update tracking (`message_tracking`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`) +- **D1** — source of truth for routes/groups (`d1_routes`/`d1_groups`), named filter fragments (`d1_fragments`), send logs (`send_logs`), audit logs (`audit_logs`), dedup (`dedup_keys`), delivery state (`delivery_state`), message-update tracking (`message_tracking`), Discord↔GitHub links (`discord_links`), Telegram↔GitHub links (`telegram_links`) - **Queue** — async delivery when `QUEUE` is bound: `webhooker-delivery` (exponential retry) + DLQ `webhooker-delivery-dlq`; oversized payloads parked in R2 (`PAYLOAD` binding, `webhooks/YYYY/MM/DD/*.json`, falling back to KV `queue:payload:*`) ## Quick Start @@ -98,7 +99,7 @@ Routes are stored in D1 (`d1_routes`, seeded from legacy KV `config:routes` on f ] ``` -`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). Routes belong to **groups** (D1 `d1_groups`, seeded from legacy KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema. +`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). A route may also set `stop: true` (skip later routes). Routes belong to **groups** (D1 `d1_groups`, seeded from legacy KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema. ### Web UI (`/admin`) @@ -112,7 +113,7 @@ Sign out at `/admin/logout`. Every group has `members` with a role (`owner` / `a ### Filter Types -Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. Filters can also be grouped into an AST via an optional `ast` on a route (`all` / `any` / `not` nodes) to express boolean combinations beyond the default AND-list. See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference. +Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-insensitive); set `exclude: true` to invert. A `field` filter reads any payload value by JSONPath (arrays expand so any element matches) and accepts an `op` operator (default `eq`); `op: "exists"` checks presence without a `match`. Filters can also be grouped into an AST via an optional `ast` on a route (`all` / `any` / `not` nodes) to express boolean combinations beyond the default AND-list — when present, `ast` takes precedence over `filters`. The route editor provides a visual AST builder, chip multi-value input, a stateless match tester, and named filter fragments (stored in D1 `d1_fragments`). See the [Filter Tutorial](https://webhooker.docs.worldexecute.me/guide/filters) for the pattern syntax and the full filter reference. ## API diff --git a/README.zh.md b/README.zh.md index df1c415..6486ba4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -9,7 +9,8 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W - **分组级 webhook 入口** — 每个分组可拥有独立的 `POST /webhook/{groupId}` URL + secret(Gitea、classic GitHub webhook,以及用 `X-WebHooker-Signature` 签名的任意自定义 JSON,可选的 timestamp+nonce 重放防护) - **GitHub App 租户隔离** — 将分组绑定到 GitHub App 安装 ID,只有该组织/用户的事件才能进入该分组 - HMAC-SHA256 签名验证(Web Crypto API) -- 按事件类型、仓库、操作人、操作、分支、关键词过滤(支持 `*`/`?` 通配符与 `/正则/`) +- 按事件类型、仓库、操作人、操作、分支、关键词,或任意载荷字段(`field` + JSONPath `path`,如 `pull_request.user.login`)过滤;支持 `*`/`?` 通配符与 `/正则/`,另有 12 个比较操作符(`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`) +- 在路由编辑器的可视化构建器中把过滤器组合成布尔 AST(`all` / `any` / `not` 节点);可复用命名过滤器片段,并可对粘贴的 JSON 载荷做无存储的试匹配 - 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML - 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标) - `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持 @@ -39,7 +40,7 @@ GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro) - **Cloudflare Worker** — HTTP 入口、签名验证、路由分发 - **Interactions Endpoint** — HTTPS 回调(无 Discord Gateway 连接、无 Durable Object);bot 保持离线,命令通过 API 注册 - **KV** — 缓存 + 临时状态:Token 存储(`token:{userId}`)、OAuth state(`state:{hex}`)、管理员会话(`session:{id}`)、分组级 webhook secret(`tenant:{groupId}`)、邀请、配置缓存、投递去重/投递状态/消息更新追踪的回退(`delivery:*`、`delivery-state:*`、`msg:*` 仅在 D1 不可用时使用)与消息更新锁(`msg:lock:*`) -- **D1** — 路由/分组(`d1_routes`/`d1_groups`)、发送日志(`send_logs`)、审计日志(`audit_logs`)、去重(`dedup_keys`)、投递状态(`delivery_state`)、消息更新追踪(`message_tracking`)、Discord↔GitHub 绑定(`discord_links`)、Telegram↔GitHub 绑定(`telegram_links`) +- **D1** — 路由/分组(`d1_routes`/`d1_groups`)、命名过滤器片段(`d1_fragments`)、发送日志(`send_logs`)、审计日志(`audit_logs`)、去重(`dedup_keys`)、投递状态(`delivery_state`)、消息更新追踪(`message_tracking`)、Discord↔GitHub 绑定(`discord_links`)、Telegram↔GitHub 绑定(`telegram_links`) - **Queue** — 绑定 `QUEUE` 时异步投递:`webhooker-delivery`(指数退避重试)+ 死信队列 `webhooker-delivery-dlq`;超大负载暂存于 R2(`PAYLOAD` 绑定,`webhooks/YYYY/MM/DD/*.json`,回退 KV `queue:payload:*`) ## 快速开始 @@ -98,7 +99,7 @@ bunx wrangler dev # 启动本地开发服务器 ] ``` -`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。路由隶属于**分组**(D1 `d1_groups`,首次加载时从旧版 KV `config:groups` 同步),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见[路由与目标](https://webhooker.docs.worldexecute.me/zh/guide/routes)与[分组与访问控制](https://webhooker.docs.worldexecute.me/zh/guide/groups)指南。 +`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。路由还可设置 `stop: true`(跳过后续路由)。路由隶属于**分组**(D1 `d1_groups`,首次加载时从旧版 KV `config:groups` 同步),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见[路由与目标](https://webhooker.docs.worldexecute.me/zh/guide/routes)与[分组与访问控制](https://webhooker.docs.worldexecute.me/zh/guide/groups)指南。 ### Web 控制台(`/admin`) @@ -112,7 +113,7 @@ bunx wrangler dev # 启动本地开发服务器 ### 过滤器类型 -所有过滤器均支持纯文本、`*`/`?` 通配符与 `/正则/`(不区分大小写);设置 `exclude: true` 可取反。过滤器还可通过路由上可选的 `ast`(`all` / `any` / `not` 节点)组合为 AST,以表达默认 AND 列表之外的布尔组合。模式语法与完整过滤器参考见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。 +所有过滤器均支持纯文本、`*`/`?` 通配符与 `/正则/`(不区分大小写);设置 `exclude: true` 可取反。`field` 过滤器通过 JSONPath(`path`)读取任意载荷字段(数组会展开,任一元素匹配即可),并可用 `op` 指定比较操作符(默认 `eq`);`op: "exists"` 只判断字段是否存在、无需 `match`。过滤器还可通过路由上可选的 `ast`(`all` / `any` / `not` 节点)组合为 AST,以表达默认 AND 列表之外的布尔组合——当 `ast` 存在时优先于 `filters`。路由编辑器提供可视化 AST 构建器、chip 多值输入、无状态试匹配器与命名过滤器片段(存于 D1 `d1_fragments`)。模式语法与完整过滤器参考见[过滤器教程](https://webhooker.docs.worldexecute.me/zh/guide/filters)。 ## API diff --git a/app/assets/css/main.css b/app/assets/css/main.css index c32e6da..9601206 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -655,6 +655,110 @@ justify-self: end; } + .node-editor { + @apply mb-1; + } + + .node-nested { + @apply ml-4 border-l border-border pl-3; + } + + .leaf-row { + @apply mb-1.5 flex flex-wrap items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5; + } + + .node-select { + @apply w-[120px] shrink-0 rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-medium text-text; + } + + .node-path { + @apply w-[160px] shrink-0; + } + + .node-values { + @apply min-w-[180px] flex-1; + } + + .node-exclude { + @apply flex shrink-0 cursor-pointer items-center gap-1.5 text-[12px] font-medium text-muted; + } + + .node-group { + @apply mb-1.5 rounded-sm border border-border bg-surface-2 p-2.5; + } + + .node-group-head { + @apply mb-2 flex flex-wrap items-center gap-2; + } + + .node-combo { + @apply w-[130px] rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-semibold text-text; + } + + .node-combo-label { + @apply inline-flex items-center rounded border border-border-strong bg-surface px-2 py-1.5 font-ui text-[12.5px] font-semibold text-text; + } + + .node-actions { + @apply ml-auto flex flex-wrap items-center gap-1.5; + } + + .node-add { + @apply px-2 py-1 text-[12px]; + } + + .node-children { + @apply flex flex-col gap-1.5; + } + + .node-empty { + @apply py-1 text-[12px] italic text-muted; + } + + .test-payload { + @apply font-mono text-[12px]; + } + + .test-result { + @apply mt-1 flex flex-col gap-1.5 rounded-sm border border-border bg-surface-2 p-2.5; + } + + .test-result.ok { + @apply border-ok; + } + + .test-badge { + @apply inline-flex w-fit items-center rounded-full bg-bad px-2 py-0.5 text-[11px] font-semibold text-white; + } + + .test-result.ok .test-badge { + @apply bg-ok; + } + + .test-explanation { + @apply font-mono text-[12px] text-muted; + } + + .fragments-list { + @apply mb-2 flex flex-col gap-1.5; + } + + .fragment-row { + @apply flex items-center gap-2 rounded-sm border border-border bg-surface-2 p-2; + } + + .fragment-name { + @apply min-w-0 flex-1 truncate text-[13px] font-medium text-text; + } + + .fragment-action { + @apply shrink-0 px-2 py-1 text-[12px]; + } + + .fragment-save { + @apply flex items-center gap-2; + } + /* ---- Toasts ---- */ .toasts { @apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2; diff --git a/app/components/AdminHome.vue b/app/components/AdminHome.vue index 3b4fbf6..f69e85c 100644 --- a/app/components/AdminHome.vue +++ b/app/components/AdminHome.vue @@ -1,5 +1,6 @@ diff --git a/app/components/ConsolePage.vue b/app/components/ConsolePage.vue index e0c68c5..03788ae 100644 --- a/app/components/ConsolePage.vue +++ b/app/components/ConsolePage.vue @@ -298,6 +298,7 @@ :open="editorOpen" :route="editing" :saving="saving" + :group-id="selectedGroup?.id ?? null" @close="editorOpen = false" @save="onSave" /> diff --git a/app/components/FilterNodeEditor.vue b/app/components/FilterNodeEditor.vue new file mode 100644 index 0000000..17a1ea9 --- /dev/null +++ b/app/components/FilterNodeEditor.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/components/RouteCard.vue b/app/components/RouteCard.vue index a26ebe2..76c044f 100644 --- a/app/components/RouteCard.vue +++ b/app/components/RouteCard.vue @@ -35,18 +35,10 @@
- - {{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }} - {{ fmtMatch(f.match) }} + + {{ summary }} - + {{ t("route.noFilters") }}
@@ -151,8 +143,8 @@ + +