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

- 控制台:导航「系统」改为「设置」(`/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

- 控制台:服务列表展示访问方式标记,可用访问方式筛选,空状态点名凭证访问 / 临时放行 / 公开访问
- 控制台:凭证访问的关闭公网业务端口改为中性文案,不再与“需处理”并列成故障
- 控制台:登记节点在签发前需确认;取消不会写入节点;成功页说明节点已在列表中,并保留一次性凭证提示
Expand Down
3 changes: 2 additions & 1 deletion scripts/umbra-ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
24 changes: 24 additions & 0 deletions src/components/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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 />
<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>
) : null}
</div>
);
}
33 changes: 25 additions & 8 deletions src/components/pages/audit-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -32,7 +35,13 @@ export function AuditPage() {
});
const pageData = audit.data ?? emptyPage<AuditItem>(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") },
Expand All @@ -51,10 +60,12 @@ export function AuditPage() {
return (
<AppShell title={t("audit.title")} description={t("audit.description")}>
<ObservabilityNav active="audit" />
{empty ? (
<p className="rounded-xl bg-card px-4 py-10 text-center text-sm text-stone shadow-border">
{t("audit.empty")}
{audit.isPending ? (
<p role="status" className="py-10 text-sm text-stone">
{t("audit.loading")}
</p>
) : empty ? (
<EmptyState icon={ScrollText} title={t("audit.empty")} />
) : (
<>
<div className="mb-4 flex flex-wrap items-end gap-3">
Expand All @@ -73,10 +84,16 @@ export function AuditPage() {
options={actionOptions}
/>
</div>
{pageData.total === 0 ? (
<p className="rounded-xl bg-card px-4 py-10 text-center text-sm text-stone shadow-border">
{t("audit.none")}
</p>
{noMatch ? (
<EmptyState
icon={ScrollText}
title={t("audit.none")}
action={
<Button variant="outline" onClick={clearFilters}>
{t("audit.clear")}
</Button>
}
/>
) : (
<>
<div className="flex flex-col gap-2 md:hidden">
Expand Down
23 changes: 20 additions & 3 deletions src/components/pages/auth-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ export function AuthPanel() {
return (
<section className="rounded-xl bg-card p-5 shadow-border">
<p className="text-xs font-medium text-pine">{t("auth.kicker")}</p>
<h2 className="mt-1 text-base font-medium text-ink">{t("auth.title")}</h2>
<h2 className="mt-1 text-base font-medium text-ink">
{s.twoFactorRequired && s.twoFactorConfigured ? t("auth.title") : t("auth.passwordTitle")}
</h2>
<p className="mt-1 text-sm leading-relaxed text-stone">
{s.twoFactorRequired ? t("auth.required") : t("auth.optional")}
{s.twoFactorRequired && s.twoFactorConfigured
? t("auth.required")
: s.twoFactorRequired
? t("auth.twoFactorPending")
: t("auth.passwordOnly")}
</p>
{s.twoFactorConfigured ? (
<p className="mt-2 text-sm text-ink">
Expand Down Expand Up @@ -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();
}}
>
Expand All @@ -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}
/>
<p id="password-rule" className="text-xs text-stone">
{t("auth.passwordRule")}
</p>
{next.length > 0 && next.length < 8 ? (
<p role="alert" className="text-xs text-rose">
{t("login.errors.shortPassword")}
</p>
) : null}
</label>
{needSecond ? (
<label className="flex flex-col gap-1.5">
Expand All @@ -116,7 +133,7 @@ function PasswordForm({ needSecond, onDone }: { needSecond: boolean; onDone: ()
/>
</label>
) : null}
<Button type="submit" variant="outline" disabled={mut.isPending}>
<Button type="submit" variant="outline" disabled={mut.isPending || next.trim().length < 8}>
{mut.isPending ? t("common.loading") : t("auth.updatePassword")}
</Button>
</form>
Expand Down
76 changes: 32 additions & 44 deletions src/components/pages/mappings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -238,31 +239,17 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) {
{t("services.missingNode")}
</p>
) : !nodes.data?.length && !all.length && !error ? (
<section className="rounded-xl border border-line bg-card p-6 sm:p-8">
<h3 className="text-xl font-semibold">{t("services.startTitle")}</h3>
<ol className="my-6 grid gap-5 text-sm sm:grid-cols-3">
<li>
<span className="text-xs text-stone">01</span>
<p className="mt-1 font-medium">{t("services.stepEnroll")}</p>
<p className="mt-1 text-stone">{t("services.stepEnrollHint")}</p>
</li>
<li>
<span className="text-xs text-stone">02</span>
<p className="mt-1 font-medium">{t("services.stepAdd")}</p>
<p className="mt-1 text-stone">{t("services.stepAddHint")}</p>
</li>
<li>
<span className="text-xs text-stone">03</span>
<p className="mt-1 font-medium">{t("services.stepConnect")}</p>
<p className="mt-1 text-stone">{t("services.stepConnectHint")}</p>
</li>
</ol>
<Button asChild>
<Link to="/nodes">
{t("services.firstNode")} <ArrowRight className="ml-2 size-4" />
</Link>
</Button>
</section>
<EmptyState
icon={Layers}
title={t("services.emptyNoNodes")}
action={
<Button asChild>
<Link to="/nodes">
{t("services.firstNode")} <ArrowRight className="ml-2 size-4" />
</Link>
</Button>
}
/>
) : !error || all.length ? (
<>
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -428,27 +415,28 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) {
</div>
) : null}
{!rows.length ? (
<section className="rounded-lg border border-dashed border-line px-5 py-10 text-center">
<h3 className="font-medium">
{q || filterCount
<EmptyState
icon={Layers}
title={
q || filterCount
? t("services.noneMatch")
: scopedNode?.status === "revoked"
? t("services.noneOnNode")
: t("services.first")}
</h3>
<p className="mt-2 text-sm text-stone">
{q || filterCount
? t("services.noneMatchHint")
: scopedNode?.status === "revoked"
? t("services.revokedHint")
: t("services.firstHint")}
</p>
{q || filterCount ? (
<Button variant="outline" className="mt-5" onClick={clearFilters}>
{t("services.clear")}
</Button>
) : null}
</section>
: t("services.emptyFirst")
}
action={
q || filterCount ? (
<Button variant="outline" onClick={clearFilters}>
{t("services.clear")}
</Button>
) : hasNode && scopedNode?.status !== "revoked" ? (
<Button onClick={() => setEditor({ mode: "create", nodeId: search.node })}>
<Plus className="mr-1.5 size-4" />
{t("services.add")}
</Button>
) : null
}
/>
) : (
<ServiceList
mappings={pageData.items}
Expand Down
52 changes: 32 additions & 20 deletions src/components/pages/nodes-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearch, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ArrowRight, Search } from "lucide-react";
import { ArrowRight, Radio, Search } from "lucide-react";
import { EmptyState } from "@/components/empty-state";
import { toast } from "sonner";
import { AppShell } from "@/components/app-shell";
import { StatusDot } from "@/components/status-dot";
Expand Down Expand Up @@ -187,9 +188,22 @@ export function NodesPage() {
/>

{pageData.total === 0 ? (
<p className="rounded-xl bg-card px-4 py-8 text-center text-sm text-stone shadow-border">
{t("nodes.emptyMatch")}
</p>
<EmptyState
icon={Radio}
title={t("nodes.emptyMatch")}
action={
<Button
type="button"
variant="outline"
onClick={() => {
setQ("");
setStatus("all");
}}
>
{t("nodes.clear")}
</Button>
}
/>
) : (
<>
<div className="node-directory" role="list" aria-label={t("nodes.list")}>
Expand Down Expand Up @@ -369,22 +383,20 @@ function NodeFleetBar({
function EmptyNodes({ onCreate, onImport }: { onCreate: () => void; onImport: () => void }) {
const { t } = useI18n();
return (
<div className="mx-auto flex max-w-md flex-col items-start gap-5 py-8">
<div>
<h2 className="font-serif text-3xl italic tracking-tight text-ink">
{t("nodes.emptyTitle")}
</h2>
<p className="mt-2 text-sm leading-relaxed text-ink-soft">{t("nodes.emptyBody")}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={onCreate}>
{t("nodes.enroll")}
</Button>
<Button type="button" variant="outline" onClick={onImport}>
{t("transfer.import")}
</Button>
</div>
</div>
<EmptyState
icon={Radio}
title={t("nodes.emptyReason")}
action={
<>
<Button type="button" onClick={onCreate}>
{t("nodes.enroll")}
</Button>
<Button type="button" variant="outline" onClick={onImport}>
{t("transfer.import")}
</Button>
</>
}
/>
);
}

Expand Down
5 changes: 1 addition & 4 deletions src/components/pages/system-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ export function SystemPage() {
return (
<AppShell title={t("system.title")} description={t("system.description")}>
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-ink">{t("system.heading")}</h2>
<p className="mt-2 text-sm leading-relaxed text-ink-soft">{t("system.intro")}</p>
</div>
<p className="text-sm leading-relaxed text-ink-soft">{t("system.intro")}</p>
<AuthPanel />
<LanguagePicker />
<ThemePicker />
Expand Down
Loading
Loading