Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;快速前往与主导航用词对齐
Expand Down
31 changes: 29 additions & 2 deletions internal/control/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -1503,6 +1527,9 @@ func (c *Console) targetNameLocked(target string) string {
}
return m.Spec.Name
}
if name := humanAuditName(detail); name != "" {
return name
}
return target
}

Expand All @@ -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"))
Expand Down
4 changes: 2 additions & 2 deletions internal/control/mutate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/control/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions internal/control/transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/control/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
},
Expand Down
14 changes: 12 additions & 2 deletions src/components/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-line px-5 py-12 text-center">
<Icon className="size-8 text-stone" aria-hidden />
<div
className={cn(
"flex flex-col items-center justify-center text-center",
compact
? "gap-2 px-3 py-6"
: "gap-4 rounded-xl border border-dashed border-line px-5 py-12",
)}
>
<Icon className={cn("text-stone", compact ? "size-6" : "size-8")} aria-hidden />
<p className="max-w-md text-sm leading-relaxed text-ink-soft">{title}</p>
{action ? (
<div className="flex flex-wrap items-center justify-center gap-2">{action}</div>
Expand Down
7 changes: 7 additions & 0 deletions src/components/pages/traffic-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ export function TrafficPage() {
loading={!catalogReady || traffic.isPending}
updating={traffic.isPlaceholderData}
error={Boolean(problem)}
emptyAction={
!nodes.data?.length ? (
<Button asChild size="sm">
<Link to="/nodes">{t("nodes.enroll")}</Link>
</Button>
) : null
}
/>
</section>
{!mappingId && !invalid ? (
Expand Down
8 changes: 7 additions & 1 deletion src/components/quick-launcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
23 changes: 12 additions & 11 deletions src/components/rate-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -259,17 +261,16 @@ export function RateChart({
aria-label={kind === "rate" ? t("traffic.rate") : t("chart.bytesAria")}
/>
{!showChart || loading || error ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center text-sm text-stone">
<span>
{error
? t("traffic.trafficFail")
: loading
? t("chart.loading")
: waitingRate && hasSignal
? t("chart.waitSample")
: t("chart.empty")}
</span>
{!loading && !error ? emptyAction : null}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center text-sm text-stone">
{error ? (
t("traffic.trafficFail")
) : loading ? (
t("chart.loading")
) : waitingRate && hasSignal ? (
t("chart.waitSample")
) : (
<EmptyState icon={Activity} title={t("chart.empty")} action={emptyAction} compact />
)}
</div>
) : null}
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,7 @@ export const zh = {
open: "回车打开",
esc: "退出",
addKeys: "添加服务 新建 创建 add new",
servicesKeys: "全部服务 all services mappings",
nodeKeys: "节点 接入 安装 nodes",
trafficKeys: "观测 流量 审计 traffic",
systemKeys: "设置 系统 安全 主题 settings system",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/lib/umbra/labels.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
32 changes: 24 additions & 8 deletions src/lib/umbra/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
7 changes: 6 additions & 1 deletion src/lib/umbra/quick-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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<Mapping> & Pick<Mapping, "id" | "name">): Mapping =>
Expand Down Expand Up @@ -36,6 +36,11 @@ const node = (over: Partial<Node> & Pick<Node, "id" | "name">): 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(
"",
Expand Down
5 changes: 5 additions & 0 deletions src/lib/umbra/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
3 changes: 2 additions & 1 deletion src/lib/umbra/service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -50,6 +50,7 @@ export type ServiceErrors = Partial<Record<ServiceField, string>>;
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");
Expand Down
Loading