From 6feb05956e0ec7cc5736069c20bad1cc5df91370 Mon Sep 17 00:00:00 2001 From: Chenow Date: Sun, 20 Sep 2026 18:35:45 +0800 Subject: [PATCH] fix: traffic empty pattern, audit names, Quick Go all-services, service name rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match traffic empty to the shared empty-state, prefer display names in audit objects, add 全部服务 to Quick Go, and reject invalid service names in the console and API. --- CHANGELOG.md | 9 ++++++ internal/control/http.go | 31 ++++++++++++++++++-- internal/control/mutate.go | 4 +-- internal/control/service_test.go | 41 +++++++++++++++++++++++++++ internal/control/transfer.go | 3 ++ internal/control/views.go | 2 +- package.json | 2 +- src/components/empty-state.tsx | 14 +++++++-- src/components/pages/traffic-page.tsx | 7 +++++ src/components/quick-launcher.tsx | 8 +++++- src/components/rate-chart.tsx | 23 ++++++++------- src/lib/i18n/messages.ts | 2 ++ src/lib/umbra/labels.test.ts | 16 +++++++++++ src/lib/umbra/labels.ts | 32 +++++++++++++++------ src/lib/umbra/quick-launch.test.ts | 7 ++++- src/lib/umbra/service.test.ts | 5 ++++ src/lib/umbra/service.ts | 3 +- 17 files changed, 179 insertions(+), 30 deletions(-) create mode 100644 src/lib/umbra/labels.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bfea0..e1d48c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- 控制台:流量无数据使用与其它页相同的空状态(图标 +「暂无流量」) +- 控制台:审计对象列优先显示节点/服务名称,原始 id 放到提示 +- 控制台:快速前往可搜索并打开「全部服务」 +- 控制台与 API:服务名称与节点名称使用同一套字符规则,`!!!bad` 不能保存 +- Console: traffic empty state uses the shared icon + “no traffic yet” pattern +- Console: audit object column prefers display names; raw ids stay in the tooltip +- Console: Quick Go includes All services +- Console and API: service names use the same character rules as node names + - 控制台:登录后进入轻量总览(节点在线、服务数、需处理、近期审计);徽标回到总览 - 控制台:顶栏负责一级分区;观测 / 设置 / 服务详情使用同一套页内二级导航;抽屉关闭更明显 - 控制台:审计动作合并同义说法并用人话;列表优先显示名称,标识放在提示里;操作者不再写 owner;快速前往与主导航用词对齐 diff --git a/internal/control/http.go b/internal/control/http.go index 90c560a..64749e2 100644 --- a/internal/control/http.go +++ b/internal/control/http.go @@ -1125,6 +1125,10 @@ func (c *Console) postMapping(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + if err := validNodeName(b.Name); err != nil { + writeErr(w, 400, err.Error()) + return + } id, err := newID("map") if err != nil { writeRandFail(w) @@ -1493,7 +1497,27 @@ func (c *Console) postVisitor(w http.ResponseWriter, r *http.Request) { }) } -func (c *Console) targetNameLocked(target string) string { +func looksLikeUmbraID(s string) bool { + s = strings.TrimSpace(s) + return strings.HasPrefix(s, "nde_") || strings.HasPrefix(s, "map_") || strings.HasPrefix(s, "tkt_") +} + +func humanAuditName(detail string) string { + s := strings.TrimSpace(detail) + if s == "" || looksLikeUmbraID(s) || strings.Contains(s, "=") { + return "" + } + if strings.HasPrefix(s, "SPA ") || strings.HasPrefix(s, "Hello") { + return "" + } + fields := strings.Fields(s) + if len(fields) >= 2 && strings.Contains(fields[len(fields)-1], "/") && !looksLikeUmbraID(fields[0]) { + return strings.Join(fields[:len(fields)-1], " ") + } + return s +} + +func (c *Console) targetNameLocked(target, detail string) string { if n := c.nodes[target]; n != nil { return n.Name } @@ -1503,6 +1527,9 @@ func (c *Console) targetNameLocked(target string) string { } return m.Spec.Name } + if name := humanAuditName(detail); name != "" { + return name + } return target } @@ -1517,7 +1544,7 @@ func (c *Console) getAudit(w http.ResponseWriter, r *http.Request) { out = append(out, map[string]any{ "id": a.ID, "ts": a.Ts.UTC().Format(time.RFC3339), "actor": a.Actor, "action": a.Action, "target": a.Target, "detail": a.Detail, - "targetName": c.targetNameLocked(a.Target), + "targetName": c.targetNameLocked(a.Target, a.Detail), }) } out = filterAuditViews(out, q.Get("q"), q.Get("action")) diff --git a/internal/control/mutate.go b/internal/control/mutate.go index 027aea4..54565b5 100644 --- a/internal/control/mutate.go +++ b/internal/control/mutate.go @@ -229,9 +229,9 @@ func (c *Console) patchMapping(w http.ResponseWriter, r *http.Request) { next := m.Spec if b.Name != nil { name := strings.TrimSpace(*b.Name) - if name == "" { + if err := validNodeName(name); err != nil { c.mu.Unlock() - writeErr(w, 400, "需要名称") + writeErr(w, 400, err.Error()) return } next.Name = name diff --git a/internal/control/service_test.go b/internal/control/service_test.go index 9136d7c..b254063 100644 --- a/internal/control/service_test.go +++ b/internal/control/service_test.go @@ -160,6 +160,11 @@ func TestCreateRejectsInvalidHostNameAndCidr(t *testing.T) { if res.StatusCode != 200 { t.Fatalf("valid mapping %d %s", res.StatusCode, readBody(t, res)) } + payload["name"] = "!!!bad" + res = doJSON(t, srv, "POST", "/v1/mappings", payload, nil) + if res.StatusCode != 400 { + t.Fatalf("bad service name %d %s", res.StatusCode, readBody(t, res)) + } } func TestCreateNodeRejectsInvalidName(t *testing.T) { @@ -174,6 +179,42 @@ func TestCreateNodeRejectsInvalidName(t *testing.T) { } } +func TestAuditObjectUsesDisplayNameAfterNodeDelete(t *testing.T) { + _, srv, _ := newTestConsole(t) + res := doJSON(t, srv, "POST", "/v1/nodes", map[string]string{"name": "home-nas", "os": "linux", "arch": "amd64"}, nil) + var n struct { + ID string `json:"id"` + } + if err := json.NewDecoder(res.Body).Decode(&n); err != nil { + t.Fatal(err) + } + res.Body.Close() + res = doJSON(t, srv, "POST", "/v1/nodes/"+n.ID+"/delete", map[string]any{"force": true}, nil) + if res.StatusCode != 200 && res.StatusCode != 204 { + t.Fatalf("delete %d %s", res.StatusCode, readBody(t, res)) + } + readBody(t, res) + res = doJSON(t, srv, "GET", "/v1/audit", nil, nil) + var rows []map[string]any + if err := json.NewDecoder(res.Body).Decode(&rows); err != nil { + t.Fatal(err) + } + res.Body.Close() + var deleteRow map[string]any + for _, row := range rows { + if row["action"] == "node.delete" { + deleteRow = row + break + } + } + if deleteRow == nil { + t.Fatalf("missing delete row %+v", rows) + } + if deleteRow["targetName"] != "home-nas" { + t.Fatalf("targetName %+v", deleteRow) + } +} + func TestCreateServiceReturnsFullViewAndRejectsRevokedNode(t *testing.T) { c, srv, _ := newTestConsole(t) c.nodes["node"] = &nodeRec{ID: "node", Name: "QA", Enabled: true} diff --git a/internal/control/transfer.go b/internal/control/transfer.go index 3d34cac..fa1ff6a 100644 --- a/internal/control/transfer.go +++ b/internal/control/transfer.go @@ -468,6 +468,9 @@ func validateBundle(b *ConfigBundle) error { if err := checkNameComment(s.Name, ""); err != nil { return err } + if err := validNodeName(s.Name); err != nil { + return err + } if _, err := specFromExport(*s); err != nil { return err } diff --git a/internal/control/views.go b/internal/control/views.go index 78575e0..d67d590 100644 --- a/internal/control/views.go +++ b/internal/control/views.go @@ -317,7 +317,7 @@ func (c *Console) overviewView(live map[string]gateNode, stats map[string]gate.M recent = append(recent, map[string]any{ "id": a.ID, "ts": a.Ts.UTC().Format(time.RFC3339), "actor": a.Actor, "action": a.Action, "target": a.Target, "detail": a.Detail, - "targetName": c.targetNameLocked(a.Target), + "targetName": c.targetNameLocked(a.Target, a.Detail), }) } return map[string]any{ diff --git a/package.json b/package.json index 8ddcd05..56dac38 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "preview:stop": "node scripts/preview.mjs stop", "typecheck": "tsc --noEmit", "check:auth": "node scripts/check-auth-invariant.mjs", - "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/auth/gate-identity.test.ts src/lib/umbra/format.test.ts src/lib/umbra/live.test.ts src/lib/umbra/page.test.ts src/lib/umbra/units.test.ts src/lib/umbra/service.test.ts src/lib/umbra/validate.test.ts src/lib/umbra/auth-error.test.ts src/lib/umbra/node-services.test.ts src/lib/umbra/traffic-scope.test.ts src/lib/umbra/quick-launch.test.ts src/lib/umbra/transfer.test.ts src/lib/i18n/i18n.test.ts", + "test": "node --test 'scripts/**/*.test.mjs' && node --experimental-strip-types --test src/lib/app-data/app-data.test.ts src/lib/auth/gate-identity.test.ts src/lib/umbra/format.test.ts src/lib/umbra/live.test.ts src/lib/umbra/page.test.ts src/lib/umbra/units.test.ts src/lib/umbra/service.test.ts src/lib/umbra/validate.test.ts src/lib/umbra/labels.test.ts src/lib/umbra/auth-error.test.ts src/lib/umbra/node-services.test.ts src/lib/umbra/traffic-scope.test.ts src/lib/umbra/quick-launch.test.ts src/lib/umbra/transfer.test.ts src/lib/i18n/i18n.test.ts", "lint": "eslint .", "format": "prettier --write ." }, diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx index 3995542..4f54f76 100644 --- a/src/components/empty-state.tsx +++ b/src/components/empty-state.tsx @@ -2,19 +2,29 @@ import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; export function EmptyState({ icon: Icon, title, action, + compact = false, }: { icon: LucideIcon; title: string; action?: ReactNode; + compact?: boolean; }) { return ( -
- +
+

{title}

{action ? (
{action}
diff --git a/src/components/pages/traffic-page.tsx b/src/components/pages/traffic-page.tsx index 2b04862..7dcb90c 100644 --- a/src/components/pages/traffic-page.tsx +++ b/src/components/pages/traffic-page.tsx @@ -280,6 +280,13 @@ export function TrafficPage() { loading={!catalogReady || traffic.isPending} updating={traffic.isPlaceholderData} error={Boolean(problem)} + emptyAction={ + !nodes.data?.length ? ( + + ) : null + } /> {!mappingId && !invalid ? ( diff --git a/src/components/quick-launcher.tsx b/src/components/quick-launcher.tsx index fbd27a2..2a92806 100644 --- a/src/components/quick-launcher.tsx +++ b/src/components/quick-launcher.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Command } from "cmdk"; -import { ArrowUpRight, Plus, Search, Radio, Activity, Settings2 } from "lucide-react"; +import { ArrowUpRight, Plus, Search, Radio, Activity, Settings2, Layers } from "lucide-react"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { useI18n } from "@/lib/i18n"; import { listMappings, listNodes } from "@/lib/umbra/api"; @@ -55,6 +55,12 @@ export function QuickLauncher() { icon: Plus, to: "/mappings" as const, }, + { + value: t("quick.servicesKeys"), + label: t("nav.services"), + icon: Layers, + to: "/mappings" as const, + }, { value: t("quick.nodeKeys"), label: t("nav.nodes"), diff --git a/src/components/rate-chart.tsx b/src/components/rate-chart.tsx index 7bc7b28..0e77201 100644 --- a/src/components/rate-chart.tsx +++ b/src/components/rate-chart.tsx @@ -4,7 +4,9 @@ import { formatBps, formatBytes } from "@/lib/umbra/format"; import { seriesToRate, trafficRangeMs, type TrafficRange } from "@/lib/umbra/live"; import type { TrafficPoint } from "@/lib/umbra/types"; import { useTheme } from "@/components/app-providers"; +import { EmptyState } from "@/components/empty-state"; import { dateLocale, useI18n } from "@/lib/i18n"; +import { Activity } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as echarts from "echarts/core"; import { LineChart } from "echarts/charts"; @@ -259,17 +261,16 @@ export function RateChart({ aria-label={kind === "rate" ? t("traffic.rate") : t("chart.bytesAria")} /> {!showChart || loading || error ? ( -
- - {error - ? t("traffic.trafficFail") - : loading - ? t("chart.loading") - : waitingRate && hasSignal - ? t("chart.waitSample") - : t("chart.empty")} - - {!loading && !error ? emptyAction : null} +
+ {error ? ( + t("traffic.trafficFail") + ) : loading ? ( + t("chart.loading") + ) : waitingRate && hasSignal ? ( + t("chart.waitSample") + ) : ( + + )}
) : null}
diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index d93068a..7949af3 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -881,6 +881,7 @@ export const zh = { open: "回车打开", esc: "退出", addKeys: "添加服务 新建 创建 add new", + servicesKeys: "全部服务 all services mappings", nodeKeys: "节点 接入 安装 nodes", trafficKeys: "观测 流量 审计 traffic", systemKeys: "设置 系统 安全 主题 settings system", @@ -1865,6 +1866,7 @@ export const en = { open: "Enter to open", esc: "Close", addKeys: "添加服务 新建 创建 add new service", + servicesKeys: "全部服务 all services mappings", nodeKeys: "节点 接入 安装 nodes enroll", trafficKeys: "观测 流量 审计 traffic observe", systemKeys: "设置 系统 安全 主题 settings system appearance", diff --git a/src/lib/umbra/labels.test.ts b/src/lib/umbra/labels.test.ts new file mode 100644 index 0000000..9d52977 --- /dev/null +++ b/src/lib/umbra/labels.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { auditTargetHint, auditTargetLabel } from "./labels.ts"; + +test("audit object column prefers a display name over raw ids", () => { + assert.equal( + auditTargetLabel({ target: "nde_abc", targetName: "nde_abc", detail: "home-nas linux/amd64" }), + "home-nas", + ); + assert.equal( + auditTargetLabel({ target: "map_1", targetName: "工作室 SSH · lab", detail: "" }), + "工作室 SSH · lab", + ); + assert.equal(auditTargetLabel({ target: "nde_abc", detail: "" }), "nde_abc"); + assert.equal(auditTargetHint({ target: "nde_abc", targetName: "home-nas" }), "nde_abc"); +}); diff --git a/src/lib/umbra/labels.ts b/src/lib/umbra/labels.ts index d8235aa..e87ebd7 100644 --- a/src/lib/umbra/labels.ts +++ b/src/lib/umbra/labels.ts @@ -126,17 +126,33 @@ export function auditActorLabel(actor: string) { return actor; } -export function auditTargetLabel(item: { target: string; targetName?: string }) { - const name = item.targetName?.trim(); - if (name) return name; - if (/^(nde|map|tkt)_/i.test(item.target)) return t("audit.unknownTarget"); +function looksLikeUmbraID(value: string) { + return /^(nde|map|tkt)_/i.test(value.trim()); +} + +export function humanAuditName(detail?: string) { + const text = detail?.trim() ?? ""; + if (!text || looksLikeUmbraID(text) || text.includes("=")) return ""; + if (/^(SPA |Hello)/i.test(text)) return ""; + const fields = text.split(/\s+/); + const last = fields[fields.length - 1] ?? ""; + if (fields.length >= 2 && last.includes("/") && !looksLikeUmbraID(fields[0])) { + return fields.slice(0, -1).join(" "); + } + return text; +} + +export function auditTargetLabel(item: { target: string; targetName?: string; detail?: string }) { + for (const candidate of [item.targetName, humanAuditName(item.detail), item.target]) { + const value = candidate?.trim(); + if (value && !looksLikeUmbraID(value)) return value; + } return item.target || "—"; } -export function auditTargetHint(item: { target: string; targetName?: string }) { - const name = item.targetName?.trim(); - if (name && item.target && name !== item.target) return item.target; - if (/^(nde|map|tkt)_/i.test(item.target)) return item.target; +export function auditTargetHint(item: { target: string; targetName?: string; detail?: string }) { + const label = auditTargetLabel(item); + if (item.target && item.target !== label) return item.target; return undefined; } diff --git a/src/lib/umbra/quick-launch.test.ts b/src/lib/umbra/quick-launch.test.ts index 3a3fb61..75e1651 100644 --- a/src/lib/umbra/quick-launch.test.ts +++ b/src/lib/umbra/quick-launch.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { QUICK_LAUNCH_LIMIT, quickLaunchHits } from "./quick-launch.ts"; +import { QUICK_LAUNCH_LIMIT, queryWordsMatch, quickLaunchHits } from "./quick-launch.ts"; import type { Mapping, Node } from "./types.ts"; const mapping = (over: Partial & Pick): Mapping => @@ -36,6 +36,11 @@ const node = (over: Partial & Pick): Node => ...over, }) as Node; +test("all-services wording matches the main nav label", () => { + assert.equal(queryWordsMatch("全部服务 all services mappings", "全部服务"), true); + assert.equal(queryWordsMatch("添加服务 新建 创建 add new", "全部服务"), false); +}); + test("empty query hides inventory", () => { const hits = quickLaunchHits( "", diff --git a/src/lib/umbra/service.test.ts b/src/lib/umbra/service.test.ts index e8ac71e..d9e2578 100644 --- a/src/lib/umbra/service.test.ts +++ b/src/lib/umbra/service.test.ts @@ -104,6 +104,11 @@ test("public access needs a second confirm when leaving a closed mode", () => { assert.equal(needsPublicConfirm("visitor", "spa"), false); }); +test("service names follow the same character rules as node names", () => { + assert.ok(validateService({ ...input, name: "!!!bad" })); + assert.equal(validateService({ ...input, name: "工作室 SSH" }), null); +}); + test("hostnames and CIDRs are validated like ports", () => { assert.ok(validateService({ ...input, localHost: "not_a_host!!!" })); assert.ok(validateService({ ...input, allowCidrs: "not-a-cidr" })); diff --git a/src/lib/umbra/service.ts b/src/lib/umbra/service.ts index cbffdd4..8c368f5 100644 --- a/src/lib/umbra/service.ts +++ b/src/lib/umbra/service.ts @@ -1,6 +1,6 @@ import { t } from "../i18n/index.ts"; import type { Mapping, MappingMode, Proto } from "./types.ts"; -import { validCidrs, validHost } from "./validate.ts"; +import { validCidrs, validHost, validNodeName } from "./validate.ts"; export function needsPublicConfirm(current: MappingMode | null | undefined, next: MappingMode) { return next === "public" && current !== "public"; @@ -50,6 +50,7 @@ export type ServiceErrors = Partial>; export function serviceErrors(input: ServiceInput): ServiceErrors { const errors: ServiceErrors = {}; if (!input.name.trim()) errors.name = t("validate.name"); + else if (!validNodeName(input.name)) errors.name = t("validate.nodeName"); if (!input.nodeId) errors.nodeId = t("validate.node"); const host = input.localHost.trim(); if (!host) errors.localHost = t("validate.host");