mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 22:20:46 +08:00
Improve managed NE filters/validation and simplify the workbench UI.
Add column filters and clearer API error mapping for NE management, tighten port validation, and slim the workbench into reusable modules with cleaner styles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3c9e0efd2b
commit
312f6f2f96
25 changed files with 1484 additions and 2016 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -8,10 +8,13 @@ import { FieldSelect } from "../../../components/ui/FieldSelect";
|
|||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { patchHopVendorChange } from "../lib/hopProxy";
|
||||
import { managedNeErrorText } from "../lib/errors";
|
||||
import type { ManagedNeItem } from "../../../types";
|
||||
import {
|
||||
applyHopTemplate,
|
||||
buildManagedNeSaveBody,
|
||||
deviceTypeForVendor,
|
||||
isValidMgmtAddress,
|
||||
emptyManagedNeForm,
|
||||
formFromManagedNe,
|
||||
allowsOpenExecPolicy,
|
||||
|
|
@ -37,6 +40,9 @@ export function ManagedNeFormDialog({
|
|||
}: ManagedNeFormDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState<ManagedNeFormState>(emptyManagedNeForm);
|
||||
// Port is edited as text so the field can be cleared while typing.
|
||||
const [portDraft, setPortDraft] = useState("22");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const metaQuery = useQuery({
|
||||
queryKey: queryKeys.managedNeMeta,
|
||||
|
|
@ -45,16 +51,6 @@ export function ManagedNeFormDialog({
|
|||
enabled: open,
|
||||
});
|
||||
|
||||
const editingId = editing?.id ?? "";
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
setForm(formFromManagedNe(editing));
|
||||
return;
|
||||
}
|
||||
setForm({ ...emptyManagedNeForm(), ...(initialValues || {}) });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create
|
||||
}, [open, editingId]);
|
||||
|
||||
const vendors = metaQuery.data?.vendors ?? [];
|
||||
const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
|
||||
|
|
@ -65,6 +61,15 @@ export function ManagedNeFormDialog({
|
|||
return base;
|
||||
}, [metaQuery.data?.device_types, form.device_type]);
|
||||
|
||||
const ipText = form.ip_address.trim();
|
||||
const portNum = /^\d+$/.test(portDraft.trim()) ? Number(portDraft.trim()) : NaN;
|
||||
const fieldErrors = {
|
||||
ip: !ipText ? t("managedNe.form.ipRequired") : !isValidMgmtAddress(ipText) ? t("managedNe.form.ipInvalid") : "",
|
||||
username: !form.username.trim() ? t("managedNe.form.usernameRequired") : "",
|
||||
port: portNum >= 1 && portNum <= 65535 ? "" : t("managedNe.form.portInvalid"),
|
||||
};
|
||||
const hasFieldErrors = Object.values(fieldErrors).some(Boolean);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = buildManagedNeSaveBody(form, {
|
||||
|
|
@ -84,6 +89,17 @@ export function ManagedNeFormDialog({
|
|||
},
|
||||
});
|
||||
|
||||
const editingId = editing?.id ?? "";
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const seed = editing ? formFromManagedNe(editing) : { ...emptyManagedNeForm(), ...(initialValues || {}) };
|
||||
setForm(seed);
|
||||
setPortDraft(String(seed.port));
|
||||
setSubmitted(false);
|
||||
saveMutation.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create
|
||||
}, [open, editingId]);
|
||||
|
||||
return (
|
||||
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
|
||||
<Modal.Header>
|
||||
|
|
@ -113,12 +129,13 @@ export function ManagedNeFormDialog({
|
|||
setForm((prev) => {
|
||||
const next = { ...prev, vendor };
|
||||
const dt = String(prev.device_type || "").trim().toLowerCase();
|
||||
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
|
||||
if (vendor === "ZTE") next.device_type = "zte_zxros";
|
||||
else if (vendor === "Huawei") next.device_type = "huawei";
|
||||
else if (vendor === "Cisco") next.device_type = "cisco_ios";
|
||||
else if (vendor === "Juniper") next.device_type = "juniper_junos";
|
||||
else if (vendor === "Nokia") next.device_type = "nokia_sros";
|
||||
const suggested = deviceTypeForVendor(vendor);
|
||||
// Follow the vendor while the type is a placeholder or still the old vendor's default.
|
||||
const followVendor = !dt || dt === deviceTypeForVendor(prev.vendor)
|
||||
|| ((dt === "generic" || dt === "other" || dt === "linux") && suggested !== "generic");
|
||||
if (followVendor) {
|
||||
next.device_type = suggested;
|
||||
if (!allowsOpenExecPolicy(suggested)) next.exec_policy = "readonly";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
|
@ -175,25 +192,39 @@ export function ManagedNeFormDialog({
|
|||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
isInvalid={submitted && Boolean(fieldErrors.ip)}
|
||||
value={form.ip_address}
|
||||
onChange={(ip_address) => setForm({ ...form, ip_address })}
|
||||
>
|
||||
<Label>{t("managedNe.col.ip")}</Label>
|
||||
<Input />
|
||||
<Input placeholder="192.0.2.10 / pe-01.example.net" />
|
||||
{submitted && fieldErrors.ip ? <span className="form-field-hint error-text">{fieldErrors.ip}</span> : null}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={String(form.port)}
|
||||
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
|
||||
isRequired
|
||||
inputMode="numeric"
|
||||
isInvalid={submitted && Boolean(fieldErrors.port)}
|
||||
value={portDraft}
|
||||
onChange={(value) => {
|
||||
setPortDraft(value);
|
||||
if (/^\d+$/.test(value.trim())) setForm((prev) => ({ ...prev, port: Number(value.trim()) }));
|
||||
}}
|
||||
>
|
||||
<Label>{t("managedNe.col.port")}</Label>
|
||||
<Input />
|
||||
{submitted && fieldErrors.port ? <span className="form-field-hint error-text">{fieldErrors.port}</span> : null}
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.protocol")}
|
||||
value={form.protocol}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const protocol = e.target.value;
|
||||
// Keep the well-known port in step with the protocol unless it was customised.
|
||||
const swapPort = protocol === "telnet" && portNum === 22 ? 23 : protocol === "ssh" && portNum === 23 ? 22 : 0;
|
||||
setForm((prev) => ({ ...prev, protocol, ...(swapPort ? { port: swapPort } : {}) }));
|
||||
if (swapPort) setPortDraft(String(swapPort));
|
||||
}}
|
||||
>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="telnet">telnet</option>
|
||||
|
|
@ -201,11 +232,13 @@ export function ManagedNeFormDialog({
|
|||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
isInvalid={submitted && Boolean(fieldErrors.username)}
|
||||
value={form.username}
|
||||
onChange={(username) => setForm({ ...form, username })}
|
||||
>
|
||||
<Label>{t("managedNe.col.user")}</Label>
|
||||
<Input />
|
||||
{submitted && fieldErrors.username ? <span className="form-field-hint error-text">{fieldErrors.username}</span> : null}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
|
|
@ -279,7 +312,7 @@ export function ManagedNeFormDialog({
|
|||
{saveMutation.isError ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
|
||||
<Alert.Description>{managedNeErrorText(saveMutation.error, t)}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
|
@ -291,7 +324,11 @@ export function ManagedNeFormDialog({
|
|||
<Button
|
||||
variant="primary"
|
||||
isDisabled={saveMutation.isPending}
|
||||
onPress={() => saveMutation.mutate()}
|
||||
onPress={() => {
|
||||
setSubmitted(true);
|
||||
if (hasFieldErrors) return;
|
||||
saveMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</Button>
|
||||
|
|
|
|||
19
web/src/features/managed-ne/lib/errors.ts
Normal file
19
web/src/features/managed-ne/lib/errors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { formatErr } from "../../../services/api";
|
||||
|
||||
type Translate = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
/** Map API detail codes such as `ip_address_exists` or `hop_password_required: a,b` to localized text. */
|
||||
export function managedNeErrorText(err: unknown, t: Translate): string {
|
||||
const raw = formatErr(err);
|
||||
const matched = /^([a-z_]+)(?::\s*([\s\S]*))?$/.exec(raw.trim());
|
||||
if (!matched) return raw;
|
||||
const key = `managedNe.errors.${matched[1]}`;
|
||||
const template = t(key);
|
||||
if (template === key) return raw;
|
||||
const detail = (matched[2] || "").trim();
|
||||
if (detail) return template.replace(/\{\{detail\}\}/g, detail);
|
||||
// No detail from the API: drop the "(…{{detail}})" or ": {{detail}}" part of the message.
|
||||
return template
|
||||
.replace(/\s*[((][^(())]*\{\{detail\}\}[))]/g, "")
|
||||
.replace(/\s*[::]\s*\{\{detail\}\}/g, "");
|
||||
}
|
||||
|
|
@ -30,6 +30,26 @@ export type ManagedNeFormState = {
|
|||
exec_policy: "readonly" | "linux_shell" | "unrestricted";
|
||||
};
|
||||
|
||||
const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const HOSTNAME_RE = /^(?=.{1,253}$)[A-Za-z0-9_][A-Za-z0-9_-]{0,62}(\.[A-Za-z0-9_][A-Za-z0-9_-]{0,62})*\.?$/;
|
||||
|
||||
/** IPv4 / IPv6 literal or DNS hostname; mirrors the API's `invalid_ip_address` check. */
|
||||
export function isValidMgmtAddress(value: string): boolean {
|
||||
const text = value.trim();
|
||||
if (!text) return false;
|
||||
if (IPV4_RE.test(text)) return true;
|
||||
if (/^[0-9.]+$/.test(text)) return false;
|
||||
if (text.includes(":")) {
|
||||
try {
|
||||
new URL(`http://[${text}]/`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return HOSTNAME_RE.test(text);
|
||||
}
|
||||
|
||||
export function deviceTypeForVendor(vendor: string): string {
|
||||
if (vendor === "ZTE") return "zte_zxros";
|
||||
if (vendor === "Huawei") return "huawei";
|
||||
|
|
|
|||
|
|
@ -2,22 +2,18 @@ import { useMemo, type ReactNode } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@heroui/react";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { WorkbenchCardIcon } from "./components/WorkbenchCardIcon";
|
||||
import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules";
|
||||
import { ConsoleIcon } from "./components/ConsoleIcon";
|
||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../../services/api";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
import { parseApiTime } from "../../utils/time";
|
||||
import { isUmeWorldContainer, isWorldDrillFolder } from "../topology/lib/treeUtils";
|
||||
import type { TopologyTreeFolderItem } from "../../types";
|
||||
|
||||
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
||||
|
||||
type DisplaySection = {
|
||||
key: "operations" | "system";
|
||||
mods: ModuleDefinition[];
|
||||
};
|
||||
|
||||
function formatCount(n: number | string): string {
|
||||
if (typeof n === "string") return n;
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
|
|
@ -32,20 +28,13 @@ function formatAgeSec(sec: number | null | undefined): string {
|
|||
const n = Math.max(0, sec);
|
||||
if (n < 60) return `${Math.round(n)}s`;
|
||||
if (n < 3600) return `${Math.max(1, Math.round(n / 60))}m`;
|
||||
return `${(n / 3600).toFixed(n >= 10 ? 0 : 1)}h`;
|
||||
return `${(n / 3600).toFixed(n >= 10 * 3600 ? 0 : 1)}h`;
|
||||
}
|
||||
|
||||
function formatCompactTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const full = formatSystemTime(value);
|
||||
if (!full) return "—";
|
||||
// Keep date+time but drop seconds when present: "2026/9/12 15:42:01" → "9/12 15:42"
|
||||
const m = full.match(/(\d{1,4}[/-]\d{1,2}[/-]\d{1,2})\s+(\d{1,2}:\d{2})(?::\d{2})?/);
|
||||
if (m) {
|
||||
const date = m[1].replace(/^\d{4}[/-]/, "");
|
||||
return `${date} ${m[2]}`;
|
||||
}
|
||||
return full;
|
||||
const date = parseApiTime(value);
|
||||
if (!date) return "—";
|
||||
return `${date.toLocaleDateString(undefined, { month: "numeric", day: "numeric" })} ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false })}`;
|
||||
}
|
||||
|
||||
const FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = {
|
||||
|
|
@ -121,7 +110,8 @@ function FleetDonut({
|
|||
<div
|
||||
className="wb-fleet__donut"
|
||||
style={{ background: buildDonutGradient(parts) }}
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
aria-label={`${center} ${unit ?? ""}`.trim()}
|
||||
>
|
||||
<div className="wb-fleet__donut-hole">
|
||||
<div className="wb-fleet__donut-value">{center}</div>
|
||||
|
|
@ -131,115 +121,15 @@ function FleetDonut({
|
|||
);
|
||||
}
|
||||
|
||||
const SCHED_DISC_COLORS = ["#38bdf8", "#22d3ee", "#818cf8", "#34d399"];
|
||||
|
||||
function polarPoint(cx: number, cy: number, r: number, deg: number): [number, number] {
|
||||
const rad = ((deg - 90) * Math.PI) / 180;
|
||||
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
|
||||
}
|
||||
|
||||
function donutSlicePath(
|
||||
cx: number,
|
||||
cy: number,
|
||||
rOuter: number,
|
||||
rInner: number,
|
||||
startDeg: number,
|
||||
endDeg: number,
|
||||
): string {
|
||||
const sweep = endDeg - startDeg;
|
||||
const large = sweep > 180 ? 1 : 0;
|
||||
const [x1, y1] = polarPoint(cx, cy, rOuter, startDeg);
|
||||
const [x2, y2] = polarPoint(cx, cy, rOuter, endDeg);
|
||||
const [x3, y3] = polarPoint(cx, cy, rInner, endDeg);
|
||||
const [x4, y4] = polarPoint(cx, cy, rInner, startDeg);
|
||||
return [
|
||||
`M ${x1.toFixed(2)} ${y1.toFixed(2)}`,
|
||||
`A ${rOuter} ${rOuter} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`,
|
||||
`L ${x3.toFixed(2)} ${y3.toFixed(2)}`,
|
||||
`A ${rInner} ${rInner} 0 ${large} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
type SchedDiscItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
on: boolean;
|
||||
on: boolean | null;
|
||||
age: string;
|
||||
moduleId: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function SchedDartDisc({
|
||||
items,
|
||||
center,
|
||||
unit,
|
||||
onSelect,
|
||||
}: {
|
||||
items: SchedDiscItem[];
|
||||
center: ReactNode;
|
||||
unit?: ReactNode;
|
||||
onSelect: (item: SchedDiscItem) => void;
|
||||
}) {
|
||||
// Match FleetDonut footprint (84px) so the three FLEET cards share one visual rhythm.
|
||||
const size = 84;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const rOuter = 39;
|
||||
const rInner = 25;
|
||||
const gap = items.length > 1 ? 2.5 : 0;
|
||||
const slice = items.length > 0 ? 360 / items.length : 360;
|
||||
|
||||
return (
|
||||
<div className="wb-fleet__donut wb-fleet__donut--dart">
|
||||
<svg
|
||||
className="wb-fleet__dart"
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
width={size}
|
||||
height={size}
|
||||
role="group"
|
||||
aria-label={typeof unit === "string" ? unit : "schedulers"}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const start = i * slice + gap / 2;
|
||||
const end = (i + 1) * slice - gap / 2;
|
||||
const color = item.on
|
||||
? SCHED_DISC_COLORS[i % SCHED_DISC_COLORS.length]
|
||||
: "#475569";
|
||||
return (
|
||||
<path
|
||||
key={item.key}
|
||||
className={`wb-fleet__dart-slice${item.on ? " is-on" : ""}`}
|
||||
d={donutSlicePath(cx, cy, rOuter, rInner, start, end)}
|
||||
fill={color}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={item.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{`${item.label} · ${item.on ? "ON" : "OFF"} · ${item.age}`}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="wb-fleet__donut-hole wb-fleet__donut-hole--dart">
|
||||
<div className="wb-fleet__donut-value">{center}</div>
|
||||
{unit ? <div className="wb-fleet__donut-unit">{unit}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function topLevelRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
||||
if (!root) return [];
|
||||
return (root.children || []).filter((c) => {
|
||||
|
|
@ -558,17 +448,20 @@ export function WorkbenchPage() {
|
|||
return slices;
|
||||
}, [topoTreeQuery.data, fabricNodes]);
|
||||
|
||||
const memValue =
|
||||
memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`;
|
||||
const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—";
|
||||
const metricsPlaceholder = metricsQuery.isLoading ? "…" : "—";
|
||||
const hostOk = Boolean(host && !host.error);
|
||||
const memValue = hostOk
|
||||
? memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`
|
||||
: metricsPlaceholder;
|
||||
const storageValue = storageOk ? formatBytesCompact(storageUsed) : metricsPlaceholder;
|
||||
|
||||
const dash = (v: number | string) => (canReadFleet ? v : "—");
|
||||
const dash = (v: number | string) => (canReadFleet && neStatsQuery.data ? v : neStatsQuery.isLoading ? "…" : "—");
|
||||
const tickAge = (sec?: number | null) => formatAgeSec(sec ?? null);
|
||||
|
||||
const schedRows: SchedDiscItem[] = [
|
||||
{
|
||||
key: "config",
|
||||
on: schedConfigOn,
|
||||
on: schedulers && !metricsQuery.isError ? schedConfigOn : null,
|
||||
label: t("workbench.fleetSchedConfig"),
|
||||
age: tickAge(schedulers?.config_sync?.last_tick_age_sec),
|
||||
moduleId: "network",
|
||||
|
|
@ -576,7 +469,7 @@ export function WorkbenchPage() {
|
|||
},
|
||||
{
|
||||
key: "lldp",
|
||||
on: schedLldpOn,
|
||||
on: schedulers && !metricsQuery.isError ? schedLldpOn : null,
|
||||
label: t("workbench.fleetSchedLldp"),
|
||||
age: tickAge(schedulers?.lldp_collect?.last_tick_age_sec),
|
||||
moduleId: "network",
|
||||
|
|
@ -584,7 +477,7 @@ export function WorkbenchPage() {
|
|||
},
|
||||
{
|
||||
key: "port",
|
||||
on: schedPortOn,
|
||||
on: schedulers && !metricsQuery.isError ? schedPortOn : null,
|
||||
label: t("workbench.fleetSchedPort"),
|
||||
age: tickAge(schedulers?.port_traffic?.last_tick_age_sec),
|
||||
moduleId: "network",
|
||||
|
|
@ -592,7 +485,7 @@ export function WorkbenchPage() {
|
|||
},
|
||||
{
|
||||
key: "ne",
|
||||
on: schedNeOn,
|
||||
on: schedulers && !metricsQuery.isError ? schedNeOn : null,
|
||||
label: t("workbench.fleetSchedNeCollect"),
|
||||
age: tickAge(schedulers?.ne_collect?.last_tick_age_sec),
|
||||
moduleId: "network",
|
||||
|
|
@ -600,80 +493,45 @@ export function WorkbenchPage() {
|
|||
},
|
||||
];
|
||||
const schedOnCount = schedRows.filter((r) => r.on).length;
|
||||
const refreshing = [metricsQuery, opsQuery, neStatsQuery, fabricQuery, topoTreeQuery].some((query) => query.isFetching);
|
||||
const refreshConsole = () => {
|
||||
void metricsQuery.refetch();
|
||||
void opsQuery.refetch();
|
||||
if (canReadFleet) {
|
||||
void neStatsQuery.refetch();
|
||||
void fabricQuery.refetch();
|
||||
void topoTreeQuery.refetch();
|
||||
}
|
||||
};
|
||||
const healthKnown = Boolean(metrics && hostOk && !metricsQuery.isError);
|
||||
const healthTone = !healthKnown ? "is-unknown" : healthBusy ? "is-busy" : "is-ok";
|
||||
const healthLabel = !healthKnown ? t("workbench.kpi.healthUnknown") : healthBusy ? t("workbench.kpi.healthWarn") : t("workbench.kpi.healthOk");
|
||||
const taskValue = opsQuery.isLoading ? "…" : opsQuery.isError ? "—" : formatCount(activeTasks);
|
||||
const schedValue = metricsQuery.isLoading ? "…" : !schedulers || metricsQuery.isError ? "—" : `${schedOnCount}/${schedRows.length}`;
|
||||
|
||||
return (
|
||||
<div className="workbench workbench--console">
|
||||
<header className="wb-head">
|
||||
<div className="wb-head__title-row">
|
||||
<h1 className="wb-head__title">{t("workbench.title")}</h1>
|
||||
<span className={`wb-head__health${healthBusy ? " is-busy" : " is-ok"}`}>
|
||||
<span className="wb-head__health-dot" aria-hidden="true" />
|
||||
{healthBusy
|
||||
? t("workbench.kpi.healthWarn")
|
||||
: metrics
|
||||
? t("workbench.kpi.healthOk")
|
||||
: t("workbench.kpi.healthUnknown")}
|
||||
</span>
|
||||
<div>
|
||||
<div className="wb-head__title-row">
|
||||
<h1 className="wb-head__title">{t("workbench.consoleTitle")}</h1>
|
||||
<span className={`wb-head__health ${healthTone}`}><span className="wb-head__health-dot" aria-hidden="true" />{healthLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wb-head__actions">
|
||||
<span className="wb-head__updated">{metricsQuery.dataUpdatedAt ? t("workbench.refreshedAt", { time: new Date(metricsQuery.dataUpdatedAt).toLocaleTimeString(undefined, { hour12: false }) }) : t("workbench.awaitingData")}</span>
|
||||
<Button size="sm" variant="outline" className="wb-refresh" isDisabled={refreshing} onPress={refreshConsole}><span className={refreshing ? "wb-refresh__icon is-spinning" : "wb-refresh__icon"}><ConsoleIcon kind="refresh" /></span>{t("workbench.refresh")}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="wb-strip wb-panel" aria-label={t("workbench.statusTitle")}>
|
||||
<div className="wb-strip__row">
|
||||
<span className="wb-strip__tag">{t("workbench.hostHealth")}</span>
|
||||
<StatLine label={t("workbench.gauge.cpu")} value={`${cpuPct}%`} pct={cpuPct} />
|
||||
<StatLine label={t("workbench.gauge.mem")} value={memValue} pct={memPct} />
|
||||
<StatLine
|
||||
label={t("workbench.gauge.storage")}
|
||||
value={storageValue}
|
||||
muted={!storageOk}
|
||||
/>
|
||||
<span className="wb-strip__divider" aria-hidden="true" />
|
||||
<span className="wb-strip__tag wb-strip__tag--soft">{t("workbench.channels")}</span>
|
||||
<StatLine
|
||||
label={t("workbench.gauge.cli")}
|
||||
value={`${cliUsed}/${cliLimit}`}
|
||||
pct={cliPct}
|
||||
/>
|
||||
<StatLine
|
||||
label={t("workbench.gauge.db")}
|
||||
value={`${dbOut}/${dbSize}`}
|
||||
pct={dbPct}
|
||||
/>
|
||||
<StatLine
|
||||
label={t("workbench.gauge.webcrt")}
|
||||
value={`${webUsed}/${webMax}`}
|
||||
pct={webPct}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="wb-body">
|
||||
<div className="wb-main">
|
||||
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
|
||||
{displaySections.map((section) => (
|
||||
<div key={section.key} className={`wb-section wb-section--${section.key}`}>
|
||||
<h2 className="wb-section__title">{t(`workbench.${section.key}`)}</h2>
|
||||
<div className="wb-tiles">
|
||||
{section.mods.map((mod) => (
|
||||
<Button
|
||||
key={mod.moduleId}
|
||||
variant="ghost"
|
||||
className={`wb-tile wb-tile--${mod.iconTone}`}
|
||||
aria-label={t("workbench.openModule")}
|
||||
onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
||||
>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<WorkbenchModules sections={displaySections} />
|
||||
|
||||
<section className="wb-fleet wb-panel" aria-label={t("workbench.fleet")}>
|
||||
<div className="wb-fleet__head">
|
||||
<h2 className="wb-fleet__title">{t("workbench.fleet")}</h2>
|
||||
<span className="wb-fleet__live"><span aria-hidden="true" />{t("workbench.autoRefresh")}</span>
|
||||
</div>
|
||||
<div className="wb-fleet__grid">
|
||||
<button
|
||||
|
|
@ -685,7 +543,7 @@ export function WorkbenchPage() {
|
|||
<div className="wb-fleet__card-top">
|
||||
<div className="wb-fleet__card-label">{t("workbench.fleetNe")}</div>
|
||||
<span className="wb-fleet__card-badge">
|
||||
{canReadFleet ? `${t("workbench.fleetNeKnown")} ${neKnownPct}%` : "—"}
|
||||
{neStatsQuery.isError ? t("workbench.loadFailed") : canReadFleet && neStatsQuery.data ? `${t("workbench.fleetNeKnown")} ${neKnownPct}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wb-fleet__viz">
|
||||
|
|
@ -746,7 +604,7 @@ export function WorkbenchPage() {
|
|||
))
|
||||
) : (
|
||||
<em>
|
||||
{t("workbench.fleetNeNoTag")} {canReadFleet ? neNoTag : "—"}
|
||||
{t("workbench.fleetNeNoTag")} {dash(neNoTag)}
|
||||
</em>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -762,7 +620,7 @@ export function WorkbenchPage() {
|
|||
<div className="wb-fleet__card-top">
|
||||
<div className="wb-fleet__card-label">{t("workbench.fleetTopo")}</div>
|
||||
<span className="wb-fleet__card-badge">
|
||||
{canReadFleet
|
||||
{fabricQuery.isError || topoTreeQuery.isError ? t("workbench.loadFailed") : canReadFleet
|
||||
? `${formatCount(fabricEdges)} ${t("workbench.fleetTopoEdges")}`
|
||||
: "—"}
|
||||
</span>
|
||||
|
|
@ -775,15 +633,15 @@ export function WorkbenchPage() {
|
|||
? "—"
|
||||
: fabricQuery.isLoading || topoTreeQuery.isLoading
|
||||
? "…"
|
||||
: formatCount(fabricNodes)
|
||||
: fabricQuery.isError ? "—" : formatCount(fabricNodes)
|
||||
}
|
||||
unit={t("workbench.fleetTopoNodes")}
|
||||
/>
|
||||
<div className="wb-fleet__rows">
|
||||
{(canReadFleet ? regionSlices : []).length === 0 ? (
|
||||
<div className="wb-fleet__row">
|
||||
<span>{t("workbench.fleetTopoNoRegion")}</span>
|
||||
<strong>{dash(formatCount(fabricNodes))}</strong>
|
||||
<span>{fabricQuery.isError || topoTreeQuery.isError ? t("workbench.loadFailed") : t("workbench.fleetTopoNoRegion")}</span>
|
||||
<strong>{canReadFleet && fabricQuery.data ? formatCount(fabricNodes) : "—"}</strong>
|
||||
</div>
|
||||
) : (
|
||||
(canReadFleet ? regionSlices : []).map((s) => (
|
||||
|
|
@ -822,41 +680,16 @@ export function WorkbenchPage() {
|
|||
|
||||
<div
|
||||
className="wb-fleet__card wb-fleet__card--sched"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: "/network/tasks/port-traffic/wall",
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: "/network/tasks/port-traffic/wall",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="wb-fleet__card-top">
|
||||
<div className="wb-fleet__card-label">{t("workbench.fleetSched")}</div>
|
||||
<span className={`wb-fleet__card-badge${schedStale ? " is-warn" : " is-ok"}`}>
|
||||
{schedStale ? t("workbench.fleetSchedStale") : t("workbench.fleetSchedFresh")}
|
||||
<span className="wb-fleet__card-badge">
|
||||
{schedValue} · {t("workbench.enabledSchedulers")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wb-fleet__viz">
|
||||
<SchedDartDisc
|
||||
items={schedRows}
|
||||
center={`${schedOnCount}/${schedRows.length}`}
|
||||
unit={t("workbench.fleetSchedOn")}
|
||||
onSelect={(row) =>
|
||||
openOrFocusModule({ moduleId: row.moduleId, path: row.path })
|
||||
}
|
||||
/>
|
||||
<div className="wb-fleet__rows">
|
||||
{schedRows.map((row, i) => (
|
||||
{schedRows.map((row) => (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
|
|
@ -868,19 +701,13 @@ export function WorkbenchPage() {
|
|||
>
|
||||
<span className="wb-fleet__legend">
|
||||
<i
|
||||
className="wb-fleet__swatch"
|
||||
style={{
|
||||
background: row.on
|
||||
? SCHED_DISC_COLORS[i % SCHED_DISC_COLORS.length]
|
||||
: "#475569",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
className={`wb-fleet__swatch${row.on ? " is-ok" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="wb-fleet__legend-text">{row.label}</span>
|
||||
</span>
|
||||
<span className={`wb-fleet__sched-state${row.on ? " is-ok" : " is-muted"}`}>
|
||||
{row.on ? "ON" : "OFF"}
|
||||
{!schedulers || metricsQuery.isError ? "—" : row.on ? "ON" : "OFF"}
|
||||
</span>
|
||||
<span className="wb-fleet__sched-age">{row.age}</span>
|
||||
</button>
|
||||
|
|
@ -906,7 +733,7 @@ export function WorkbenchPage() {
|
|||
<div className="wb-rail__head">
|
||||
<h2 className="wb-rail__title">
|
||||
{t("workbench.taskRail")}
|
||||
<span className="wb-rail__count">{activeTasks}</span>
|
||||
<span className="wb-rail__count">{taskValue}</span>
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -919,8 +746,10 @@ export function WorkbenchPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{railTasks.length === 0 ? (
|
||||
<p className="wb-rail__empty">{t("workbench.taskRailEmpty")}</p>
|
||||
{opsQuery.isLoading ? <p className="wb-rail__empty" role="status">{t("workbench.loadingTasks")}</p> : opsQuery.isError ? (
|
||||
<div className="wb-rail__empty wb-rail__empty--error" role="status"><ConsoleIcon kind="activity" /><strong>{t("workbench.loadFailed")}</strong><Button size="sm" variant="ghost" onPress={() => void opsQuery.refetch()}>{t("workbench.retry")}</Button></div>
|
||||
) : railTasks.length === 0 ? (
|
||||
<div className="wb-rail__empty"><span className="wb-rail__empty-icon"><ConsoleIcon kind="check" /></span><strong>{t("workbench.taskRailEmpty")}</strong></div>
|
||||
) : (
|
||||
<ul className="wb-rail__list">
|
||||
{railTasks.map((row) => (
|
||||
|
|
@ -939,14 +768,32 @@ export function WorkbenchPage() {
|
|||
<span className="wb-rail__progress"> · {row.progress}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="wb-rail__time">{formatCompactTime(row.updated_at || row.started_at)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className="wb-runtime wb-panel" aria-label={t("workbench.statusTitle")}>
|
||||
<div className="wb-runtime__head"><h2 className="wb-panel-title">{t("workbench.statusTitle")}</h2><ConsoleIcon kind="activity" /></div>
|
||||
{metricsQuery.isError && <p className="wb-runtime__error" role="status">{t("workbench.loadFailed")} · {t("workbench.retryHint")}</p>}
|
||||
<div className="wb-runtime__group wb-runtime__group--host"><h3>{t("workbench.hostHealth")}</h3>
|
||||
<StatLine label={t("workbench.gauge.cpu")} value={hostOk ? `${cpuPct}%` : metricsPlaceholder} pct={hostOk ? cpuPct : undefined} />
|
||||
<StatLine label={t("workbench.gauge.mem")} value={memValue} pct={hostOk ? memPct : undefined} />
|
||||
<StatLine label={t("workbench.gauge.storage")} value={storageValue} muted={!storageOk} />
|
||||
</div>
|
||||
<div className="wb-runtime__group wb-runtime__group--capacity"><h3>{t("workbench.channels")}</h3>
|
||||
<StatLine label={t("workbench.gauge.cli")} value={metrics?.cli_budget ? `${cliUsed}/${cliLimit}` : metricsPlaceholder} pct={metrics?.cli_budget ? cliPct : undefined} />
|
||||
<StatLine label={t("workbench.gauge.db")} value={metrics?.db_pool ? `${dbOut}/${dbSize}` : metricsPlaceholder} pct={metrics?.db_pool ? dbPct : undefined} />
|
||||
<StatLine label={t("workbench.gauge.webcrt")} value={metrics?.webcrt ? `${webUsed}/${webMax}` : metricsPlaceholder} pct={metrics?.webcrt ? webPct : undefined} />
|
||||
</div>
|
||||
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
14
web/src/features/workbench/components/ConsoleIcon.tsx
Normal file
14
web/src/features/workbench/components/ConsoleIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" };
|
||||
|
||||
export function ConsoleIcon({ kind }: Props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{kind === "arrow" && <path d="M5 12h14m-6-6 6 6-6 6" />}
|
||||
{kind === "search" && <><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4 4" /></>}
|
||||
{kind === "refresh" && <><path d="M20 7v5h-5M4 17v-5h5" /><path d="M6 7a7 7 0 0 1 11-2l3 3M4 16l3 3a7 7 0 0 0 11-2" /></>}
|
||||
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
|
||||
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
|
||||
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
70
web/src/features/workbench/components/WorkbenchModules.tsx
Normal file
70
web/src/features/workbench/components/WorkbenchModules.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@heroui/react";
|
||||
import type { ModuleDefinition } from "../../../config/modules";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { openOrFocusModule } from "../../../utils/moduleWindows";
|
||||
import { ConsoleIcon } from "./ConsoleIcon";
|
||||
import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
|
||||
|
||||
export type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[] };
|
||||
|
||||
function moduleDescription(mod: ModuleDefinition, t: (key: string) => string) {
|
||||
const key = `workbench.descriptions.${mod.moduleId}`;
|
||||
const description = t(key);
|
||||
return description === key ? t(mod.titleKey) : description;
|
||||
}
|
||||
|
||||
export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLocaleLowerCase();
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
mods: section.mods.filter((mod) =>
|
||||
[t(mod.labelKey), moduleDescription(mod, t), mod.moduleId]
|
||||
.some((value) => value.toLocaleLowerCase().includes(term)),
|
||||
),
|
||||
})).filter((section) => section.mods.length > 0);
|
||||
}, [sections, search, t]);
|
||||
const count = filtered.reduce((sum, section) => sum + section.mods.length, 0);
|
||||
|
||||
return (
|
||||
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
|
||||
<div className="wb-modules__head">
|
||||
<div>
|
||||
<h2 className="wb-panel-title">{t("workbench.modules")}</h2>
|
||||
</div>
|
||||
<div className="wb-search">
|
||||
<ConsoleIcon kind="search" />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
aria-label={t("workbench.searchModules")}
|
||||
placeholder={t("workbench.searchModules")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") setSearch(""); }}
|
||||
/>
|
||||
{search && <Button isIconOnly size="sm" variant="ghost" className="wb-search__clear" aria-label={t("workbench.clearSearch")} onPress={() => setSearch("")}><ConsoleIcon kind="close" /></Button>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="wb-sr-only" role="status">{t("workbench.moduleCount", { count })}</span>
|
||||
{filtered.map((section) => (
|
||||
<div key={section.key} className={`wb-section wb-section--${section.key}`}>
|
||||
<div className={section.key === "operations" ? "wb-sr-only" : "wb-section__head"}>
|
||||
<h3 className="wb-section__title">{t(`workbench.${section.key}`)}</h3>
|
||||
</div>
|
||||
<div className="wb-tiles">
|
||||
{section.mods.map((mod) => (
|
||||
<Button key={mod.moduleId} variant="ghost" className={`wb-tile wb-tile--${mod.iconTone}`} aria-label={`${t(mod.labelKey)} · ${t("workbench.openModule")}`} onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{count === 0 && <div className="wb-modules__empty"><ConsoleIcon kind="search" /><p>{t("workbench.noModules")}</p><Button size="sm" variant="ghost" onPress={() => setSearch("")}>{t("workbench.clearSearch")}</Button></div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue