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");