diff --git a/CHANGELOG.md b/CHANGELOG.md index 312564b..699077d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- 控制台:导航「系统」改为「设置」(`/settings`,`/deploy` 重定向);2FA 文案与页面实际能力一致;修改密码展示至少 8 位规则 +- 控制台:节点、服务、流量、审计空状态统一为图标 + 一句说明 + 可选操作;流量图无数据时居中「暂无流量」 +- 控制台:审计加载中不再闪「没有匹配的记录」;空库与筛选无结果分开;流量/审计筛选空状态可一键清除 +- 控制台:改为公开访问需二次确认,取消则保持原访问方式 +- Console: nav “System” is now “Settings” (`/settings`, `/deploy` redirects); 2FA copy matches what the page can do; password change shows the 8-character rule +- Console: nodes, services, traffic, and audit share one empty-state pattern; empty charts show a centered “No traffic yet” +- Console: audit loading no longer flashes “no matching records”; empty database vs filtered no-match are distinct; traffic/audit filter empties can be cleared in one click +- Console: switching to public access requires a second confirm; cancel keeps the previous mode + - 控制台:服务列表展示访问方式标记,可用访问方式筛选,空状态点名凭证访问 / 临时放行 / 公开访问 - 控制台:凭证访问的关闭公网业务端口改为中性文案,不再与“需处理”并列成故障 - 控制台:登记节点在签发前需确认;取消不会写入节点;成功页说明节点已在列表中,并保留一次性凭证提示 diff --git a/scripts/umbra-ui-smoke.mjs b/scripts/umbra-ui-smoke.mjs index 22b51cc..79a913d 100644 --- a/scripts/umbra-ui-smoke.mjs +++ b/scripts/umbra-ui-smoke.mjs @@ -21,7 +21,8 @@ try { ["/mappings", "全部服务"], ["/traffic", "观测"], ["/audit", "观测"], - ["/deploy", "系统"], + ["/settings", "设置"], + ["/deploy", "设置"], ]) { await page.goto(base + path, { waitUntil: "domcontentloaded" }); await page.getByRole("heading", { name: title, exact: true, level: 1 }).waitFor(); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 1d59b0e..5d46b96 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -16,7 +16,7 @@ const nav = [ { to: "/nodes", labelKey: "nav.nodes", icon: Radio, paths: ["/", "/nodes"] }, { to: "/mappings", labelKey: "nav.services", icon: Layers, paths: ["/mappings"] }, { to: "/traffic", labelKey: "nav.observe", icon: Activity, paths: ["/traffic", "/audit"] }, - { to: "/deploy", labelKey: "nav.system", icon: Settings2, paths: ["/deploy"] }, + { to: "/settings", labelKey: "nav.settings", icon: Settings2, paths: ["/settings", "/deploy"] }, ] as const; export function AppShell({ diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx new file mode 100644 index 0000000..3995542 --- /dev/null +++ b/src/components/empty-state.tsx @@ -0,0 +1,24 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +export function EmptyState({ + icon: Icon, + title, + action, +}: { + icon: LucideIcon; + title: string; + action?: ReactNode; +}) { + return ( +
+ +

{title}

+ {action ? ( +
{action}
+ ) : null} +
+ ); +} diff --git a/src/components/pages/audit-page.tsx b/src/components/pages/audit-page.tsx index d945e9a..0917296 100644 --- a/src/components/pages/audit-page.tsx +++ b/src/components/pages/audit-page.tsx @@ -2,9 +2,12 @@ import { useEffect, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { ScrollText } from "lucide-react"; import { ObservabilityNav } from "@/components/observability-nav"; import { AppShell } from "@/components/app-shell"; +import { EmptyState } from "@/components/empty-state"; import { SelectField } from "@/components/field"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Pager } from "@/components/ui/pager"; import { useI18n } from "@/lib/i18n"; @@ -32,7 +35,13 @@ export function AuditPage() { }); const pageData = audit.data ?? emptyPage(page); const list = pageData.items; - const empty = !audit.isLoading && pageData.total === 0 && !q && action === "all"; + const loaded = audit.isSuccess || Boolean(audit.data); + const empty = loaded && pageData.total === 0 && !q && action === "all"; + const noMatch = loaded && pageData.total === 0 && Boolean(q || action !== "all"); + const clearFilters = () => { + setQ(""); + setAction("all"); + }; const labels = actionLabel(); const actionOptions = [ { value: "all", label: t("audit.all") }, @@ -51,10 +60,12 @@ export function AuditPage() { return ( - {empty ? ( -

- {t("audit.empty")} + {audit.isPending ? ( +

+ {t("audit.loading")}

+ ) : empty ? ( + ) : ( <>
@@ -73,10 +84,16 @@ export function AuditPage() { options={actionOptions} />
- {pageData.total === 0 ? ( -

- {t("audit.none")} -

+ {noMatch ? ( + + {t("audit.clear")} + + } + /> ) : ( <>
diff --git a/src/components/pages/auth-panel.tsx b/src/components/pages/auth-panel.tsx index 0acc430..196e9c0 100644 --- a/src/components/pages/auth-panel.tsx +++ b/src/components/pages/auth-panel.tsx @@ -29,9 +29,15 @@ export function AuthPanel() { return (

{t("auth.kicker")}

-

{t("auth.title")}

+

+ {s.twoFactorRequired && s.twoFactorConfigured ? t("auth.title") : t("auth.passwordTitle")} +

- {s.twoFactorRequired ? t("auth.required") : t("auth.optional")} + {s.twoFactorRequired && s.twoFactorConfigured + ? t("auth.required") + : s.twoFactorRequired + ? t("auth.twoFactorPending") + : t("auth.passwordOnly")}

{s.twoFactorConfigured ? (

@@ -78,6 +84,7 @@ function PasswordForm({ needSecond, onDone }: { needSecond: boolean; onDone: () className="flex flex-col gap-3" onSubmit={(e) => { e.preventDefault(); + if (next.trim().length < 8 || mut.isPending) return; mut.mutate(); }} > @@ -101,7 +108,17 @@ function PasswordForm({ needSecond, onDone }: { needSecond: boolean; onDone: () value={next} onChange={(e) => setNext(e.target.value)} required + aria-describedby="password-rule" + aria-invalid={next.length > 0 && next.length < 8} /> +

+ {t("auth.passwordRule")} +

+ {next.length > 0 && next.length < 8 ? ( +

+ {t("login.errors.shortPassword")} +

+ ) : null} {needSecond ? ( ) : null} - diff --git a/src/components/pages/mappings-page.tsx b/src/components/pages/mappings-page.tsx index 1749021..61c3fda 100644 --- a/src/components/pages/mappings-page.tsx +++ b/src/components/pages/mappings-page.tsx @@ -2,7 +2,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { ArrowLeft, ArrowRight, Plus, Search, SlidersHorizontal, X } from "lucide-react"; +import { ArrowLeft, ArrowRight, Layers, Plus, Search, SlidersHorizontal, X } from "lucide-react"; +import { EmptyState } from "@/components/empty-state"; import { toast } from "sonner"; import { AppShell } from "@/components/app-shell"; import { Button } from "@/components/ui/button"; @@ -238,31 +239,17 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) { {t("services.missingNode")}

) : !nodes.data?.length && !all.length && !error ? ( -
-

{t("services.startTitle")}

-
    -
  1. - 01 -

    {t("services.stepEnroll")}

    -

    {t("services.stepEnrollHint")}

    -
  2. -
  3. - 02 -

    {t("services.stepAdd")}

    -

    {t("services.stepAddHint")}

    -
  4. -
  5. - 03 -

    {t("services.stepConnect")}

    -

    {t("services.stepConnectHint")}

    -
  6. -
- -
+ + + {t("services.firstNode")} + + + } + /> ) : !error || all.length ? ( <>
@@ -428,27 +415,28 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) {
) : null} {!rows.length ? ( -
-

- {q || filterCount + -

- {q || filterCount - ? t("services.noneMatchHint") : scopedNode?.status === "revoked" ? t("services.revokedHint") - : t("services.firstHint")} -

- {q || filterCount ? ( - - ) : null} -

+ : t("services.emptyFirst") + } + action={ + q || filterCount ? ( + + ) : hasNode && scopedNode?.status !== "revoked" ? ( + + ) : null + } + /> ) : ( {pageData.total === 0 ? ( -

- {t("nodes.emptyMatch")} -

+ { + setQ(""); + setStatus("all"); + }} + > + {t("nodes.clear")} + + } + /> ) : ( <>
@@ -369,22 +383,20 @@ function NodeFleetBar({ function EmptyNodes({ onCreate, onImport }: { onCreate: () => void; onImport: () => void }) { const { t } = useI18n(); return ( -
-
-

- {t("nodes.emptyTitle")} -

-

{t("nodes.emptyBody")}

-
-
- - -
-
+ + + + + } + /> ); } diff --git a/src/components/pages/system-page.tsx b/src/components/pages/system-page.tsx index 5fe1b5a..5817ffb 100644 --- a/src/components/pages/system-page.tsx +++ b/src/components/pages/system-page.tsx @@ -11,10 +11,7 @@ export function SystemPage() { return (
-
-

{t("system.heading")}

-

{t("system.intro")}

-
+

{t("system.intro")}

diff --git a/src/components/pages/traffic-page.tsx b/src/components/pages/traffic-page.tsx index 1d9ab28..2b04862 100644 --- a/src/components/pages/traffic-page.tsx +++ b/src/components/pages/traffic-page.tsx @@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { ArrowRight } from "lucide-react"; +import { Activity, ArrowRight, Layers, Radio } from "lucide-react"; +import { EmptyState } from "@/components/empty-state"; import { ObservabilityNav } from "@/components/observability-nav"; import { AppShell } from "@/components/app-shell"; import { ObservationScope } from "@/components/observation-scope"; @@ -104,7 +105,11 @@ export function TrafficPage() { : undefined); return ( - +
-
-
+
+ ) : nodeId ? ( + + ) : ( + + ) + } + /> )} {rows.length > PAGE_SIZE ? ( window.removeEventListener("keydown", handle); }, [open]); - const go = (to: "/mappings" | "/nodes" | "/traffic" | "/deploy", search = {}) => { + const go = (to: "/mappings" | "/nodes" | "/traffic" | "/settings", search = {}) => { close(); void navigate({ to, search }); }; @@ -71,7 +71,7 @@ export function QuickLauncher() { value: t("quick.systemKeys"), label: t("quick.system"), icon: Settings2, - to: "/deploy" as const, + to: "/settings" as const, }, ]; const hits = quickLaunchHits(query, mappings.data ?? [], nodes.data ?? []); diff --git a/src/components/rate-chart.tsx b/src/components/rate-chart.tsx index 6a44002..7bc7b28 100644 --- a/src/components/rate-chart.tsx +++ b/src/components/rate-chart.tsx @@ -128,8 +128,9 @@ export function RateChart({ const elRef = useRef(null); const chartRef = useRef(null); - const waitingRate = kind === "rate" && inn.length === 0; - const showChart = !waitingRate && data.length > 0; + const hasSignal = inn.some(([, value]) => value > 0) || out.some(([, value]) => value > 0); + const waitingRate = kind === "rate" && data.length > 0 && inn.length === 0; + const showChart = hasSignal && !waitingRate; const option = useMemo(() => { const span = trafficRangeMs(range); @@ -242,8 +243,12 @@ export function RateChart({ return (
- - + {showChart && !loading && !error ? ( + <> + + + + ) : null}
0 + : waitingRate && hasSignal ? t("chart.waitSample") - : kind === "rate" - ? t("chart.emptyRate") - : t("chart.emptyBytes")} + : t("chart.empty")} {!loading && !error ? emptyAction : null}
diff --git a/src/components/services/service-editor.tsx b/src/components/services/service-editor.tsx index f79a006..bd8e539 100644 --- a/src/components/services/service-editor.tsx +++ b/src/components/services/service-editor.tsx @@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm"; import { SheetHeader, SheetTitle, @@ -28,6 +29,7 @@ import { import type { Mapping, MappingMode, Proto } from "@/lib/umbra/types"; import { accessOptions, + needsPublicConfirm, serviceErrors, validateService, type ServiceField, @@ -117,6 +119,9 @@ export function ServiceEditor({ const [step, setStep] = useState(0); const [attempted, setAttempted] = useState(false); const [touched, setTouched] = useState>>({}); + const [publicOpen, setPublicOpen] = useState(false); + const [publicAccepted, setPublicAccepted] = useState(mapping?.mode === "public"); + const publicSaveRef = useRef(false); const stepHeading = useRef(null); useEffect(() => { if (!editing) stepHeading.current?.focus(); @@ -175,10 +180,8 @@ export function ServiceEditor({ const stepTitles = [t("editor.where"), t("editor.who"), t("editor.confirm")]; const save = useMutation({ - mutationFn: () => - editing - ? updateMapping({ data: { id: mapping.id, ...payload } }) - : createMapping({ data: payload }), + mutationFn: (data: typeof payload) => + editing ? updateMapping({ data: { id: mapping.id, ...data } }) : createMapping({ data }), onSuccess: (m) => { toast.success(editing ? t("editor.saved") : t("editor.added")); onDone(m); @@ -215,12 +218,17 @@ export function ServiceEditor({ e.preventDefault(); setAttempted(true); if (stepValidation || save.isPending) return; + if (needsPublicConfirm(mapping?.mode, mode) && !publicAccepted) { + publicSaveRef.current = !editing && step < 2 ? false : true; + setPublicOpen(true); + return; + } if (!editing && step < 2) { setAttempted(false); setStep(step + 1); return; } - save.mutate(); + save.mutate(payload); }} > @@ -309,7 +317,15 @@ export function ServiceEditor({ name="service-access" className="mt-1 accent-[var(--pine)]" checked={mode === option.mode} - onChange={() => setMode(option.mode)} + onChange={() => { + if (needsPublicConfirm(mode, option.mode) && !publicAccepted) { + publicSaveRef.current = false; + setPublicOpen(true); + return; + } + if (option.mode !== "public") setPublicAccepted(false); + setMode(option.mode); + }} /> {option.label} @@ -454,6 +470,42 @@ export function ServiceEditor({
+ { + if (!open) { + publicSaveRef.current = false; + setPublicOpen(false); + } + }} + onConfirm={() => { + const next = { + ...payload, + mode: "public" as const, + entryPort: Number(entryPort), + }; + setMode("public"); + setPublicAccepted(true); + setPublicOpen(false); + if (!publicSaveRef.current) return; + publicSaveRef.current = false; + if (validateService(next) || save.isPending) return; + save.mutate(next); + }} + /> ); } + +function validPortNumber(value: string) { + const n = Number(value); + return Number.isInteger(n) && n >= 1 && n <= 65535; +} diff --git a/src/lib/i18n/i18n.test.ts b/src/lib/i18n/i18n.test.ts index 02ced84..57e2c25 100644 --- a/src/lib/i18n/i18n.test.ts +++ b/src/lib/i18n/i18n.test.ts @@ -21,11 +21,13 @@ test("auto preference follows the browser, explicit preference wins", () => { test("t interpolates and falls back, including simple plurals", () => { setActiveLocale("zh"); assert.equal(t("nav.nodes"), "节点"); + assert.equal(t("nav.settings"), "设置"); assert.equal(t("nodes.services", { count: 1 }), "1 项服务"); assert.equal(t("action.node.create"), "登记节点"); assert.equal(t("action.auth.2fa.enrolled"), "绑定双因素"); setActiveLocale("en"); assert.equal(t("nav.nodes"), "Nodes"); + assert.equal(t("nav.settings"), "Settings"); assert.equal(t("nodes.services", { count: 1 }), "1 service"); assert.equal(t("nodes.services", { count: 2 }), "2 services"); assert.equal(t("action.node.create"), "Enroll node"); diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index a87b74a..a2d3de7 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -36,6 +36,7 @@ export const zh = { services: "全部服务", observe: "观测", system: "系统", + settings: "设置", main: "主导航", }, shell: { @@ -67,10 +68,10 @@ export const zh = { en: "English", }, system: { - title: "系统", - description: "管理员安全、语言与界面外观。", - heading: "系统设置", - intro: "管理控制台的安全设置、语言与外观偏好。", + title: "设置", + description: "控制台密码、语言与外观。", + heading: "设置", + intro: "管理控制台登录密码、界面语言与外观。网关部署不在此页。", }, pager: { empty: "共 0 条", @@ -296,8 +297,11 @@ export const zh = { auth: { kicker: "控制台认证", title: "密码与双因素", - required: "登录需要管理员密码和动态验证码。双因素认证由网关管理员配置。", - optional: "当前使用密码登录。验证器绑定会保留;重新启用双因素认证后,需要再次登录。", + passwordTitle: "控制台密码", + required: "登录需要管理员密码和动态验证码。双因素认证由网关配置,不能在此开关。", + passwordOnly: "此网关使用管理员密码登录。双因素认证由网关配置,不能在此开关。", + twoFactorPending: "网关要求双因素认证,请退出后在登录流程中完成绑定。", + passwordRule: "至少 8 位字符", remaining: "剩余恢复码 {n} 个。", changePassword: "修改密码", currentPassword: "当前密码", @@ -334,6 +338,7 @@ export const zh = { rotatedNote: "旧凭证宽限 {sec} 秒", emptyTitle: "先登记一台节点", emptyBody: "凭证只显示一次。之后服务都在服务端改,不用再登录那台机器。", + emptyReason: "还没有节点。登记一台后即可添加服务。", services: { one: "{count} 项服务", other: "{count} 项服务" }, attention: "{n} 项需处理", openServices: "打开 {name} 的服务", @@ -519,6 +524,8 @@ export const zh = { noneMatchHint: "调整搜索或筛选条件,查看其他服务。", revokedHint: "该节点已吊销,请返回并选择有效节点。", firstHint: "填写目标地址和端口。默认凭证访问(无公网业务端口),也可选临时放行或公开访问。", + emptyNoNodes: "还没有节点。先接入一台内网机器,再添加服务。", + emptyFirst: "还没有服务。添加后选择凭证访问、临时放行或公开访问。", deleted: "服务已删除", more: "{name} 的更多操作", edit: "编辑服务", @@ -631,6 +638,12 @@ export const zh = { save: "保存配置", next: "下一步", confirmConnect: "确认接入并连接", + publicConfirmTitle: "改为公开访问?", + publicConfirm: + "公开访问会在公网入口监听业务端口,可被扫描发现。确认后才会使用此方式;取消则保持原来的访问方式。", + publicConfirmPort: + "公开访问会在公网入口监听端口 {port},可被扫描发现。确认后才会使用此方式;取消则保持原来的访问方式。", + publicConfirmSave: "确认公开访问", }, connect: { eyebrow: "SERVICE / 连接工作区", @@ -761,6 +774,7 @@ export const zh = { noMatch: "没有匹配结果,请调整搜索。", noServices: "此节点还没有服务。", noNodes: "还没有节点。", + clear: "清除筛选", nodesFail: "无法读取节点或服务,请重试。", trafficFail: "流量读取失败,请重试。", sessions: "活跃会话 {n}", @@ -775,6 +789,7 @@ export const zh = { out: "出站", loading: "正在读取流量…", waitSample: "再等一个采样即可画出速率。", + empty: "暂无流量", emptyRate: "还没有采样数据,收到采样后显示速率趋势。", emptyBytes: "还没有采样数据,收到采样后显示累计流量。", bytesAria: "历史累计流量", @@ -783,11 +798,13 @@ export const zh = { title: "观测", description: "查看服务流量与操作记录。", empty: "还没有审计记录。", + loading: "正在读取审计记录…", searchPh: "搜索操作、对象、详情", search: "搜索审计记录", action: "操作", all: "全部操作", none: "没有匹配的记录。", + clear: "清除筛选", time: "时间", target: "对象", detail: "详情", @@ -824,7 +841,7 @@ export const zh = { add: "添加服务", nodes: "接入与管理节点", traffic: "查看网络观测", - system: "系统与外观", + system: "设置与外观", services: "服务", nodeGroup: "节点", moreServices: "还有 {n} 项服务,去列表看", @@ -839,7 +856,7 @@ export const zh = { addKeys: "添加服务 新建 创建 add new", nodeKeys: "节点 接入 安装 nodes", trafficKeys: "观测 流量 审计 traffic", - systemKeys: "系统 安全 主题 system", + systemKeys: "设置 系统 安全 主题 settings system", }, action: { "node.create": "登记节点", @@ -932,6 +949,7 @@ export const en = { services: "All services", observe: "Observe", system: "System", + settings: "Settings", main: "Main", }, shell: { @@ -964,10 +982,11 @@ export const en = { en: "English", }, system: { - title: "System", - description: "Admin security, language, and appearance.", - heading: "System settings", - intro: "Manage console security, language, and appearance.", + title: "Settings", + description: "Console password, language, and appearance.", + heading: "Settings", + intro: + "Manage the console password, language, and appearance. Gateway deployment is not on this page.", }, pager: { empty: "0 items", @@ -1206,10 +1225,14 @@ export const en = { auth: { kicker: "Console authentication", title: "Password and two-factor", + passwordTitle: "Console password", required: - "Sign-in requires an administrator password and an authenticator code. Two-factor authentication is configured by the gateway administrator.", - optional: - "This gateway uses password-only sign-in. Existing authenticator bindings are kept. Sign in again when two-factor authentication is re-enabled.", + "Sign-in requires an administrator password and an authenticator code. Two-factor authentication is configured on the gateway and cannot be switched here.", + passwordOnly: + "This gateway uses administrator password sign-in. Two-factor authentication is configured on the gateway and cannot be switched here.", + twoFactorPending: + "The gateway requires two-factor authentication. Sign out and finish binding during sign-in.", + passwordRule: "At least 8 characters", remaining: "{n} recovery codes left.", changePassword: "Change password", currentPassword: "Current password", @@ -1248,6 +1271,7 @@ export const en = { emptyTitle: "Enroll a node first", emptyBody: "The credential is shown once. After that, services are changed on the server without logging into the machine.", + emptyReason: "No nodes yet. Enroll one, then add services.", services: { one: "{count} service", other: "{count} services" }, attention: "{n} need attention", openServices: "Open services on {name}", @@ -1447,6 +1471,9 @@ export const en = { revokedHint: "This node is revoked. Go back and pick a valid node.", firstHint: "Enter a target address and port. Default is ticket access (no public business port); you can also choose temporary allow or public access.", + emptyNoNodes: "No nodes yet. Enroll a private machine first, then add a service.", + emptyFirst: + "No services yet. After adding, choose ticket access, temporary allow, or public access.", deleted: "Service deleted", more: "More actions for {name}", edit: "Edit service", @@ -1562,6 +1589,12 @@ export const en = { save: "Save config", next: "Next", confirmConnect: "Confirm and connect", + publicConfirmTitle: "Switch to public access?", + publicConfirm: + "Public access listens on a public business port and can be scanned. Confirm to use this mode; cancel keeps the previous mode.", + publicConfirmPort: + "Public access will listen on port {port} and can be scanned. Confirm to use this mode; cancel keeps the previous mode.", + publicConfirmSave: "Confirm public access", }, connect: { eyebrow: "SERVICE / connection workspace", @@ -1698,6 +1731,7 @@ export const en = { noMatch: "No matches. Adjust the search.", noServices: "This node has no services yet.", noNodes: "No nodes yet.", + clear: "Clear filters", nodesFail: "Could not read nodes or services. Try again.", trafficFail: "Could not read traffic. Try again.", sessions: "Active sessions {n}", @@ -1712,6 +1746,7 @@ export const en = { out: "Out", loading: "Reading traffic…", waitSample: "One more sample will draw the rate.", + empty: "No traffic yet", emptyRate: "No samples yet. Rate trend appears after they arrive.", emptyBytes: "No samples yet. Cumulative traffic appears after they arrive.", bytesAria: "Historical cumulative traffic", @@ -1720,11 +1755,13 @@ export const en = { title: "Observe", description: "Service traffic and audit records.", empty: "No audit records yet.", + loading: "Reading audit records…", searchPh: "Search action, target, or detail", search: "Search audit records", action: "Action", all: "All actions", none: "No matching records.", + clear: "Clear filters", time: "Time", target: "Target", detail: "Detail", @@ -1761,7 +1798,7 @@ export const en = { add: "Add service", nodes: "Enroll and manage nodes", traffic: "View network observation", - system: "System and appearance", + system: "Settings and appearance", services: "Services", nodeGroup: "Nodes", moreServices: "{n} more services, open the list", @@ -1776,7 +1813,7 @@ export const en = { addKeys: "添加服务 新建 创建 add new service", nodeKeys: "节点 接入 安装 nodes enroll", trafficKeys: "观测 流量 审计 traffic observe", - systemKeys: "系统 安全 主题 system appearance", + systemKeys: "设置 系统 安全 主题 settings system appearance", }, action: { "node.create": "Enroll node", diff --git a/src/lib/umbra/service.test.ts b/src/lib/umbra/service.test.ts index aa8a2ee..f1344b5 100644 --- a/src/lib/umbra/service.test.ts +++ b/src/lib/umbra/service.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + needsPublicConfirm, serviceCanConnect, serviceState, serviceSummary, @@ -89,6 +90,14 @@ test("real target and public entry ports must be explicit and valid", () => { assert.ok(validateService({ ...input, maxConns: NaN })); }); +test("public access needs a second confirm when leaving a closed mode", () => { + assert.equal(needsPublicConfirm("visitor", "public"), true); + assert.equal(needsPublicConfirm("spa", "public"), true); + assert.equal(needsPublicConfirm(undefined, "public"), true); + assert.equal(needsPublicConfirm("public", "public"), false); + assert.equal(needsPublicConfirm("visitor", "spa"), false); +}); + 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 eefda9a..8004284 100644 --- a/src/lib/umbra/service.ts +++ b/src/lib/umbra/service.ts @@ -2,6 +2,10 @@ import { t } from "../i18n/index.ts"; import type { Mapping, MappingMode, Proto } from "./types.ts"; import { validCidrs, validHost } from "./validate.ts"; +export function needsPublicConfirm(current: MappingMode | null | undefined, next: MappingMode) { + return next === "public" && current !== "public"; +} + export function accessOptions(): { mode: MappingMode; label: string; description: string }[] { return [ { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2b4b1ae..25ec68e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DeployRouteImport } from './routes/deploy' import { Route as LoginRouteImport } from './routes/login' import { Route as MappingsRouteImport } from './routes/mappings' import { Route as NodesRouteImport } from './routes/nodes' +import { Route as SettingsRouteImport } from './routes/settings' import { Route as TrafficRouteImport } from './routes/traffic' import { Route as NodesNodeIdRouteImport } from './routes/nodes_.$nodeId' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -49,6 +50,11 @@ const NodesRoute = NodesRouteImport.update({ path: '/nodes', getParentRoute: () => rootRouteImport, } as any) +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any) const TrafficRoute = TrafficRouteImport.update({ id: '/traffic', path: '/traffic', @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/mappings': typeof MappingsRoute '/nodes': typeof NodesRoute + '/settings': typeof SettingsRoute '/traffic': typeof TrafficRoute '/nodes/$nodeId': typeof NodesNodeIdRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -83,6 +90,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/mappings': typeof MappingsRoute '/nodes': typeof NodesRoute + '/settings': typeof SettingsRoute '/traffic': typeof TrafficRoute '/nodes/$nodeId': typeof NodesNodeIdRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -95,6 +103,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/mappings': typeof MappingsRoute '/nodes': typeof NodesRoute + '/settings': typeof SettingsRoute '/traffic': typeof TrafficRoute '/nodes_/$nodeId': typeof NodesNodeIdRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -108,6 +117,7 @@ export interface FileRouteTypes { | '/login' | '/mappings' | '/nodes' + | '/settings' | '/traffic' | '/nodes/$nodeId' | '/api/auth/$' @@ -119,6 +129,7 @@ export interface FileRouteTypes { | '/login' | '/mappings' | '/nodes' + | '/settings' | '/traffic' | '/nodes/$nodeId' | '/api/auth/$' @@ -130,6 +141,7 @@ export interface FileRouteTypes { | '/login' | '/mappings' | '/nodes' + | '/settings' | '/traffic' | '/nodes_/$nodeId' | '/api/auth/$' @@ -142,6 +154,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute MappingsRoute: typeof MappingsRoute NodesRoute: typeof NodesRoute + SettingsRoute: typeof SettingsRoute TrafficRoute: typeof TrafficRoute NodesNodeIdRoute: typeof NodesNodeIdRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute @@ -191,6 +204,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NodesRouteImport parentRoute: typeof rootRouteImport } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRouteImport + } '/traffic': { id: '/traffic' path: '/traffic' @@ -222,6 +242,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, MappingsRoute: MappingsRoute, NodesRoute: NodesRoute, + SettingsRoute: SettingsRoute, TrafficRoute: TrafficRoute, NodesNodeIdRoute: NodesNodeIdRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, diff --git a/src/routes/deploy.tsx b/src/routes/deploy.tsx index 63c7fed..602d15c 100644 --- a/src/routes/deploy.tsx +++ b/src/routes/deploy.tsx @@ -1,4 +1,7 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SystemPage } from "@/components/pages/system-page"; +import { createFileRoute, redirect } from "@tanstack/react-router"; -export const Route = createFileRoute("/deploy")({ component: SystemPage }); +export const Route = createFileRoute("/deploy")({ + beforeLoad: () => { + throw redirect({ to: "/settings" }); + }, +}); diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx new file mode 100644 index 0000000..d9b4e53 --- /dev/null +++ b/src/routes/settings.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SystemPage } from "@/components/pages/system-page"; + +export const Route = createFileRoute("/settings")({ component: SystemPage });