Refactor web UI: feature-first structure, tokenized styles, modern blue theme.

Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
  333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
  types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
  (2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.

Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
  pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
  off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
  unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
  without tree indent, soft selected state instead of left accent bar.

Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 03:17:51 +08:00
parent 3b45d6abe3
commit 0d894efe5e
196 changed files with 26947 additions and 28091 deletions

View file

@ -0,0 +1,445 @@
import { Button, Checkbox, Input, Modal } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
import { HelpHint } from "../../components/HelpHint";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { apiDelete, apiGet, apiPatch, apiPost } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
type TokenRow = {
id: string;
name: string;
user_id: string;
username: string;
scopes?: string[];
created_at: string | null;
expires_at: string | null;
last_used_at: string | null;
revoked: boolean;
expired: boolean;
active: boolean;
};
type UserRow = {
id: string;
username: string;
role: string;
scopes?: string[];
is_active: boolean;
};
const ALL_SCOPE_KEYS = [
"alarms:read",
"ne:read",
"ne:write",
"ne:exec",
"biz-monitor:read",
"sql:query",
"webcrt:session",
"admin:users",
"ops:write",
] as const;
/** Applied on create; refine later via「改权限」. */
const CREATE_DEFAULT_SCOPES = [
"alarms:read",
"ne:read",
"ne:exec",
"ne:write",
"biz-monitor:read",
] as const;
const SCOPE_LABEL_KEYS: Record<(typeof ALL_SCOPE_KEYS)[number], string> = {
"alarms:read": "auth.scopeAlarmsRead",
"ne:read": "auth.scopeNeRead",
"ne:write": "auth.scopeNeWrite",
"ne:exec": "auth.scopeNeExec",
"biz-monitor:read": "auth.scopeBizMonitorRead",
"sql:query": "auth.scopeSql",
"webcrt:session": "auth.scopeWebcrt",
"admin:users": "auth.scopeAdminUsers",
"ops:write": "auth.scopeOpsWrite",
};
const EXPIRY_OPTIONS = [
{ value: 7, labelKey: "auth.expire7d" },
{ value: 30, labelKey: "auth.expire30d" },
{ value: 90, labelKey: "auth.expire90d" },
{ value: 365, labelKey: "auth.expire365d" },
{ value: 0, labelKey: "auth.expireNever" },
] as const;
function tokenStatusClass(row: TokenRow): string {
if (row.revoked) return "pt-list-status--failed";
if (row.expired) return "pt-list-status--warning";
return "pt-list-status--ok";
}
function intersectScopes(available: string[], desired: readonly string[]): string[] {
const allow = new Set(available);
return desired.filter((s) => allow.has(s));
}
export function ApiTokensPage() {
const { t } = useI18n();
const { ready, user, isAdmin, scopes: myScopes } = useAuth();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const [name, setName] = useState("mcp");
const [expiresInDays, setExpiresInDays] = useState(90);
const [ownerUserId, setOwnerUserId] = useState("");
const [createdPlain, setCreatedPlain] = useState("");
const [editingRow, setEditingRow] = useState<TokenRow | null>(null);
const [editScopes, setEditScopes] = useState<string[]>([]);
const tokensQuery = useQuery({
queryKey: ["apiTokens"],
queryFn: () =>
apiGet<{ items: TokenRow[]; active_count?: number; max_count?: number }>("/v1/api-tokens"),
enabled: ready,
});
const usersQuery = useQuery({
queryKey: ["appUsers"],
queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"),
enabled: ready && isAdmin,
});
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
const tokenMax = Number(tokensQuery.data?.max_count ?? 20);
const tokenActive = Number(tokensQuery.data?.active_count ?? items.filter((r) => !r.revoked).length);
const atTokenLimit = tokenMax > 0 && tokenActive >= tokenMax;
const availableForCreate = useMemo(() => {
if (ownerUserId) {
const owner = users.find((u) => u.id === ownerUserId);
return [...(owner?.scopes || [])].sort();
}
return [...(myScopes || [])].sort();
}, [ownerUserId, users, myScopes]);
const editAvailable = useMemo(() => {
if (!editingRow) return [];
if (editingRow.user_id === user?.id) return [...(myScopes || [])].sort();
const owner = users.find((u) => u.id === editingRow.user_id);
return [...(owner?.scopes || myScopes || [])].sort();
}, [editingRow, user?.id, users, myScopes]);
const createMut = useMutation({
mutationFn: () => {
const scopes = intersectScopes(availableForCreate, CREATE_DEFAULT_SCOPES);
if (!scopes.length) {
throw new Error(t("auth.scopesNoneAvailable"));
}
return apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
name: name.trim() || "mcp",
expires_in_days: expiresInDays,
user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
scopes,
});
},
onSuccess: async (data) => {
setCreatedPlain(data.token.token);
showOk(t("auth.tokenCreated"));
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const updateMut = useMutation({
mutationFn: (payload: { id: string; scopes: string[] }) =>
apiPatch<{ token: TokenRow }>(`/v1/api-tokens/${encodeURIComponent(payload.id)}`, {
scopes: payload.scopes,
}),
onSuccess: async () => {
showOk(t("auth.tokenUpdated"));
setEditingRow(null);
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const revokeMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`),
onSuccess: async () => {
showOk(t("auth.tokenRevoked"));
setEditingRow(null);
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const deleteMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}/purge`),
onSuccess: async () => {
showOk(t("auth.tokenDeleted"));
setEditingRow(null);
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const onCreate = (e: FormEvent) => {
e.preventDefault();
setCreatedPlain("");
createMut.mutate();
};
const dismissCreatedPlain = () => setCreatedPlain("");
const copyToken = async () => {
try {
await navigator.clipboard.writeText(createdPlain);
showOk(t("auth.tokenCopied"));
dismissCreatedPlain();
} catch {
showError(t("auth.tokenCopyFailed"));
}
};
const toggleEditScope = (scope: string) => {
setEditScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(),
);
};
const startEdit = (row: TokenRow) => {
const available =
row.user_id === user?.id
? [...(myScopes || [])]
: [...(users.find((u) => u.id === row.user_id)?.scopes || myScopes || [])];
const current = row.scopes?.length ? [...row.scopes] : [...available];
setEditingRow(row);
setEditScopes(intersectScopes(available, current.length ? current : CREATE_DEFAULT_SCOPES));
};
const formatScopes = (scopes: string[] | undefined) => {
if (!scopes || scopes.length === 0) return t("auth.scopesUnsetLegacy");
return scopes.join(", ");
};
return (
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2 className="token-page__title card-title-with-hint">
{t("auth.apiKeysTitle")}
<HelpHint text={t("auth.apiKeysHelp", { max: tokenMax || 20 })} ariaLabel={t("common.help")} />
</h2>
{tokenMax > 0 ? (
<span className="muted" style={{ fontSize: 13 }}>
{t("auth.tokenQuota", { active: tokenActive, max: tokenMax })}
</span>
) : null}
</div>
<div className="pt-list">
{atTokenLimit ? (
<p className="panel__hint panel__hint--live">{t("auth.tokenLimitReached", { max: tokenMax })}</p>
) : null}
<form className="token-create" onSubmit={onCreate}>
<div className="filter-inline">
<Input
placeholder={t("auth.tokenName")}
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={atTokenLimit}
/>
<FieldSelect
aria-label={t("auth.expiresIn")}
value={String(expiresInDays)}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
disabled={atTokenLimit}
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</FieldSelect>
{isAdmin ? (
<FieldSelect
aria-label={t("auth.tokenOwner")}
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
disabled={atTokenLimit}
>
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
{users
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role})
</option>
))}
</FieldSelect>
) : null}
<Button
type="submit"
size="sm"
variant="primary"
isDisabled={createMut.isPending || atTokenLimit}
>
{t("auth.createToken")}
</Button>
</div>
</form>
{createdPlain ? (
<div className="token-once">
<div className="panel__hint" style={{ margin: 0 }}>
{t("auth.tokenOnceHint")}
</div>
<code>{createdPlain}</code>
<Button size="sm" variant="primary" onPress={() => void copyToken()}>
{t("auth.copyToken")}
</Button>
<Button size="sm" variant="tertiary" onPress={dismissCreatedPlain}>
{t("common.close")}
</Button>
</div>
) : null}
{tokensQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !tokensQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.tokenName")}</th>
<th>{t("auth.tokenOwner")}</th>
<th>{t("auth.scopesCol")}</th>
<th>{t("auth.colTime")}</th>
<th>{t("auth.expiresAt")}</th>
<th>{t("auth.lastUsed")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td className="pt-list-task-name">{row.name}</td>
<td>{row.username || row.user_id}</td>
<td className="token-scopes-cell" title={formatScopes(row.scopes)}>
{formatScopes(row.scopes)}
</td>
<td className="pt-list-time">
{row.created_at ? formatSystemTime(row.created_at) : t("common.empty")}
</td>
<td className="pt-list-time">
{row.expires_at ? formatSystemTime(row.expires_at) : t("auth.expireNever")}
</td>
<td className="pt-list-time">
{row.last_used_at ? formatSystemTime(row.last_used_at) : t("common.empty")}
</td>
<td>
<span className={`pt-list-status ${tokenStatusClass(row)}`}>
{row.revoked
? t("auth.tokenStatusRevoked")
: row.expired
? t("auth.tokenStatusExpired")
: t("auth.tokenStatusActive")}
</span>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button
size="sm"
variant="ghost"
isDisabled={row.revoked || row.expired}
onPress={() => startEdit(row)}
>
{t("auth.editScopes")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={row.revoked || revokeMut.isPending}
onPress={() => {
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
}}
>
{t("auth.revokeToken")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteMut.isPending}
onPress={() => {
if (window.confirm(t("auth.deleteConfirm"))) deleteMut.mutate(row.id);
}}
>
{t("auth.deleteToken")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
<AppModalShell open={Boolean(editingRow)} onClose={() => setEditingRow(null)} size="md">
<Modal.Header>
<Modal.Heading>{t("auth.editScopes")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{editingRow ? (
<p className="text-sm text-muted">
{editingRow.name}
{editingRow.username ? ` · ${editingRow.username}` : ""}
</p>
) : null}
<ul className="token-scopes-modal__list">
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
<li key={scope}>
<Checkbox
isSelected={editScopes.includes(scope)}
onChange={() => toggleEditScope(scope)}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t(SCOPE_LABEL_KEYS[scope])}</Checkbox.Content>
</Checkbox>
</li>
))}
{!editAvailable.length ? (
<li className="muted">{t("auth.scopesNoneAvailable")}</li>
) : null}
</ul>
</Modal.Body>
<Modal.Footer>
<Button
variant="primary"
isDisabled={updateMut.isPending || editScopes.length === 0 || !editingRow}
onPress={() => {
if (editingRow) updateMut.mutate({ id: editingRow.id, scopes: editScopes });
}}
>
{t("auth.saveScopes")}
</Button>
<Button variant="tertiary" onPress={() => setEditingRow(null)}>
{t("common.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
</div>
);
}

View file

@ -0,0 +1,32 @@
import { NavLink, Outlet } from "react-router-dom";
import { AUDIT_NAV } from "../../config/auditNav";
import { useI18n } from "../../i18n";
export function AuditLayout() {
const { t } = useI18n();
return (
<div className="network-shell audit-shell">
<aside className="network-nav" aria-label={t("audit.title")}>
<nav className="network-nav__scroll">
<ul className="network-nav__list network-nav__list--flat">
{AUDIT_NAV.map((item) => (
<li key={item.id}>
<NavLink
to={item.path}
className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}`}
end={item.path === "/audit/tasks"}
>
{t(item.labelKey)}
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
<div className="network-main">
<Outlet />
</div>
</div>
);
}

View file

@ -0,0 +1,332 @@
import { Button, Input } from "@heroui/react";
import { Fragment, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../../i18n";
import { apiGet } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
type AuditItem = {
id: string;
ts: string | null;
actor_username: string;
action: string;
method: string;
path: string;
status_code: number;
client_ip: string;
detail: Record<string, unknown>;
};
/** Quick filters map to action substring and/or exclude_noise flag. */
type QuickFilter = "business" | "webcrt." | "ne.exec" | "auth." | "http." | "all";
function statusClass(code: number): string {
if (code >= 500) return "pt-list-status--failed";
if (code >= 400) return "pt-list-status--warning";
if (code >= 200 && code < 300) return "pt-list-status--ok";
return "pt-list-status--other";
}
function detailStr(detail: Record<string, unknown>, key: string): string {
const v = detail?.[key];
if (v == null) return "";
return String(v).trim();
}
function deviceLabel(detail: Record<string, unknown>): string {
const name = detailStr(detail, "ne_name");
const ip = detailStr(detail, "ne_ip");
if (name && ip) return `${name} (${ip})`;
return name || ip || detailStr(detail, "ne_id") || "";
}
export function auditSummary(
action: string,
detail: Record<string, unknown>,
t: (key: string, vars?: Record<string, string>) => string,
row?: Pick<AuditItem, "method" | "path">,
): string {
const d = detail || {};
const device = deviceLabel(d);
if (action === "webcrt.session_connecting") {
return t("audit.summary.connecting", { device: device || t("common.empty") });
}
if (action === "webcrt.session_created") {
return t("audit.summary.loginOk", { device: device || t("common.empty") });
}
if (action === "webcrt.session_open_failed") {
const err = detailStr(d, "error") || t("common.empty");
return t("audit.summary.loginFail", { device: device || t("common.empty"), error: err.slice(0, 80) });
}
if (action === "webcrt.session_closed") {
const reason = detailStr(d, "reason");
return reason
? t("audit.summary.logoutReason", { device: device || t("common.empty"), reason })
: t("audit.summary.logout", { device: device || t("common.empty") });
}
if (action === "webcrt.command") {
const cmd = detailStr(d, "command") || t("common.empty");
const redacted = Boolean(d.redacted);
return t("audit.summary.command", {
device: device || t("common.empty"),
command: redacted ? "***" : cmd,
});
}
if (action === "ne.exec") {
const cmds = Array.isArray(d.commands) ? d.commands.map(String).filter(Boolean) : [];
return t("audit.summary.neExec", {
device: device || t("common.empty"),
commands: cmds.slice(0, 3).join("; ") || t("common.empty"),
});
}
if (action === "ne.exec_batch") {
const neIds = Array.isArray(d.ne_ids) ? (d.ne_ids as unknown[]) : [];
const targetCount =
d.target_count != null && d.target_count !== ""
? Number(d.target_count)
: neIds.length;
return t("audit.summary.neExecBatch", {
n: String(Number.isFinite(targetCount) ? targetCount : 0),
ok: String(d.ok_count != null ? Number(d.ok_count) : 0),
fail: String(d.fail_count != null ? Number(d.fail_count) : 0),
});
}
if (action.startsWith("auth.")) {
if (action === "auth.unauthorized") {
const method = row?.method || "GET";
const path = row?.path || "";
return path ? `${method} ${path}` : "unauthorized";
}
return action.replace(/^auth\./, "");
}
if (action.startsWith("http.") || action.startsWith("ume.")) {
const method = row?.method || action.replace(/^http\./, "").toUpperCase();
const path = row?.path || "";
return path ? `${method} ${path}` : method;
}
return "";
}
function quickToQuery(quick: QuickFilter): { action: string; excludeNoise: boolean } {
if (quick === "business") return { action: "", excludeNoise: true };
if (quick === "all") return { action: "", excludeNoise: false };
if (quick === "http.") return { action: "http.", excludeNoise: false };
return { action: quick, excludeNoise: true };
}
export function AuditPage() {
const { t } = useI18n();
const { ready, isAdmin } = useAuth();
const [page, setPage] = useState(1);
const [username, setUsername] = useState("");
const [action, setAction] = useState("");
const [quick, setQuick] = useState<QuickFilter>("business");
const [expanded, setExpanded] = useState<string | null>(null);
const derived = quickToQuery(quick);
const effectiveAction = action.trim() || derived.action;
const excludeNoise = action.trim() ? false : derived.excludeNoise;
const query = useQuery({
queryKey: ["auditLogs", page, username, effectiveAction, excludeNoise],
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
p.set("page_size", "10");
p.set("exclude_noise", excludeNoise ? "true" : "false");
if (username.trim()) p.set("username", username.trim());
if (effectiveAction) p.set("action", effectiveAction);
return apiGet<{ total: number; page: number; page_size: number; items: AuditItem[] }>(
`/v1/audit-logs?${p.toString()}`,
);
},
enabled: ready,
});
const items = useMemo(() => query.data?.items || [], [query.data]);
const total = query.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / 10));
const hasFilters = Boolean(username.trim() || action.trim() || quick !== "business");
const setQuickFilter = (next: QuickFilter) => {
setPage(1);
setQuick(next);
if (next !== "all" && next !== "business") setAction("");
else setAction("");
};
return (
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2>{t("audit.logsTitle")}</h2>
</div>
<div className="pt-list">
<div className="filter-inline audit-quick-filters">
{(
[
["business", "audit.filterBusiness"],
["webcrt.", "audit.filterWebcrt"],
["ne.exec", "audit.filterNeExec"],
["auth.", "audit.filterAuth"],
["http.", "audit.filterHttp"],
["all", "audit.filterAll"],
] as const
).map(([value, labelKey]) => (
<Button
key={value}
size="sm"
variant={quick === value && !action.trim() ? "primary" : "secondary"}
onPress={() => setQuickFilter(value)}
>
{t(labelKey)}
</Button>
))}
</div>
<div className="filter-inline">
{isAdmin ? (
<Input
placeholder={t("auth.filterUsername")}
value={username}
onChange={(e) => {
setPage(1);
setUsername(e.target.value);
}}
/>
) : null}
<Input
placeholder={t("auth.filterAction")}
value={action}
onChange={(e) => {
setPage(1);
setAction(e.target.value);
if (e.target.value.trim()) setQuick("all");
}}
/>
<Button size="sm" variant="secondary" onPress={() => void query.refetch()} isDisabled={query.isFetching}>
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setUsername("");
setAction("");
setQuick("business");
setPage(1);
}}
>
{t("common.clearFilters")}
</Button>
</div>
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !query.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.colTime")}</th>
<th>{t("auth.colUser")}</th>
<th>{t("auth.colAction")}</th>
<th>{t("audit.colSummary")}</th>
<th>{t("auth.colStatus")}</th>
<th>{t("auth.colIp")}</th>
<th>{t("audit.colDetail")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => {
const open = expanded === row.id;
const summary = auditSummary(row.action, row.detail || {}, t, row);
return (
<Fragment key={row.id}>
<tr>
<td className="pt-list-time">
{row.ts ? formatSystemTime(row.ts) : t("common.empty")}
</td>
<td className="pt-list-task-name">{row.actor_username || t("common.empty")}</td>
<td className="pt-list-num">{row.action}</td>
<td className="audit-summary-cell">{summary || row.path || t("common.empty")}</td>
<td>
{row.status_code ? (
<span className={`pt-list-status ${statusClass(row.status_code)}`}>
{row.status_code}
</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
<td>
<Button
size="sm"
variant="ghost"
className="linkish"
onPress={() => setExpanded(open ? null : row.id)}
>
{open ? t("audit.hideDetail") : t("audit.showDetail")}
</Button>
</td>
</tr>
{open ? (
<tr className="audit-detail-row">
<td colSpan={7}>
<pre className="audit-detail-pre">{JSON.stringify(row.detail || {}, null, 2)}</pre>
{row.method || row.path ? (
<p className="muted audit-detail-meta">
{row.method} {row.path}
</p>
) : null}
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={page <= 1}
onPress={() => setPage((p) => Math.max(1, p - 1))}
>
{t("common.prevPage")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={page >= pages}
onPress={() => setPage((p) => Math.min(pages, p + 1))}
>
{t("common.nextPage")}
</Button>
</div>
</div>
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,194 @@
import { Button } from "@heroui/react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useI18n } from "../../i18n";
import { fetchOpsTasks, type OpsTaskItem } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 4000;
function statusTone(status: string): string {
const s = status.toLowerCase();
if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running";
if (s === "ready" || s === "idle") return "ok";
if (s === "paused" || s === "pending" || s === "detached") return "paused";
if (s === "failed" || s === "error") return "stopped";
return "other";
}
function kindLabel(kind: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
const key = `audit.tasks.kind.${kind}`;
const tr = t(key);
return tr === key ? kind : tr;
}
function statusLabel(status: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
const key = `audit.tasks.status.${status}`;
const tr = t(key);
return tr === key ? status : tr;
}
function triggerLabel(trigger: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
const raw = String(trigger || "").trim();
if (!raw || raw === "—") return "—";
const key = `audit.tasks.trigger.${raw}`;
const tr = t(key);
return tr === key ? raw : tr;
}
function runtimeTaskSubject(
taskId: string,
t: (k: string, vars?: Record<string, string | number>) => string,
): string {
const key = `ume.tasks.runtimeTask.${taskId}`;
const label = t(key);
return label === key ? taskId : label;
}
function taskTitle(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
const kind = kindLabel(row.kind, t);
const subjectRaw = String(row.title || row.id || "").trim();
const subject =
row.kind === "ume_runtime" && subjectRaw ? runtimeTaskSubject(subjectRaw, t) : subjectRaw;
if (!subject) return kind;
// Avoid "Kind · Kind · x" if an old backend still prefixed the localized kind.
if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) {
return subject;
}
return `${kind} · ${subject}`;
}
function progressLabel(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
const base = String(row.progress || "").trim();
const inflight = Number(row.inflight || 0);
if (inflight > 0) {
const extra = t("audit.tasks.inflight", { n: inflight });
return base ? `${base} · ${extra}` : extra;
}
return base || "—";
}
export function TaskOverviewPage() {
const { t } = useI18n();
const query = useQuery({
queryKey: ["opsTasks"],
queryFn: fetchOpsTasks,
refetchInterval: POLL_MS,
staleTime: 1500,
});
const data = query.data;
const items = useMemo(() => data?.items || [], [data]);
const byKind = data?.by_kind || {};
const byStatus = data?.by_status || {};
return (
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2>{t("audit.tasks.title")}</h2>
<Button
size="sm"
variant="secondary"
onPress={() => void query.refetch()}
isDisabled={query.isFetching}
>
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="pt-list-kpis">
<div className="pt-list-kpi pt-list-kpi--live">
<div className="pt-list-kpi__label">{t("audit.tasks.kpiActive")}</div>
<div className="pt-list-kpi__value">{data?.active ?? "—"}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("audit.tasks.kpiTotal")}</div>
<div className="pt-list-kpi__value">{data?.total ?? "—"}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("audit.tasks.kpiKinds")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 13, fontWeight: 500 }}>
{Object.keys(byKind).length
? Object.entries(byKind)
.map(([k, n]) => `${kindLabel(k, t)} ${n}`)
.join(" · ")
: "—"}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("audit.tasks.kpiStatuses")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 13, fontWeight: 500 }}>
{Object.keys(byStatus).length
? Object.entries(byStatus)
.map(([k, n]) => `${statusLabel(k, t)} ${n}`)
.join(" · ")
: "—"}
</div>
</div>
</div>
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !query.isLoading ? (
<div className="pt-list-empty">
<p>{t("audit.tasks.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("audit.tasks.colKind")}</th>
<th>{t("audit.tasks.colTitle")}</th>
<th>{t("audit.tasks.colStatus")}</th>
<th>{t("audit.tasks.colActor")}</th>
<th>{t("audit.tasks.colTrigger")}</th>
<th>{t("audit.tasks.colProgress")}</th>
<th>{t("audit.tasks.colStarted")}</th>
<th>{t("audit.tasks.colUpdated")}</th>
<th>{t("audit.tasks.colLink")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={`${row.kind}:${row.id}`} title={row.detail || undefined}>
<td>{kindLabel(row.kind, t)}</td>
<td>
<div className="pt-list-task-name">{taskTitle(row, t)}</div>
{row.detail ? (
<div className="muted" style={{ fontSize: 12 }}>
{row.detail}
</div>
) : null}
</td>
<td>
<span className={`pt-list-status pt-list-status--${statusTone(row.status)}`}>
{statusLabel(row.status, t)}
</span>
</td>
<td>{row.actor || "—"}</td>
<td>{triggerLabel(row.trigger, t)}</td>
<td className="pt-list-num">{progressLabel(row, t)}</td>
<td className="pt-list-time">{formatSystemTime(row.started_at) || "—"}</td>
<td className="pt-list-time">{formatSystemTime(row.updated_at) || "—"}</td>
<td>
{row.href ? (
<Link to={row.href} className="link-btn">
{t("audit.tasks.open")}
</Link>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,111 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { apiGet, apiPost, clearAuthToken } from "../../services/api";
export type AuthUser = {
id: string;
username: string;
role: string;
scopes?: string[];
is_active: boolean;
must_change_password?: boolean;
created_by?: string;
created_at?: string | null;
updated_at?: string | null;
};
type AuthState = {
ready: boolean;
token: string | null;
user: AuthUser | null;
scopes: string[];
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshMe: () => Promise<void>;
isAdmin: boolean;
hasScope: (scope: string) => boolean;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
// token is opaque for UI; cookie session means we only care about user presence.
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [scopes, setScopes] = useState<string[]>([]);
const refreshMe = useCallback(async () => {
clearAuthToken(); // drop any legacy localStorage tokens
try {
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
setToken("cookie");
setUser(data.user);
setScopes(data.scopes || data.user.scopes || []);
} catch {
setToken(null);
setUser(null);
setScopes([]);
}
}, []);
useEffect(() => {
void (async () => {
await refreshMe();
setReady(true);
})();
}, [refreshMe]);
const login = useCallback(async (username: string, password: string) => {
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
username,
password,
});
clearAuthToken();
setToken("cookie");
setUser(data.user);
setScopes(data.user.scopes || []);
}, []);
const logout = useCallback(async () => {
try {
await apiPost("/v1/auth/logout", {});
} catch {
// ignore
}
clearAuthToken();
setToken(null);
setUser(null);
setScopes([]);
}, []);
const value = useMemo<AuthState>(
() => ({
ready,
token,
user,
scopes,
login,
logout,
refreshMe,
isAdmin: user?.role === "admin",
hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin",
}),
[ready, token, user, scopes, login, logout, refreshMe],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth outside AuthProvider");
return ctx;
}

View file

@ -0,0 +1,123 @@
import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react";
import { useAuth } from "./AuthContext";
import { useI18n } from "../../i18n";
import { apiPost } from "../../services/api";
import { LoginShell } from "./LoginShell";
export function ForceChangePasswordPage() {
const { t } = useI18n();
const { user, refreshMe, logout } = useAuth();
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 8) {
setError(t("auth.passwordTooShort"));
return;
}
if (newPassword !== confirm) {
setError(t("auth.passwordMismatch"));
return;
}
if (newPassword === oldPassword || newPassword === "admin123") {
setError(t("auth.passwordMustChange"));
return;
}
setBusy(true);
try {
await apiPost("/v1/auth/change-password", {
old_password: oldPassword,
new_password: newPassword,
});
await refreshMe();
} catch (err) {
setError(String(err instanceof Error ? err.message : err));
} finally {
setBusy(false);
}
};
return (
<LoginShell>
<Card className="login-card login-card--heroui" variant="transparent">
<Card.Header className="login-card__head">
<Card.Title className="login-card__title">{t("auth.forceChangeTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX">
NETX
</div>
</Card.Header>
<Card.Content>
<p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
</p>
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<TextField
fullWidth
type="password"
autoComplete="current-password"
autoFocus
value={oldPassword}
onChange={setOldPassword}
isDisabled={busy}
isRequired
>
<Label className="login-card__sr">{t("auth.oldPassword")}</Label>
<Input placeholder={t("auth.oldPassword")} />
</TextField>
<TextField
fullWidth
type="password"
autoComplete="new-password"
value={newPassword}
onChange={setNewPassword}
isDisabled={busy}
isRequired
minLength={8}
>
<Label className="login-card__sr">{t("auth.newPassword")}</Label>
<Input placeholder={t("auth.newPassword")} />
</TextField>
<TextField
fullWidth
type="password"
autoComplete="new-password"
value={confirm}
onChange={setConfirm}
isDisabled={busy}
isRequired
minLength={8}
>
<Label className="login-card__sr">{t("auth.confirmPassword")}</Label>
<Input placeholder={t("auth.confirmPassword")} />
</TextField>
{error ? (
<Alert status="danger" className="login-card__error">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button type="submit" variant="primary" fullWidth isDisabled={busy}>
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
</Button>
<Button
type="button"
variant="tertiary"
fullWidth
isDisabled={busy}
onPress={() => void logout()}
>
{t("auth.logout")}
</Button>
</Form>
</Card.Content>
</Card>
</LoginShell>
);
}

View file

@ -0,0 +1,126 @@
import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
import { useState, type FormEvent } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "./AuthContext";
import { useI18n } from "../../i18n";
import { LoginShell } from "./LoginShell";
export function LoginPage() {
const { t } = useI18n();
const { ready, user, login } = useAuth();
const [params] = useSearchParams();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
if (ready && user) {
const next = params.get("next") || "/";
return <Navigate to={next.startsWith("/") ? next : "/"} replace />;
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setBusy(true);
try {
await login(username.trim(), password);
} catch (err) {
setError(String(err instanceof Error ? err.message : err) || t("auth.loginFailed"));
} finally {
setBusy(false);
}
};
return (
<LoginShell>
<Card className="login-card login-card--heroui" variant="transparent">
<Card.Header className="login-card__head">
<Card.Title className="login-card__title">{t("auth.loginTitle")}</Card.Title>
<div className="login-card__brand" aria-label="NETX">
NETX
</div>
</Card.Header>
<Card.Content>
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
<TextField
fullWidth
name="username"
value={username}
onChange={setUsername}
isDisabled={busy || !ready}
autoFocus
autoComplete="username"
>
<Label className="login-card__sr">{t("auth.username")}</Label>
<Input placeholder={t("auth.username")} />
</TextField>
<div className="login-card__password">
<TextField
fullWidth
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
isDisabled={busy || !ready}
autoComplete="current-password"
>
<Label className="login-card__sr">{t("auth.password")}</Label>
<Input placeholder={t("auth.password")} />
</TextField>
<button
type="button"
className="login-card__eye"
aria-label={showPassword ? "Hide password" : "Show password"}
tabIndex={-1}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
</svg>
)}
</button>
</div>
{error ? (
<Alert status="danger" className="login-card__error">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
type="submit"
variant="primary"
fullWidth
className="login-card__submit"
isDisabled={busy || !ready || !password}
>
{busy ? t("auth.loggingIn") : t("auth.login")}
</Button>
</Form>
</Card.Content>
</Card>
</LoginShell>
);
}

View file

@ -0,0 +1,35 @@
import { useEffect, type ReactNode } from "react";
/** Auth shell: night-earth stage on the left, form rail on the right. */
export function LoginShell({ children }: { children: ReactNode }) {
// Pause ambient CSS loops while the tab is hidden.
useEffect(() => {
const root = document.documentElement;
const sync = () => {
root.classList.toggle("login-page--paused", document.hidden);
};
sync();
document.addEventListener("visibilitychange", sync);
return () => {
document.removeEventListener("visibilitychange", sync);
root.classList.remove("login-page--paused");
};
}, []);
return (
<div className="login-page">
<div className="login-page__space" aria-hidden="true">
<div className="login-planet">
<div className="login-planet__earth">
<div className="login-planet__textures" />
</div>
</div>
</div>
{/* Sibling of space so blooms sit above the stage, under the login card. */}
<div className="login-page__flares" aria-hidden="true">
<span className="login-page__flare login-page__flare--a" />
</div>
<aside className="login-page__rail">{children}</aside>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,857 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
createConfigSyncCycle,
fetchCliTargets,
fetchConfigSyncCycleTasks,
fetchConfigSyncCycles,
fetchConfigSyncDashboard,
formatErr,
pauseConfigSyncCycle,
resumeConfigSyncCycle,
stopConfigSyncCycle,
updateConfigSyncPolicy,
} from "../../services/api";
import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips";
const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 10;
export function ConfigSyncPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [cyclePage, setCyclePage] = useState(1);
const [cyclePageSize, setCyclePageSize] = useState(10);
const [cycleStatus, setCycleStatus] = useState("");
const [cycleKeyword, setCycleKeyword] = useState("");
const [exportingCycles, setExportingCycles] = useState(false);
const [expandedCycleId, setExpandedCycleId] = useState("");
const [taskPage, setTaskPage] = useState(1);
const [taskPageSize, setTaskPageSize] = useState(10);
const [taskStatus, setTaskStatus] = useState("");
const [taskKeyword, setTaskKeyword] = useState("");
const [exportingTasks, setExportingTasks] = useState(false);
const debouncedCycleKeyword = useDebouncedValue(cycleKeyword, 300);
const debouncedTaskKeyword = useDebouncedValue(taskKeyword, 300);
const [enabled, setEnabled] = useState(false);
const [intervalDays, setIntervalDays] = useState(3);
const [concurrency, setConcurrency] = useState(5);
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
const [historyKeep, setHistoryKeep] = useState(3);
const [cycleKeep, setCycleKeep] = useState(30);
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
const [policyHydrated, setPolicyHydrated] = useState(false);
const [targetKeyword, setTargetKeyword] = useState("");
const [targetPage, setTargetPage] = useState(1);
const dashQuery = useQuery({
queryKey: queryKeys.configSyncDashboard,
queryFn: fetchConfigSyncDashboard,
staleTime: 1000,
refetchInterval: (q) => {
const running = q.state.data?.running_cycle;
return running && (running.status === "running" || running.status === "paused" || running.status === "pending")
? POLL_MS
: false;
},
});
useEffect(() => {
if (!dashQuery.data || policyHydrated) return;
const p = dashQuery.data.policy;
setEnabled(Boolean(p.enabled));
setIntervalDays(Number(p.interval_days || 3));
setConcurrency(Number(p.concurrency || 5));
setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
setHistoryKeep(Number(p.history_keep ?? 3));
setCycleKeep(Math.max(0, Math.min(200, Number(p.cycle_keep ?? 30))));
const map: Record<string, ConfigSyncTargetRef> = {};
for (const ref of p.selected_targets || []) {
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
}
setSelectedMap(map);
setPolicyHydrated(true);
}, [dashQuery.data, policyHydrated]);
useEffect(() => {
const id = String(searchParams.get("cycle_id") || "").trim();
if (!id) return;
setExpandedCycleId(id);
setTaskPage(1);
setTaskPageSize(20);
setTaskKeyword("");
setTaskStatus("");
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("cycle_id") === id) next.delete("cycle_id");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
const cyclesQuery = useQuery({
queryKey: queryKeys.configSyncCycles(cyclePage, cyclePageSize, cycleStatus, debouncedCycleKeyword),
queryFn: () =>
fetchConfigSyncCycles({
page: cyclePage,
pageSize: cyclePageSize,
status: cycleStatus,
keyword: debouncedCycleKeyword,
}),
staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false),
});
const tasksQuery = useQuery({
queryKey: queryKeys.configSyncCycleTasks(
expandedCycleId,
taskPage,
taskPageSize,
taskStatus,
debouncedTaskKeyword,
),
queryFn: () =>
fetchConfigSyncCycleTasks({
cycleId: expandedCycleId,
page: taskPage,
pageSize: taskPageSize,
status: taskStatus,
keyword: debouncedTaskKeyword,
}),
enabled: Boolean(expandedCycleId),
staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_cycle?.id === expandedCycleId ? POLL_MS : false),
});
const targetsQuery = useQuery({
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
queryFn: () =>
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
enabled: scopeMode === "selected",
staleTime: 5000,
});
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.configSyncDashboard }),
queryClient.invalidateQueries({ queryKey: queryKeys.configSyncCyclesAll }),
queryClient.invalidateQueries({ queryKey: queryKeys.configSyncCycleTasksAll }),
]);
};
const savePolicyMut = useMutation({
mutationFn: () =>
updateConfigSyncPolicy({
enabled,
interval_days: intervalDays,
concurrency,
scope_mode: scopeMode,
history_keep: historyKeep,
cycle_keep: cycleKeep,
selected_targets: Object.values(selectedMap),
}),
onSuccess: async (saved) => {
// Apply server response immediately — do NOT flip policyHydrated false then
// rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug).
setEnabled(Boolean(saved.enabled));
setIntervalDays(Number(saved.interval_days || 3));
setConcurrency(Number(saved.concurrency || 5));
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
setHistoryKeep(Number(saved.history_keep ?? 3));
setCycleKeep(Math.max(0, Math.min(200, Number(saved.cycle_keep ?? 30))));
const map: Record<string, ConfigSyncTargetRef> = {};
for (const ref of saved.selected_targets || []) {
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
}
setSelectedMap(map);
setPolicyHydrated(true);
queryClient.setQueryData(queryKeys.configSyncDashboard, (prev: unknown) => {
if (!prev || typeof prev !== "object") return prev;
return { ...(prev as object), policy: saved };
});
showOk(t("configSync.policySaved"));
await refresh();
},
onError: (err) => showError(formatErr(err)),
});
const startMut = useMutation({
mutationFn: (mode: "full" | "retry_failed") => createConfigSyncCycle({ mode }),
onSuccess: async () => {
showOk(t("configSync.started"));
await refresh();
},
onError: (err) => showError(formatErr(err)),
});
const pauseMut = useMutation({
mutationFn: (id: string) => pauseConfigSyncCycle(id),
onSuccess: async () => {
showOk(t("configSync.paused"));
await refresh();
},
onError: (err) => showError(formatErr(err)),
});
const resumeMut = useMutation({
mutationFn: (id: string) => resumeConfigSyncCycle(id),
onSuccess: async () => {
showOk(t("configSync.resumed"));
await refresh();
},
onError: (err) => showError(formatErr(err)),
});
const stopMut = useMutation({
mutationFn: (id: string) => stopConfigSyncCycle(id),
onSuccess: async () => {
showOk(t("configSync.stopped"));
await refresh();
},
onError: (err) => showError(formatErr(err)),
});
const dash = dashQuery.data;
const running = dash?.running_cycle;
const last = dash?.last_cycle;
const cycles = cyclesQuery.data?.items ?? [];
const cycleTotal = Number(cyclesQuery.data?.total || 0);
const cyclePages = pageCount(cycleTotal, cyclePageSize);
const taskTotal = Number(tasksQuery.data?.total || 0);
const taskPages = pageCount(taskTotal, taskPageSize);
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
const hasCycleFilters = Boolean(cycleKeyword.trim() || cycleStatus);
const openCycleTasks = (cycleId: string) => {
setExpandedCycleId(cycleId);
setTaskPage(1);
setTaskKeyword("");
setTaskStatus("");
};
const exportCyclesCsv = async () => {
setExportingCycles(true);
try {
const rows = await fetchAllPages<ConfigSyncCycle>({
pageSize: 100,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchConfigSyncCycles({
page: p,
pageSize: ps,
status: cycleStatus,
keyword: debouncedCycleKeyword,
}),
});
downloadCsv(
`${t("configSync.exportCyclesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "id", header: "ID" },
{ key: "trigger_mode", header: t("configSync.col.trigger") },
{ key: "status", header: t("configSync.col.status") },
{
key: "progress",
header: t("configSync.col.progress"),
value: (r) => `${r.success_count}/${r.planned_count} fail ${r.fail_count}`,
},
{
key: "started_at",
header: t("configSync.col.started"),
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
},
{
key: "ended_at",
header: t("configSync.col.ended"),
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
},
{ key: "error_message", header: t("configSync.col.message") },
],
);
if (rows.length < cycleTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(cycleTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingCycles(false);
}
};
const exportTasksCsv = async () => {
if (!expandedCycleId) return;
setExportingTasks(true);
try {
const rows = await fetchAllPages<ConfigSyncTask>({
pageSize: 200,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchConfigSyncCycleTasks({
cycleId: expandedCycleId,
page: p,
pageSize: ps,
status: taskStatus,
keyword: debouncedTaskKeyword,
}),
});
downloadCsv(
`${t("configSync.exportTasksName")}-${expandedCycleId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "ne_name", header: t("configSync.col.name"), value: (r) => r.ne_name || r.target_id },
{ key: "ne_ip", header: "IP" },
{ key: "vendor", header: t("configSync.col.vendor") },
{ key: "source", header: t("configSync.col.source") },
{ key: "status", header: t("configSync.col.status") },
{ key: "message", header: t("configSync.col.message") },
{ key: "target_id", header: "ID" },
],
);
if (rows.length < taskTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(taskTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingTasks(false);
}
};
const toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed";
const key = `${source}:${row.id}`;
setSelectedMap((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { source, id: row.id };
return next;
});
};
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("configSync.title")}</h2>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={dashQuery.isFetching}
onPress={() => void refresh()}
>
{t("common.refresh")}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={Boolean(running) || startMut.isPending}
onPress={() => startMut.mutate("full")}
>
{t("configSync.syncNow")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
onPress={() => startMut.mutate("retry_failed")}
>
{t("configSync.retryFailed")}
</Button>
{running?.status === "running" || running?.status === "pending" ? (
<Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(running.id)}
>
{t("configSync.pause")}
</Button>
) : null}
{running?.status === "paused" ? (
<Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(running.id)}
>
{t("configSync.resume")}
</Button>
) : null}
{running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id);
}}
>
{t("configSync.stop")}
</Button>
) : null}
</div>
</div>
<div className="pt-list" style={{ marginBottom: 16 }}>
<div className="pt-list-kpis">
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("configSync.kpi.snapshots")}</div>
<div className="pt-list-kpi__value">{dash?.snapshot_count ?? "—"}</div>
</div>
<div className={`pt-list-kpi${running ? " pt-list-kpi--live" : ""}`}>
<div className="pt-list-kpi__label">{t("configSync.kpi.running")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: running ? 15 : 22 }}>
{running
? `${running.status} · ${running.success_count}/${running.planned_count}`
: t("configSync.kpi.idle")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("configSync.kpi.last")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
{last
? `${last.status} · ok ${last.success_count} / fail ${last.fail_count}`
: t("common.empty")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("configSync.kpi.nextDue")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
</div>
</div>
</div>
</div>
<div className="panel" style={{ marginBottom: 16 }}>
<h3>{t("configSync.policyTitle")}</h3>
<div className="config-sync-policy-row">
<label className="config-sync-policy-check">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>{t("configSync.enabled")}</span>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.intervalDays")}</span>
<Input
type="number"
min={1}
max={365}
value={String(intervalDays)}
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.concurrency")}</span>
<Input
type="number"
min={1}
max={30}
value={String(concurrency)}
onChange={(e) => setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.historyKeep")}</span>
<Input
type="number"
min={0}
max={30}
value={String(historyKeep)}
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.cycleKeep")}</span>
<Input
type="number"
min={0}
max={200}
value={String(cycleKeep)}
onChange={(e) => setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("configSync.scope")}</span>
<select
value={scopeMode}
onChange={(e) => setScopeMode(e.target.value === "selected" ? "selected" : "all")}
>
<option value="all">{t("configSync.scopeAll")}</option>
<option value="selected">{t("configSync.scopeSelected")}</option>
</select>
</label>
<Button
variant="primary"
isDisabled={savePolicyMut.isPending}
onPress={() => savePolicyMut.mutate()}
>
{t("configSync.savePolicy")}
</Button>
</div>
{scopeMode === "selected" ? (
<div style={{ marginTop: 12 }}>
<p className="muted">{t("configSync.selectedCount", { count: String(selectedCount) })}</p>
<div className="filter-inline">
<input
value={targetKeyword}
placeholder={t("configSync.targetKeywordPh")}
onChange={(e) => {
setTargetKeyword(e.target.value);
setTargetPage(1);
}}
/>
</div>
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("configSync.col.source")}</th>
<th>{t("configSync.col.name")}</th>
<th>IP</th>
<th>{t("configSync.col.vendor")}</th>
</tr>
</thead>
<tbody>
{(targetsQuery.data?.items ?? []).map((row) => {
const source = row.source === "ume" ? "ume" : "managed";
const key = `${source}:${row.id}`;
return (
<tr key={key}>
<td>
<input
type="checkbox"
checked={Boolean(selectedMap[key])}
onChange={() => toggleTarget(row)}
/>
</td>
<td>
<NmStatusChip color={sourceChipColor(source)}>{source}</NmStatusChip>
</td>
<td>{row.name}</td>
<td>{row.ip_address}</td>
<td>{row.vendor || "-"}</td>
</tr>
);
})}
</tbody>
</table>
<div className="pager">
<Button
size="sm"
variant="ghost"
isDisabled={targetPage <= 1}
onPress={() => setTargetPage((p) => p - 1)}
>
{t("common.prevPage")}
</Button>
<span className="muted">
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
page: String(targetPage),
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
</span>
<Button
size="sm"
variant="ghost"
isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onPress={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
</Button>
</div>
</div>
) : null}
</div>
<h3>{t("configSync.cyclesTitle")}</h3>
<div className="pt-list">
<div className="filter-inline">
<Input
value={cycleKeyword}
placeholder={t("configSync.keywordPh")}
onChange={(e) => {
setCycleKeyword(e.target.value);
setCyclePage(1);
}}
/>
<FieldSelect
value={cycleStatus}
onChange={(e) => {
setCycleStatus(e.target.value);
setCyclePage(1);
}}
>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="paused">paused</option>
<option value="success">success</option>
<option value="completed">completed</option>
<option value="failed">failed</option>
<option value="stopped">stopped</option>
</FieldSelect>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasCycleFilters}
onPress={() => {
setCycleKeyword("");
setCycleStatus("");
setCyclePage(1);
}}
>
{t("common.clearFilters")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exportingCycles || cycleTotal === 0}
onPress={() => void exportCyclesCsv()}
>
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
</Button>
</div>
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("configSync.col.trigger")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.progress")}</th>
<th>{t("configSync.col.started")}</th>
<th>{t("configSync.col.ended")}</th>
<th>{t("configSync.col.actions")}</th>
</tr>
</thead>
<tbody>
{cycles.map((c) => (
<tr key={c.id}>
<td>
<Button size="sm" variant="ghost" onPress={() => openCycleTasks(c.id)}>
{t("configSync.expand")}
</Button>
</td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td>
<td>
<NmStatusChip color={jobChipColor(c.status)}>{c.status}</NmStatusChip>
</td>
<td>
{c.success_count}/{c.planned_count} · fail {c.fail_count}
</td>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
<td>
<div className="btn-row">
{c.status === "running" || c.status === "pending" ? (
<Button
size="sm"
variant="ghost"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(c.id)}
>
{t("configSync.pause")}
</Button>
) : null}
{c.status === "paused" ? (
<Button
size="sm"
variant="ghost"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(c.id)}
>
{t("configSync.resume")}
</Button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
>
{t("configSync.stop")}
</Button>
) : null}
</div>
</td>
</tr>
))}
{!cycles.length && !cyclesQuery.isLoading ? (
<tr>
<td colSpan={8} className="muted">
{t("configSync.cyclesEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<ListPager
page={cyclePage}
pages={cyclePages}
total={cycleTotal}
pageSize={cyclePageSize}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setCyclePage}
onPageSizeChange={(size) => {
setCyclePageSize(size);
setCyclePage(1);
}}
disabled={cyclesQuery.isLoading}
/>
</div>
<AppModalShell
open={Boolean(expandedCycleId)}
onClose={() => setExpandedCycleId("")}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("configSync.tasksTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">
{expandedCycleId.slice(0, 8)}
{(() => {
const c = cycles.find((x) => x.id === expandedCycleId);
return c
? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
: "";
})()}
</p>
<div className="filter-inline">
<Input
value={taskKeyword}
placeholder={t("configSync.taskKeywordPh")}
onChange={(e) => {
setTaskKeyword(e.target.value);
setTaskPage(1);
}}
/>
<FieldSelect
value={taskStatus}
onChange={(e) => {
setTaskStatus(e.target.value);
setTaskPage(1);
}}
>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="success">success</option>
<option value="fail">fail</option>
<option value="skipped">skipped</option>
</FieldSelect>
<Button
size="sm"
variant="secondary"
isDisabled={exportingTasks || taskTotal === 0}
onPress={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</Button>
</div>
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{tasksQuery.isError ? (
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
) : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("configSync.col.name")}</th>
<th>IP</th>
<th>{t("configSync.col.vendor")}</th>
<th>{t("configSync.col.source")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.message")}</th>
</tr>
</thead>
<tbody>
{(tasksQuery.data?.items ?? []).map((task) => (
<tr key={task.id}>
<td>{task.ne_name || task.target_id}</td>
<td>{task.ne_ip}</td>
<td>{task.vendor || "-"}</td>
<td>
<NmStatusChip color={sourceChipColor(task.source)}>{task.source}</NmStatusChip>
</td>
<td>
<NmStatusChip color={jobChipColor(task.status)}>{task.status}</NmStatusChip>
</td>
<td title={task.message}>{task.message || "-"}</td>
</tr>
))}
{!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
<tr>
<td colSpan={6} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<ListPager
page={taskPage}
pages={taskPages}
total={taskTotal}
pageSize={taskPageSize}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setTaskPage}
onPageSizeChange={(size) => {
setTaskPageSize(size);
setTaskPage(1);
}}
disabled={tasksQuery.isLoading}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setExpandedCycleId("")}>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,233 @@
// Pure template editing/import helpers; preserve all comparison settings.
export type MetricSheet = {
sheet_id?: string;
title?: string;
metric_id: string;
key_fields: string[];
iface_fields: string[];
compare_fields: string[];
display_fields?: string[];
row_filters?: RowFilter[];
field_rules?: FieldRule[];
ignore_port_changes?: boolean | null;
};
export type FieldRule = {
field: string;
compare?: string;
normalize?: string;
ignore?: boolean;
tolerance?: number;
};
export type RowFilter = {
field?: string;
op?: string;
value?: string | number | boolean | (string | number | boolean)[];
any?: RowFilter[];
all?: RowFilter[];
};
export function parseTemplateImport(raw: unknown): {
name: string;
note: string;
metrics: MetricSheet[];
iface_normalize_rules?: { from: string; to: string }[];
} | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
if ((o.format !== undefined && o.format !== "netx.biz_compare_template") || (o.version !== undefined && o.version !== 1)) {
throw new Error("template: unsupported_format_version");
}
// Accept our export shape or a bare {name, metrics} / TemplateIn body
const name = String(o.name || "").trim();
const note = String(o.note || "");
let metricsRaw = o.metrics;
if (!Array.isArray(metricsRaw) && o.metric_id) {
metricsRaw = [
{
metric_id: o.metric_id,
key_fields: o.key_fields || [],
iface_fields: o.iface_fields || [],
compare_fields: o.compare_fields || [],
display_fields: o.display_fields || [],
row_filters: o.row_filters || [],
field_rules: o.field_rules || [],
ignore_fields: o.ignore_fields || [],
ignore_port_changes: o.ignore_port_changes,
},
];
}
if (!name || !Array.isArray(metricsRaw) || !metricsRaw.length) return null;
const metrics: MetricSheet[] = [];
for (const item of metricsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`metrics[${metrics.length}]: sheet_required`);
const m = item as Record<string, unknown>;
const mid = String(m.metric_id || "").trim();
const path = `metrics[${metrics.length}]`;
const keys = stringList(m.key_fields, `${path}.key_fields`);
if (!mid || !keys.length) throw new Error(`${path}: metric_id_and_keys_required`);
const rules = m.field_rules === undefined ? [] : m.field_rules;
if (!Array.isArray(rules)) throw new Error(`${path}.field_rules: list_required`);
const ignore = stringList(m.ignore_fields ?? [], `${path}.ignore_fields`);
const fieldRules = structuredClone(rules) as FieldRule[];
for (const field of ignore) {
const rule = fieldRules.find((r) => r?.field === field);
if (rule) Object.assign(rule, { compare: "ignore", ignore: true });
else fieldRules.push({ field, compare: "ignore", ignore: true });
}
if (m.row_filters !== undefined && !Array.isArray(m.row_filters)) throw new Error(`${path}.row_filters: list_required`);
if (m.ignore_port_changes != null && typeof m.ignore_port_changes !== "boolean") throw new Error(`${path}: boolean_port_policy_required`);
metrics.push({
sheet_id: String(m.sheet_id || mid).trim() || mid,
title: String(m.title || m.sheet_id || mid).trim() || mid,
metric_id: mid,
key_fields: keys,
iface_fields: stringList(m.iface_fields ?? [], `${path}.iface_fields`),
compare_fields: stringList(m.compare_fields ?? [], `${path}.compare_fields`).filter((f) => !ignore.includes(f)),
display_fields: m.display_fields === undefined ? undefined : stringList(m.display_fields, `${path}.display_fields`),
row_filters: (m.row_filters ?? []) as RowFilter[],
field_rules: fieldRules,
ignore_port_changes: m.ignore_port_changes as boolean | null | undefined,
});
}
if (!metrics.length) return null;
const normRaw = o.iface_normalize_rules;
const iface_normalize_rules: { from: string; to: string }[] = [];
if (Array.isArray(normRaw)) {
for (const item of normRaw) {
if (!item || typeof item !== "object") throw new Error("iface_normalize_rules: alias_pair_required");
const r = item as Record<string, unknown>;
const fr = String(r.from || "").trim();
const to = String(r.to || "").trim();
if (!fr || !to) throw new Error("iface_normalize_rules: alias_pair_required");
if (iface_normalize_rules.some((x) => x.from.toLowerCase() === fr.toLowerCase())) throw new Error("iface_normalize_rules: duplicate_alias");
iface_normalize_rules.push({ from: fr, to });
}
}
if (normRaw != null && !Array.isArray(normRaw)) throw new Error("iface_normalize_rules: list_required");
validateTemplateDraft(metrics);
return { name, note, metrics, iface_normalize_rules };
}
export function parseIfaceNormText(text: string): { from: string; to: string }[] {
const out: { from: string; to: string }[] = [];
for (const [index, line] of text.split(/\r?\n/).entries()) {
const s = line.trim();
if (!s || s.startsWith("#")) continue;
let fr = "";
let to = "";
if (s.includes(",")) {
const i = s.indexOf(",");
fr = s.slice(0, i).trim();
to = s.slice(i + 1).trim();
} else if (s.includes("\t")) {
const i = s.indexOf("\t");
fr = s.slice(0, i).trim();
to = s.slice(i + 1).trim();
} else {
const parts = s.split(/\s+/);
if (parts.length >= 2) {
fr = parts[0];
to = parts.slice(1).join(" ");
}
}
if (!fr || !to) throw new Error(`iface_normalize_rules:${index + 1}: alias_pair_required`);
if (out.some((x) => x.from.toLowerCase() === fr.toLowerCase())) throw new Error(`iface_normalize_rules:${index + 1}: duplicate_alias`);
out.push({ from: fr, to });
}
return out;
}
function isLeafFilter(f: RowFilter): boolean {
return Boolean(f && !f.any && !f.all && f.field);
}
function stringList(raw: unknown, path: string): string[] {
if (!Array.isArray(raw) || raw.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${path}: nonempty_strings_required`);
const values = raw.map((x: string) => x.trim());
if (new Set(values).size !== values.length) throw new Error(`${path}: duplicate_field`);
return values;
}
export function validateTemplateDraft(metrics: MetricSheet[]): void {
if (!metrics.length) throw new Error("metrics: metrics_required");
const ids = new Set<string>();
const ops = new Set(["eq", "==", "ci_eq", "ne", "!=", "in", "not_in", "nin", "contains", "empty", "not_empty", "nonempty", "regex", "age_timer"]);
function filters(nodes: RowFilter[], path: string, depth = 0) {
if (!Array.isArray(nodes) || depth > 12) throw new Error(`${path}: invalid_filters`);
nodes.forEach((f, i) => {
const here = `${path}[${i}]`;
if (!f || typeof f !== "object") throw new Error(`${here}: filter_required`);
if (f.any !== undefined || f.all !== undefined) {
if ((f.any !== undefined && f.all !== undefined) || f.field || f.op || f.value != null) throw new Error(`${here}: group_or_leaf_required`);
const kids = f.any ?? f.all;
if (!Array.isArray(kids) || !kids.length) throw new Error(`${here}: nonempty_group_required`);
filters(kids, here, depth + 1);
} else {
const op = (f.op || "eq").trim().toLowerCase();
if (!f.field?.trim() || !ops.has(op)) throw new Error(`${here}: invalid_filter_field_or_operator`);
if (["in", "not_in", "nin"].includes(op) && (!Array.isArray(f.value) || !f.value.length)) throw new Error(`${here}: nonempty_value_list_required`);
if (!["in", "not_in", "nin", "empty", "not_empty", "nonempty", "age_timer"].includes(op) && !["string", "number", "boolean"].includes(typeof f.value)) throw new Error(`${here}: scalar_value_required`);
if (["contains", "regex"].includes(op) && !String(f.value ?? "").trim()) throw new Error(`${here}: value_required`);
}
});
}
metrics.forEach((s, i) => {
const path = `metrics[${i}]`;
const id = (s.sheet_id || s.metric_id).trim();
if (!s.metric_id?.trim() || !id || ids.has(id)) throw new Error(`${path}: invalid_or_duplicate_sheet_id`);
ids.add(id);
if (!stringList(s.key_fields, `${path}.key_fields`).length) throw new Error(`${path}: key_fields_required`);
for (const key of ["iface_fields", "compare_fields", "display_fields"] as const) if (s[key] !== undefined) stringList(s[key], `${path}.${key}`);
const fields = new Set<string>();
for (const r of s.field_rules || []) {
if (!r?.field?.trim() || fields.has(r.field.trim())) throw new Error(`${path}.field_rules: invalid_or_duplicate_field`);
fields.add(r.field.trim());
if (!["", "eq", "ignore", "skip", "off", "numeric", "number", "int", "float", "percent", "pct", "rel"].includes((r.compare || "").toLowerCase())) throw new Error(`${path}.field_rules: unknown_compare_mode`);
if (!["", "none", "strip", "lower", "upper", "mac", "empty_as_blank"].includes((r.normalize || "").toLowerCase())) throw new Error(`${path}.field_rules: unknown_normalize_mode`);
if (r.tolerance != null && (typeof r.tolerance !== "number" || !Number.isFinite(r.tolerance) || r.tolerance < 0)) throw new Error(`${path}.${r.field}: finite_nonnegative_tolerance_required`);
}
filters(s.row_filters || [], `${path}.row_filters`);
});
}
/** Expand sheet row_filters into OR-groups of AND leaf conditions for editing. */
export function toOrGroups(filters: RowFilter[]): RowFilter[][] | null {
const list = filters || [];
if (!list.length) return [];
if (list.length === 1 && list[0]?.any && Array.isArray(list[0].any)) {
const groups: RowFilter[][] = [];
for (const g of list[0].any) {
if (g?.all?.length && !g.any && !g.field && g.all.every(isLeafFilter)) groups.push(g.all);
else if (isLeafFilter(g)) groups.push([g]);
else return null;
}
return groups;
}
if (list.every(isLeafFilter)) return [list.map((f) => ({ ...f }))];
// Opaque structures stay intact. Never show an editable, incomplete subset.
return null;
}
/** Serialize OR-groups back to engine row_filters (AND of leaves, or single any-of). */
export function fromOrGroups(groups: RowFilter[][]): RowFilter[] {
const clean = groups
.map((g) =>
g
.map((f) => ({
field: f.field || "",
op: f.op || "eq",
value: f.value ?? "",
}))
.filter((f) => f.field),
)
.filter((g) => g.length);
if (!clean.length) return [];
if (clean.length === 1) return clean[0];
return [
{
any: clean.map((g) => (g.length === 1 ? g[0] : { all: g })),
},
];
}

View file

@ -0,0 +1,70 @@
/**
* Short-lived cache for cutover list APIs.
* Switching biz-state / compare / migration / templates remounts each page;
* without cache every click re-waits on Promise.all network round-trips.
*/
type Entry = { at: number; data: unknown };
const store = new Map<string, Entry>();
const inFlight = new Map<string, Promise<unknown>>();
const DEFAULT_TTL_MS = 45_000;
export async function cutoverCachedGet<T>(
key: string,
fetcher: () => Promise<T>,
opts?: { force?: boolean; ttlMs?: number },
): Promise<T> {
const ttl = opts?.ttlMs ?? DEFAULT_TTL_MS;
if (!opts?.force) {
const hit = store.get(key);
if (hit && Date.now() - hit.at < ttl) {
return hit.data as T;
}
}
const existing = inFlight.get(key);
if (!opts?.force && existing) return existing as Promise<T>;
const request = Promise.resolve().then(fetcher).then((data) => {
// An invalidation or forced refresh may supersede this request.
if (inFlight.get(key) === request) store.set(key, { at: Date.now(), data });
return data;
}).finally(() => {
if (inFlight.get(key) === request) inFlight.delete(key);
});
inFlight.set(key, request);
return request;
}
/** Return stale immediately (if any), refresh in background and notify. */
export async function cutoverCachedGetSWR<T>(
key: string,
fetcher: () => Promise<T>,
onFresh?: (data: T) => void,
): Promise<T> {
const hit = store.get(key);
if (hit) {
void cutoverCachedGet(key, fetcher, { ttlMs: 0 })
.then((data) => {
if (store.get(key)?.data === data) onFresh?.(data);
})
.catch(() => {
/* keep stale */
});
return hit.data as T;
}
return cutoverCachedGet(key, fetcher);
}
export function invalidateCutoverCache(prefix = ""): void {
if (!prefix) {
store.clear();
inFlight.clear();
return;
}
for (const k of [...store.keys()]) {
if (k.startsWith(prefix)) store.delete(k);
}
for (const k of [...inFlight.keys()]) {
if (k.startsWith(prefix)) inFlight.delete(k);
}
}

View file

@ -0,0 +1,13 @@
/** Prefetch cutover compare route chunks so nav switches don't wait on first download. */
let started = false;
export function prefetchCutoverPages(): void {
if (started) return;
started = true;
// Fire-and-forget dynamic imports (same modules as App.tsx lazy()).
void import("../BizStatePage");
void import("../BizComparePage");
void import("../BizMigrationPage");
void import("../BizMonitorTemplatesPage");
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,250 @@
import { Input, Label, TextField } from "@heroui/react";
import { useI18n } from "../../../i18n";
import { FieldSelect } from "../../../components/ui/FieldSelect";
import {
HOP_VENDORS,
defaultHopTemplate,
expandBastionHopFields,
isAutoHopTemplate,
isBastionHopVendor,
isLinuxHopVendor,
patchHopVendorChange,
type HopTargetAuthMode,
type HopVendor,
} from "../lib/hopProxy";
export type HopProxyFieldsState = {
hop_vendor: HopVendor;
hop_host: string;
hop_port: number;
hop_protocol: string;
hop_username: string;
hop_password: string;
hop_command_template: string;
hop_vrf: string;
hop_target_auth_mode: HopTargetAuthMode;
hop_enter_system_view: boolean;
};
export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_vendor: "zte",
hop_host: "",
hop_port: 22,
hop_protocol: "ssh",
hop_username: "",
hop_password: "",
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
hop_vrf: "",
hop_target_auth_mode: "bastion_managed",
hop_enter_system_view: false,
});
function applyHopTemplate(
prev: HopProxyFieldsState,
protocol: string,
vrf: string,
force = false,
): Partial<HopProxyFieldsState> {
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) {
return {};
}
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
}
function hopHintKey(vendor: string): string {
const v = String(vendor || "").toLowerCase();
if (v === "bastion") return "managedNe.hop.bastionHint";
if (v === "linux") return "managedNe.hop.linuxHint";
if (v === "huawei") return "managedNe.hop.huaweiHint";
if (v === "cisco") return "managedNe.hop.ciscoHint";
return "managedNe.hop.zteHint";
}
function templateHintKey(vendor: string): string {
const v = String(vendor || "").toLowerCase();
if (v === "bastion") return "managedNe.hop.templateHintBastion";
if (v === "huawei") return "managedNe.hop.templateHintHuawei";
if (v === "cisco") return "managedNe.hop.templateHintCisco";
return "managedNe.hop.templateHint";
}
function vrfLabelKey(vendor: string): string {
const v = String(vendor || "").toLowerCase();
if (v === "huawei") return "managedNe.hop.vpnInstance";
if (v === "cisco") return "managedNe.hop.vrfCisco";
return "managedNe.hop.vrf";
}
type Props = {
value: HopProxyFieldsState;
onChange: (patch: Partial<HopProxyFieldsState>) => void;
hopPasswordRequired?: boolean;
hopPasswordOptional?: boolean;
};
export function HopProxyFields({
value,
onChange,
hopPasswordRequired = true,
hopPasswordOptional = false,
}: Props) {
const { t } = useI18n();
const linux = isLinuxHopVendor(value.hop_vendor);
const bastion = isBastionHopVendor(value.hop_vendor);
const huawei = value.hop_vendor === "huawei";
const cliHop = !linux && !bastion;
const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch);
return (
<div className="form-grid">
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.type")}
required
value={value.hop_vendor}
hint={t(hopHintKey(value.hop_vendor))}
onChange={(e) => {
const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value));
}}
>
{HOP_VENDORS.map((v) => (
<option key={v} value={v}>
{t(`managedNe.hop.vendor.${v}`)}
</option>
))}
</FieldSelect>
<TextField
fullWidth
isRequired
value={value.hop_host}
onChange={(hop_host) => set({ hop_host })}
onBlur={() => {
if (!bastion) return;
const raw = String(value.hop_host || "").trim();
if (!raw.includes("@")) return;
const parsed = expandBastionHopFields(raw, value.hop_username);
if (!parsed.hop_host || parsed.hop_host === raw) return;
set({
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || value.hop_username,
});
}}
>
<Label>{t("managedNe.hop.host")}</Label>
<Input placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined} />
{bastion ? <span className="form-field-hint">{t("managedNe.hop.hostHintBastion")}</span> : null}
</TextField>
<TextField
fullWidth
type="number"
value={String(value.hop_port)}
onChange={(hop_port) => set({ hop_port: Number(hop_port) || 22 })}
>
<Label>{t("managedNe.hop.port")}</Label>
<Input />
</TextField>
{bastion ? (
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.targetAuthMode")}
value={value.hop_target_auth_mode}
hint={t("managedNe.hop.targetAuthHint")}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
>
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</FieldSelect>
) : null}
{cliHop ? (
<FieldSelect
label={t("managedNe.hop.protocol")}
value={value.hop_protocol}
onChange={(e) => {
const hop_protocol = e.target.value;
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
}}
>
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option>
</FieldSelect>
) : null}
<TextField
fullWidth
isRequired
value={value.hop_username}
onChange={(hop_username) => set({ hop_username })}
>
<Label>{t("managedNe.hop.username")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password"
isRequired={hopPasswordRequired}
value={value.hop_password}
onChange={(hop_password) => set({ hop_password })}
>
<Label>
{t("managedNe.hop.password")}
{hopPasswordOptional ? (
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
) : null}
</Label>
<Input />
</TextField>
{bastion ? (
<TextField
fullWidth
className="form-grid__full"
value={value.hop_command_template}
onChange={(hop_command_template) => set({ hop_command_template })}
>
<Label>{t("managedNe.hop.usernameTemplate")}</Label>
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</TextField>
) : null}
{cliHop ? (
<>
<TextField
fullWidth
value={value.hop_vrf}
onChange={(hop_vrf) => {
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
}}
>
<Label>{t(vrfLabelKey(value.hop_vendor))}</Label>
<Input />
</TextField>
{huawei ? (
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.enterSystemView")}
value={value.hop_enter_system_view ? "yes" : "no"}
hint={t("managedNe.hop.enterSystemViewHint")}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
>
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</FieldSelect>
) : null}
<TextField
fullWidth
className="form-grid__full"
value={value.hop_command_template}
onChange={(hop_command_template) => set({ hop_command_template })}
>
<Label>{t("managedNe.hop.commandTemplate")}</Label>
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</TextField>
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,94 @@
import { Alert, Button, Chip, Modal } from "@heroui/react";
import { useMutation } from "@tanstack/react-query";
import { connectTestManagedNe } from "../../../services/api";
import { AppModalShell } from "../../../components/ui/AppModalShell";
import { useI18n } from "../../../i18n";
import { formatSystemTime } from "../../../utils/time";
import type { ManagedNeItem } from "../../../types";
import { connectPillLevel } from "../lib/connectStatus";
export type ManagedNeConnectDetailDialogProps = {
row: ManagedNeItem | null;
onClose: () => void;
/** Called after a retest is submitted so parent can refresh/poll. */
onRetestSubmitted?: (rowId: string) => void;
};
function chipColor(level: string): "success" | "danger" | "warning" | "default" {
if (level === "up" || level === "pass") return "success";
if (level === "down" || level === "fail") return "danger";
if (level === "testing") return "warning";
return "default";
}
export function ManagedNeConnectDetailDialog({
row,
onClose,
onRetestSubmitted,
}: ManagedNeConnectDetailDialogProps) {
const { t } = useI18n();
const connectMutation = useMutation({
mutationFn: connectTestManagedNe,
onSuccess: () => {
if (row) onRetestSubmitted?.(row.id);
},
});
const open = Boolean(row);
const level = row ? connectPillLevel(row.connect_status) : "unknown";
return (
<AppModalShell open={open} onClose={onClose} size="lg">
<Modal.Header>
<Modal.Heading>{t("managedNe.connectDetailTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{row ? (
<>
<p className="text-sm text-muted">
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
{row.connect_tested_at
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<div className="flex flex-wrap items-center gap-2">
<Chip size="sm" color={chipColor(level)} variant="soft">
<Chip.Label>{row.connect_status}</Chip.Label>
</Chip>
{row.connect_message ? (
<span className="connect-detail-summary text-sm">— {row.connect_message}</span>
) : null}
</div>
<pre className="connect-log">
{row.connect_detail?.trim() ||
row.connect_message?.trim() ||
t("managedNe.connectDetailEmpty")}
</pre>
</>
) : null}
{connectMutation.isError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{String(connectMutation.error)}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Modal.Body>
<Modal.Footer>
<Button
variant="primary"
isDisabled={!row || connectMutation.isPending}
onPress={() => {
if (row) connectMutation.mutate([row.id]);
}}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</Button>
<Button variant="tertiary" onPress={onClose}>
{t("managedNe.form.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
);
}

View file

@ -0,0 +1,301 @@
import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../../services/api";
import { HopProxyFields } from "./HopProxyFields";
import { AppModalShell } from "../../../components/ui/AppModalShell";
import { FieldSelect } from "../../../components/ui/FieldSelect";
import { queryKeys } from "../../../constants/queryKeys";
import { useI18n } from "../../../i18n";
import { patchHopVendorChange } from "../lib/hopProxy";
import type { ManagedNeItem } from "../../../types";
import {
applyHopTemplate,
buildManagedNeSaveBody,
emptyManagedNeForm,
formFromManagedNe,
allowsOpenExecPolicy,
type ManagedNeFormState,
} from "../lib/formState";
export type ManagedNeFormDialogProps = {
open: boolean;
/** null = create mode */
editing: ManagedNeItem | null;
/** Prefill when creating (e.g. topology deep-link fields). Ignored when editing. */
initialValues?: Partial<ManagedNeFormState>;
onClose: () => void;
onSaved: (item: ManagedNeItem) => void;
};
export function ManagedNeFormDialog({
open,
editing,
initialValues,
onClose,
onSaved,
}: ManagedNeFormDialogProps) {
const { t } = useI18n();
const [form, setForm] = useState<ManagedNeFormState>(emptyManagedNeForm);
const metaQuery = useQuery({
queryKey: queryKeys.managedNeMeta,
queryFn: fetchManagedNeMeta,
staleTime: 60_000,
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);
const deviceTypes = useMemo(() => {
const base = metaQuery.data?.device_types ?? [];
const cur = String(form.device_type || "").trim();
if (cur && !base.includes(cur)) return [cur, ...base];
return base;
}, [metaQuery.data?.device_types, form.device_type]);
const saveMutation = useMutation({
mutationFn: async () => {
const body = buildManagedNeSaveBody(form, {
editing: Boolean(editing),
hopHostRequired: t("managedNe.hop.hostRequired"),
hopUserRequired: t("managedNe.hop.userRequired"),
hopPasswordRequired: t("managedNe.hop.passwordRequired"),
execPolicyEnabled: Boolean(metaQuery.data?.exec_policy_enabled),
});
if (editing) {
return updateManagedNe(editing.id, body);
}
return createManagedNe(body);
},
onSuccess: (item) => {
onSaved(item);
},
});
return (
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
<Modal.Header>
<Modal.Heading>
{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
</Modal.Heading>
<Modal.CloseTrigger isDisabled={saveMutation.isPending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid">
<TextField
fullWidth
value={form.name}
onChange={(name) => setForm({ ...form, name })}
>
<Label>{t("managedNe.col.name")}</Label>
<Input />
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
</TextField>
<FieldSelect
label={t("managedNe.col.vendor")}
required
value={form.vendor}
onChange={(e) => {
const vendor = e.target.value;
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";
}
return next;
});
}}
>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("managedNe.col.deviceType")}
required
value={form.device_type}
onChange={(e) => {
const device_type = e.target.value;
setForm((prev) => ({
...prev,
device_type,
exec_policy: allowsOpenExecPolicy(device_type) ? prev.exec_policy : "readonly",
}));
}}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</FieldSelect>
{execPolicyEnabled ? (
<>
<FieldSelect
label={t("managedNe.col.execPolicy")}
value={form.exec_policy}
onChange={(e) =>
setForm({
...form,
exec_policy: e.target.value as ManagedNeFormState["exec_policy"],
})
}
>
<option value="readonly">{t("managedNe.execPolicy.readonly")}</option>
{allowsOpenExecPolicy(form.device_type) ? (
<>
<option value="linux_shell">{t("managedNe.execPolicy.linuxShell")}</option>
<option value="unrestricted">{t("managedNe.execPolicy.unrestricted")}</option>
</>
) : null}
</FieldSelect>
<p className="form-field-hint form-grid__full">{t("managedNe.execPolicy.hint")}</p>
</>
) : null}
<TextField
fullWidth
isRequired
value={form.ip_address}
onChange={(ip_address) => setForm({ ...form, ip_address })}
>
<Label>{t("managedNe.col.ip")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="number"
value={String(form.port)}
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
>
<Label>{t("managedNe.col.port")}</Label>
<Input />
</TextField>
<FieldSelect
label={t("managedNe.col.protocol")}
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</FieldSelect>
<TextField
fullWidth
isRequired
value={form.username}
onChange={(username) => setForm({ ...form, username })}
>
<Label>{t("managedNe.col.user")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password"
value={form.password}
onChange={(password) => setForm({ ...form, password })}
>
<Label>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
</Label>
<Input />
</TextField>
<TextField fullWidth value={form.tags} onChange={(tags) => setForm({ ...form, tags })}>
<Label>{t("managedNe.col.tags")}</Label>
<Input />
</TextField>
<TextField
fullWidth
className="form-grid__full"
value={form.remark}
onChange={(remark) => setForm({ ...form, remark })}
>
<Label>{t("managedNe.col.remark")}</Label>
<Input />
</TextField>
</div>
<fieldset className="form-fieldset form-grid__full">
<legend>{t("managedNe.hop.sectionTitle")}</legend>
<Checkbox
isSelected={form.hop_enabled}
onChange={(hop_enabled) => {
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
...patchHopVendorChange(prev.hop_vendor, prev),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
}
: {}),
}));
}}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</Checkbox>
{form.hop_enabled ? (
<HopProxyFields
value={{
hop_vendor: form.hop_vendor,
hop_host: form.hop_host,
hop_port: form.hop_port,
hop_protocol: form.hop_protocol,
hop_username: form.hop_username,
hop_password: form.hop_password,
hop_command_template: form.hop_command_template,
hop_vrf: form.hop_vrf,
hop_target_auth_mode: form.hop_target_auth_mode,
hop_enter_system_view: form.hop_enter_system_view,
}}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
hopPasswordRequired={!editing}
hopPasswordOptional={Boolean(editing)}
/>
) : null}
</fieldset>
{saveMutation.isError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={saveMutation.isPending} onPress={onClose}>
{t("managedNe.form.cancel")}
</Button>
<Button
variant="primary"
isDisabled={saveMutation.isPending}
onPress={() => saveMutation.mutate()}
>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</Button>
</Modal.Footer>
</AppModalShell>
);
}

View file

@ -0,0 +1,14 @@
export function connectStatusClass(status: string): string {
if (status === "pass") return "pt-list-status--ok";
if (status === "fail") return "pt-list-status--failed";
if (status === "testing") return "pt-list-status--running";
return "pt-list-status--unknown";
}
export function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
const s = String(status || "").toLowerCase();
if (s === "pass" || s === "ok") return "up";
if (s === "fail" || s === "error") return "down";
if (s === "testing") return "warn";
return "unknown";
}

View file

@ -0,0 +1,206 @@
import {
defaultHopTemplate,
isAutoHopTemplate,
type HopVendor,
} from "./hopProxy";
import type { ManagedNeItem } from "../../../types";
export type ManagedNeFormState = {
name: string;
vendor: string;
device_type: string;
ip_address: string;
port: number;
protocol: string;
username: string;
password: string;
tags: string;
remark: string;
hop_enabled: boolean;
hop_vendor: HopVendor;
hop_host: string;
hop_port: number;
hop_protocol: string;
hop_username: string;
hop_password: string;
hop_command_template: string;
hop_vrf: string;
hop_target_auth_mode: "bastion_managed" | "manual";
hop_enter_system_view: boolean;
exec_policy: "readonly" | "linux_shell" | "unrestricted";
};
export function deviceTypeForVendor(vendor: string): string {
if (vendor === "ZTE") return "zte_zxros";
if (vendor === "Huawei") return "huawei";
if (vendor === "Cisco") return "cisco_ios";
if (vendor === "Juniper") return "juniper_junos";
if (vendor === "Nokia") return "nokia_sros";
return "generic";
}
export function isLinuxDeviceType(deviceType: string | undefined | null): boolean {
const low = String(deviceType || "")
.trim()
.toLowerCase();
return low === "linux" || low === "linux_ssh" || low === "linux_telnet" || low.startsWith("linux_");
}
export function isMikrotikDeviceType(deviceType: string | undefined | null): boolean {
const low = String(deviceType || "")
.trim()
.toLowerCase();
return (
low === "mikrotik_routeros" ||
low === "mikrotik_switchos" ||
low.startsWith("mikrotik_")
);
}
/** Linux + MikroTik may use linux_shell / unrestricted (multiline scripts). */
export function allowsOpenExecPolicy(deviceType: string | undefined | null): boolean {
return isLinuxDeviceType(deviceType) || isMikrotikDeviceType(deviceType);
}
export function emptyManagedNeForm(): ManagedNeFormState {
return {
name: "",
vendor: "ZTE",
device_type: "zte_zxros",
ip_address: "",
port: 22,
protocol: "ssh",
username: "",
password: "",
tags: "",
remark: "",
hop_enabled: false,
hop_vendor: "zte",
hop_host: "",
hop_port: 22,
hop_protocol: "ssh",
hop_username: "",
hop_password: "",
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
hop_vrf: "",
hop_target_auth_mode: "bastion_managed",
hop_enter_system_view: false,
exec_policy: "readonly",
};
}
export function applyHopTemplate(
prev: ManagedNeFormState,
protocol: string,
vrf: string,
force = false,
): Partial<ManagedNeFormState> {
if (
!force &&
!isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)
) {
return {};
}
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
}
export function formFromManagedNe(row: ManagedNeItem): ManagedNeFormState {
const hopVendor = (["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor)
? row.hop_vendor
: "zte") as HopVendor;
return {
name: row.name,
vendor: row.vendor,
device_type: row.device_type,
ip_address: row.ip_address,
port: row.port,
protocol: row.protocol,
username: row.username,
password: "",
tags: row.tags,
remark: row.remark,
hop_enabled: row.hop_enabled,
hop_vendor: hopVendor,
hop_host: row.hop_host,
hop_port: row.hop_port,
hop_protocol: row.hop_protocol,
hop_username: row.hop_username,
hop_password: "",
hop_command_template: isAutoHopTemplate(
row.hop_command_template,
row.hop_vendor,
row.hop_protocol,
row.hop_vrf,
)
? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf)
: row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf),
hop_vrf: row.hop_vrf,
hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
hop_enter_system_view: Boolean(row.hop_enter_system_view),
exec_policy:
row.exec_policy === "linux_shell" || row.exec_policy === "unrestricted"
? row.exec_policy
: "readonly",
};
}
export function managedSourceKey(
source: string | undefined,
): "manual" | "ume_sync" | "webcrt" | "lldp" | "topology" | "" {
const s = String(source || "").trim().toLowerCase();
if (!s) return "manual";
if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s;
return "";
}
/** Build API body + hop validation. Throws Error with message key text already translated by caller. */
export function buildManagedNeSaveBody(
form: ManagedNeFormState,
opts: {
editing: boolean;
hopHostRequired: string;
hopUserRequired: string;
hopPasswordRequired: string;
/** When false, omit exec_policy so lab-open cannot be persisted accidentally. */
execPolicyEnabled?: boolean;
},
): Record<string, unknown> {
const body: Record<string, unknown> = {
name: form.name,
vendor: form.vendor,
device_type: form.device_type,
ip_address: form.ip_address,
port: form.port,
protocol: form.protocol,
username: form.username,
tags: form.tags,
remark: form.remark,
hop_enabled: form.hop_enabled,
hop_vendor: form.hop_vendor,
hop_host: form.hop_host,
hop_port: form.hop_port,
hop_protocol: form.hop_protocol,
hop_username: form.hop_username,
hop_command_template: form.hop_command_template,
hop_vrf: form.hop_vrf,
hop_target_auth_mode: form.hop_target_auth_mode,
hop_enter_system_view: form.hop_enter_system_view,
...(form.password ? { password: form.password } : {}),
...(form.hop_password ? { hop_password: form.hop_password } : {}),
};
if (opts.execPolicyEnabled) {
body.exec_policy = allowsOpenExecPolicy(form.device_type) ? form.exec_policy : "readonly";
}
if (form.hop_enabled) {
if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired);
if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired);
if (!opts.editing && !form.hop_password) throw new Error(opts.hopPasswordRequired);
}
if (opts.editing) {
if (!form.password) delete body.password;
if (!form.hop_password) delete body.hop_password;
} else {
body.password = form.password || "";
}
return body;
}

View file

@ -0,0 +1,165 @@
/** Jump-host (hop) templates per vendor. */
import { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate } from "./zteHop";
export type HopVendor = "zte" | "huawei" | "cisco" | "linux" | "bastion";
export type HopTargetAuthMode = "bastion_managed" | "manual";
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux", "bastion"];
export function bastionHopTemplate(): string {
return "{hop_user}@{target_user}@{target_ip}";
}
export const LEGACY_BASTION_HOP_TEMPLATE = "{hop_user}@{target_user}@{target_ip}@{hop_host}";
export function isBastionHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "bastion";
}
export function isLinuxHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "linux";
}
export function isCliHopVendor(vendor: string): boolean {
const v = String(vendor || "").toLowerCase();
return v === "zte" || v === "huawei" || v === "cisco";
}
/** Strip ssh:// / trailing slash / :port from bastion host (IP or FQDN). */
export function normalizeHopHost(value: string): string {
let host = String(value || "").trim();
if (!host) return "";
host = host.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
if (host.includes("://")) host = host.split("://", 2)[1] || host;
host = host.trim().replace(/\/+$/, "");
if (host.startsWith("[") && host.includes("]")) {
const inside = host.slice(1, host.indexOf("]"));
const rest = host.slice(host.indexOf("]") + 1);
if (!rest || rest.startsWith(":")) return inside.trim();
return host;
}
if ((host.match(/:/g) || []).length === 1) {
const [left, right] = host.split(":");
if (/^\d+$/.test(right || "") && left && !left.includes("@")) return left.trim();
}
return host;
}
/**
* Parse OpenSSH-style bastion destination.
* Example: bastion-user@target-user@198.51.100.20@ssh-bastion.example.com
*/
export function parseBastionSshDestination(value: string): {
hop_host: string;
hop_username: string;
target_user: string;
target_ip: string;
ssh_username: string;
} {
let raw = String(value || "").trim().replace(/^["']|["']$/g, "");
raw = raw.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
if (!raw) {
return { hop_host: "", hop_username: "", target_user: "", target_ip: "", ssh_username: "" };
}
if (!raw.includes("@")) {
return {
hop_host: normalizeHopHost(raw),
hop_username: "",
target_user: "",
target_ip: "",
ssh_username: "",
};
}
const at = raw.lastIndexOf("@");
const userPart = raw.slice(0, at).trim();
const hopHost = normalizeHopHost(raw.slice(at + 1));
const parts = userPart.split("@").filter(Boolean);
return {
hop_host: hopHost,
hop_username: parts[0] || "",
target_user: parts[1] || "",
target_ip: parts[2] || "",
ssh_username: userPart,
};
}
/** If hop_host is a pasted user@…@bastion string, split into host + hop username. */
export function expandBastionHopFields(
hopHost: string,
hopUsername = "",
): {
hop_host: string;
hop_username: string;
target_user: string;
target_ip: string;
} {
const curUser = String(hopUsername || "").trim();
const raw = String(hopHost || "").trim();
if (!raw.includes("@")) {
return { hop_host: normalizeHopHost(raw), hop_username: curUser, target_user: "", target_ip: "" };
}
const parsed = parseBastionSshDestination(raw);
return {
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || curUser,
target_user: parsed.target_user,
target_ip: parsed.target_ip,
};
}
export function defaultHopTemplate(vendor: string, protocol: string, vrf: string): string {
const v = String(vendor || "zte").toLowerCase();
if (v === "huawei") return huaweiHopTemplate(protocol, vrf);
if (v === "cisco") return ciscoHopTemplate(protocol, vrf);
if (v === "linux") return "";
if (v === "bastion") return bastionHopTemplate();
return zteHopTemplate(protocol, vrf);
}
export function patchHopVendorChange(
vendor: HopVendor,
prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string; hop_vendor?: string },
): {
hop_vendor: HopVendor;
hop_protocol: string;
hop_vrf: string;
hop_command_template: string;
hop_port?: number;
hop_target_auth_mode?: HopTargetAuthMode;
hop_enter_system_view?: boolean;
} {
if (vendor === "linux") {
return {
hop_vendor: "linux",
hop_protocol: "ssh",
hop_vrf: "",
hop_command_template: "",
hop_enter_system_view: false,
};
}
if (vendor === "bastion") {
return {
hop_vendor: "bastion",
hop_protocol: "ssh",
hop_port: 22,
hop_vrf: "",
hop_command_template: bastionHopTemplate(),
hop_target_auth_mode: "bastion_managed" as HopTargetAuthMode,
hop_enter_system_view: false,
};
}
const protocol = prev.hop_protocol || "ssh";
const vrf = prev.hop_vrf || "";
return {
hop_vendor: vendor,
hop_protocol: protocol,
hop_vrf: vrf,
hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
// Explicit Huawei option only; reset when leaving/entering other CLI hop vendors.
hop_enter_system_view: false,
};
}
export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate };

View file

@ -0,0 +1,52 @@
/** Vendor CLI jump command templates (placeholders). */
const LEGACY_HOP_TEMPLATES = new Set([
"ssh {target_user}@{target_ip}",
"ssh {target_ip}",
"telnet {target_ip}",
]);
export function zteHopTemplate(protocol: string, vrf: string): string {
const cmd = String(protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh";
const v = String(vrf || "").trim();
return v ? `${cmd} {target_ip} vrf {vrf}` : `${cmd} {target_ip}`;
}
/** Cisco: ssh -vrf VRF IP; telnet IP [/vrf VRF]. */
export function ciscoHopTemplate(protocol: string, vrf: string): string {
const v = String(vrf || "").trim();
if (String(protocol || "ssh").toLowerCase() === "telnet") {
return v ? `telnet {target_ip} /vrf {vrf}` : `telnet {target_ip}`;
}
return v ? `ssh -vrf {vrf} {target_ip}` : `ssh {target_ip}`;
}
/** Huawei: telnet [vpn-instance VRF] IP; SSH uses stelnet. */
export function huaweiHopTemplate(protocol: string, vrf: string): string {
const v = String(vrf || "").trim();
if (String(protocol || "ssh").toLowerCase() === "telnet") {
return v ? `telnet vpn-instance {vrf} {target_ip}` : `telnet {target_ip}`;
}
return v ? `stelnet {target_ip} -vpn-instance {vrf}` : `stelnet {target_ip}`;
}
export function isAutoHopTemplate(
template: string,
vendor: string,
protocol: string,
vrf: string,
): boolean {
const t = String(template || "").trim();
if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true;
const v = String(vendor || "zte").toLowerCase();
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
if (v === "linux") return t === "";
if (v === "bastion") {
return (
t === "{hop_user}@{target_user}@{target_ip}"
|| t === "{hop_user}@{target_user}@{target_ip}@{hop_host}"
);
}
return t === zteHopTemplate(protocol, vrf);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,278 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { fetchUmeCurrentAlarms, formatErr } from "../../services/api";
import type { UmeAlarmItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import { NmStatusChip, severityChipColor } from "../../components/NmChips";
/** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [searchParams] = useSearchParams();
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
useEffect(() => {
const host = searchParams.get("host");
const cleared = searchParams.get("cleared");
const severity = searchParams.get("severity");
const keyword = searchParams.get("keyword");
let touched = false;
if (host != null) {
setCurHostName(host);
touched = true;
}
if (cleared != null) {
setCurCleared(cleared);
touched = true;
}
if (severity != null) {
setCurSeverity(severity);
touched = true;
}
if (keyword != null) {
setCurKeyword(keyword);
touched = true;
}
if (touched) setCurPage(1);
}, [searchParams]);
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
const debouncedHost = useDebouncedValue(curHostName, 300);
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(
curSeverity,
curCleared,
debouncedHost,
debouncedKeyword,
curPage,
curPageSize,
),
queryFn: () =>
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
hostName: debouncedHost,
keyword: debouncedKeyword,
page: curPage,
pageSize: curPageSize,
}),
staleTime: 5000,
});
const items = currentQuery.data?.items || [];
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
const exportCsv = async () => {
setExporting(true);
try {
const rows = await fetchAllPages<UmeAlarmItem>({
pageSize: 200,
maxRows: 2000,
fetchPage: (page, pageSize) =>
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
hostName: debouncedHost,
keyword: debouncedKeyword,
page,
pageSize,
}),
});
downloadCsv(`ume-alarms-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
{ key: "time_created", header: t("ume.alarms.col.time"), value: (r) => formatSystemTime(r.time_created) },
{ key: "perceived_severity", header: t("ume.alarms.col.severity") },
{ key: "ne_id", header: t("ume.alarms.col.neId") },
{ key: "host_name", header: t("ume.alarms.col.hostName") },
{ key: "ne_type", header: t("ume.alarms.col.neType") },
{ key: "native_probable_cause", header: t("ume.alarms.col.cause") },
{ key: "is_cleared", header: "is_cleared" },
{ key: "alarm_key", header: "alarm_key" },
]);
if (rows.length < curTotal) {
showOk(
t("common.exportTruncated", { count: String(rows.length), total: String(curTotal) }),
);
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExporting(false);
}
};
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={exporting || curTotal === 0}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</Button>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<Input
value={curKeyword}
placeholder={t("ume.alarms.keywordPh")}
onChange={(e) => {
setCurKeyword(e.target.value);
setCurPage(1);
}}
/>
<Input
value={curHostName}
placeholder={t("ume.alarms.hostNamePh")}
onChange={(e) => {
setCurHostName(e.target.value);
setCurPage(1);
}}
/>
<FieldSelect
value={curSeverity}
onChange={(e) => {
setCurSeverity(e.target.value);
setCurPage(1);
}}
>
<option value="">{t("ume.alarms.allSeverity")}</option>
<option value="critical">critical</option>
<option value="major">major</option>
<option value="minor">minor</option>
<option value="warning">warning</option>
<option value="info">info</option>
</FieldSelect>
<FieldSelect
value={curCleared}
onChange={(e) => {
setCurCleared(e.target.value);
setCurPage(1);
}}
>
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="true">{t("ume.alarms.clearedYes")}</option>
</FieldSelect>
<Button
size="sm"
variant="tertiary"
aria-label={t("ume.alarms.clearTitle")}
onPress={() => {
setCurKeyword("");
setCurHostName("");
setCurSeverity("");
setCurCleared("");
setCurPage(1);
}}
isDisabled={!hasFilters}
>
{t("common.clearFilters")}
</Button>
</div>
{currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{currentQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !currentQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("ume.alarms.col.time")}</th>
<th>{t("ume.alarms.col.severity")}</th>
<th>{t("ume.alarms.col.neId")}</th>
<th>{t("ume.alarms.col.hostName")}</th>
<th>{t("ume.alarms.col.neType")}</th>
<th>{t("ume.alarms.col.cause")}</th>
</tr>
</thead>
<tbody>
{items.map((x) => (
<tr key={x.alarm_key}>
<td className="pt-list-time">{formatSystemTime(x.time_created)}</td>
<td>
<NmStatusChip color={severityChipColor(x.perceived_severity)}>
{x.perceived_severity || "—"}
</NmStatusChip>
</td>
<td className="pt-list-num">{x.ne_id}</td>
<td>
{(x.host_name || "").trim() ? (
<Button
size="sm"
variant="ghost"
className="link-btn pt-list-task-name"
onPress={() => {
setCurHostName(x.host_name || "");
setCurKeyword("");
setCurPage(1);
}}
aria-label={t("ume.alarms.filterByHost")}
>
{x.host_name}
</Button>
) : (
<span className="muted" title={t("ume.alarms.noHostName")}>
{t("common.empty")}
</span>
)}
</td>
<td>{x.ne_type ?? ""}</td>
<td>{x.native_probable_cause}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<ListPager
page={curPage}
pages={curPages}
total={curTotal}
pageSize={curPageSize}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setCurPage}
onPageSizeChange={(size) => {
setCurPageSize(size);
setCurPage(1);
}}
disabled={currentQuery.isLoading}
/>
</div>
</section>
);
}

View file

@ -0,0 +1,395 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
downloadNeConfigSnapshot,
fetchNeConfigSnapshotDetail,
fetchNeConfigSnapshots,
formatErr,
} from "../../services/api";
import type { NeConfigSnapshotMeta } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
import { formatSystemTime } from "../../utils/time";
import { NmStatusChip, sourceChipColor } from "../../components/NmChips";
function fmtBytes(n: number): string {
if (!n) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
export function NetworkConfigsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState("");
const [vendor, setVendor] = useState("");
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
const [tab, setTab] = useState<"primary" | "alt">("primary");
const [exporting, setExporting] = useState("");
const [exportingList, setExportingList] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const debouncedVendor = useDebouncedValue(vendor, 300);
useEffect(() => {
const q = String(searchParams.get("q") || "").trim();
if (!q) return;
setKeyword(q);
setPage(1);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("q") === q) next.delete("q");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
const listQuery = useQuery({
queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
queryFn: () =>
fetchNeConfigSnapshots({
page,
pageSize,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
}),
staleTime: 5000,
});
const detailQuery = useQuery({
queryKey: queryKeys.networkConfigDetail(selected?.source || "", selected?.id || ""),
queryFn: () => fetchNeConfigSnapshotDetail(selected!.source, selected!.id, "both"),
enabled: Boolean(selected),
staleTime: 10000,
});
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
const pages = pageCount(total, pageSize);
const detail = detailQuery.data;
const showAlt = Boolean(detail?.has_alt);
const hasFilters = Boolean(keyword || source || vendor);
const exportConfig = async (
src: string,
id: string,
field: "primary" | "alt" | "both",
) => {
const key = `${src}:${id}:${field}`;
setExporting(key);
try {
await downloadNeConfigSnapshot(src, id, field);
showOk(t("networkConfigs.exportOk"));
} catch (err) {
showError(formatErr(err));
} finally {
setExporting("");
}
};
const exportListCsv = async () => {
setExportingList(true);
try {
const rows = await fetchAllPages<NeConfigSnapshotMeta>({
pageSize: 100,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchNeConfigSnapshots({
page: p,
pageSize: ps,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
}),
});
downloadCsv(
`${t("networkConfigs.exportListName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "ne_name", header: t("networkConfigs.col.name"), value: (r) => r.ne_name || r.target_id },
{ key: "ne_ip", header: "IP" },
{ key: "vendor", header: t("networkConfigs.col.vendor") },
{ key: "source", header: t("networkConfigs.col.source") },
{ key: "plain_size", header: t("networkConfigs.col.size"), value: (r) => fmtBytes(r.plain_size) },
{
key: "collected_at",
header: t("networkConfigs.col.collected"),
value: (r) => (r.collected_at ? formatSystemTime(r.collected_at) : ""),
},
{ key: "target_id", header: "ID" },
],
);
if (rows.length < total) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingList(false);
}
};
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={exportingList || total === 0}
onPress={() => void exportListCsv()}
>
{exportingList ? t("common.exporting") : t("common.exportCsv")}
</Button>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<Input
value={keyword}
placeholder={t("networkConfigs.keywordPh")}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<FieldSelect
value={source}
onChange={(e) => {
setSource(e.target.value);
setPage(1);
}}
>
<option value="">{t("networkConfigs.allSource")}</option>
<option value="managed">managed</option>
<option value="ume">ume</option>
</FieldSelect>
<Input
value={vendor}
placeholder={t("networkConfigs.vendorPh")}
onChange={(e) => {
setVendor(e.target.value);
setPage(1);
}}
/>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setKeyword("");
setSource("");
setVendor("");
setPage(1);
}}
>
{t("common.clearFilters")}
</Button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("networkConfigs.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("networkConfigs.col.name")}</th>
<th>IP</th>
<th>{t("networkConfigs.col.vendor")}</th>
<th>{t("networkConfigs.col.source")}</th>
<th>{t("networkConfigs.col.size")}</th>
<th>{t("networkConfigs.col.collected")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => {
const exportKey = `${row.source}:${row.target_id}:list`;
return (
<tr key={`${row.source}:${row.target_id}`}>
<td className="pt-list-task-name">{row.ne_name || row.target_id}</td>
<td className="pt-list-num">{row.ne_ip}</td>
<td>{row.vendor || "—"}</td>
<td>
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
</td>
<td className="pt-list-num">{fmtBytes(row.plain_size)}</td>
<td className="pt-list-time">
{row.collected_at ? formatSystemTime(row.collected_at) : "—"}
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button
size="sm"
variant="ghost"
onPress={() => {
setSelected({ source: row.source, id: row.target_id });
setTab("primary");
}}
>
{t("networkConfigs.view")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={
exporting === exportKey ||
exporting.startsWith(`${row.source}:${row.target_id}:`)
}
onPress={() =>
void exportConfig(
row.source,
row.target_id,
row.has_alt ? "both" : "primary",
)
}
>
{t("networkConfigs.export")}
</Button>
<Button
size="sm"
variant="ghost"
onPress={() => {
const path =
row.source === "ume"
? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume`
: `/webcrt?ne_id=${encodeURIComponent(row.target_id)}`;
openNewModuleWindow({ moduleId: "webcrt", path });
}}
>
WebCRT
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</div>
<AppModalShell
open={Boolean(selected)}
onClose={() => setSelected(null)}
size="lg"
className="nm-config-modal"
>
<Modal.Header>
<Modal.Heading>{detail?.ne_name || selected?.id || t("networkConfigs.view")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{selected ? (
<p className="muted">
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
</p>
) : null}
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{showAlt ? (
<div className="btn-row nm-config-modal__tabs">
<Button
size="sm"
variant={tab === "primary" ? "primary" : "secondary"}
onPress={() => setTab("primary")}
>
{t("networkConfigs.tabSet")}
</Button>
<Button
size="sm"
variant={tab === "alt" ? "primary" : "secondary"}
onPress={() => setTab("alt")}
>
{t("networkConfigs.tabHier")}
</Button>
</div>
) : null}
<textarea
className="nm-config-modal__body"
readOnly
spellCheck={false}
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
/>
<p className="muted nm-config-modal__meta">
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
</p>
</Modal.Body>
<Modal.Footer>
{selected ? (
<>
<Button
variant="secondary"
isDisabled={Boolean(exporting)}
onPress={() =>
void exportConfig(
selected.source,
selected.id,
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
)
}
>
{t("networkConfigs.export")}
</Button>
{showAlt ? (
<Button
variant="secondary"
isDisabled={Boolean(exporting)}
onPress={() => void exportConfig(selected.source, selected.id, "both")}
>
{t("networkConfigs.exportBoth")}
</Button>
) : null}
</>
) : null}
<Button variant="tertiary" onPress={() => setSelected(null)}>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}

View file

@ -0,0 +1,211 @@
import { Button, Input } from "@heroui/react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { fetchCliTargets, formatErr } from "../../services/api";
import type { CliTargetItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
import { connectChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips";
function openWebcrt(row: CliTargetItem) {
const path =
row.source === "ume"
? `/webcrt?ne_id=${encodeURIComponent(row.id)}&source=ume`
: `/webcrt?ne_id=${encodeURIComponent(row.id)}`;
openNewModuleWindow({ moduleId: "webcrt", path });
}
/** Inventory of managed + UME NEs for Network Management. */
export function NetworkDevicesPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const listQuery = useQuery({
queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
staleTime: 5000,
});
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
const pages = pageCount(total, pageSize);
const hasFilters = Boolean(keyword.trim() || source !== "all");
const exportCsv = async () => {
setExporting(true);
try {
const rows = await fetchAllPages<CliTargetItem>({
pageSize: 200,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
});
downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
{ key: "source", header: t("networkDevices.col.source") },
{ key: "name", header: t("networkDevices.col.name"), value: (r) => r.name || r.id },
{ key: "ip_address", header: "IP" },
{ key: "vendor", header: t("networkDevices.col.vendor") },
{
key: "device_type",
header: t("networkDevices.col.deviceType"),
value: (r) => r.device_type || r.ne_type || "",
},
{ key: "connect_status", header: t("networkDevices.col.connect") },
{ key: "id", header: "ID" },
]);
if (rows.length < total) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExporting(false);
}
};
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2>
<div className="btn-row">
<Button
size="sm"
variant="secondary"
isDisabled={exporting || total === 0}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</Button>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<Input
value={keyword}
placeholder={t("networkDevices.keywordPh")}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<FieldSelect
value={source}
onChange={(e) => {
setSource(e.target.value as "all" | "managed" | "ume");
setPage(1);
}}
>
<option value="all">{t("networkDevices.allSource")}</option>
<option value="managed">managed</option>
<option value="ume">ume</option>
</FieldSelect>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setKeyword("");
setSource("all");
setPage(1);
}}
>
{t("common.clearFilters")}
</Button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("networkDevices.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("networkDevices.col.source")}</th>
<th>{t("networkDevices.col.name")}</th>
<th>IP</th>
<th>{t("networkDevices.col.vendor")}</th>
<th>{t("networkDevices.col.deviceType")}</th>
<th>{t("networkDevices.col.connect")}</th>
<th>{t("networkDevices.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={`${row.source}:${row.id}`}>
<td>
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
</td>
<td className="pt-list-task-name">{row.name || row.id}</td>
<td className="pt-list-num">{row.ip_address || "—"}</td>
<td>{row.vendor || "—"}</td>
<td>{row.device_type || row.ne_type || "—"}</td>
<td>
<NmStatusChip color={connectChipColor(row.connect_status)}>
{row.connect_status || "—"}
</NmStatusChip>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
WebCRT
</Button>
<Button
size="sm"
variant="ghost"
onPress={() =>
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
})
}
>
{t("network.nav.configs")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</div>
</section>
);
}

View file

@ -0,0 +1,128 @@
import { Accordion } from "@heroui/react";
import type { Key } from "@react-types/shared";
import { useEffect, useMemo, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
import { useI18n } from "../../i18n";
import { prefetchCutoverPages } from "../cutover/lib/cutoverPrefetch";
function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean {
const group = NETWORK_NAV.find((g) => g.id === groupId);
if (!group) return false;
return group.items.some(
(item) => pathname === item.path || pathname.startsWith(`${item.path}/`),
);
}
function groupsForPath(pathname: string): Set<Key> {
const keys = new Set<Key>();
for (const g of NETWORK_NAV) {
if (groupContainsPath(g.id, pathname)) keys.add(g.id);
}
return keys;
}
export function NetworkLayout() {
const { t } = useI18n();
const { pathname } = useLocation();
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(() => groupsForPath(pathname));
useEffect(() => {
setExpandedKeys((prev) => {
const routeKeys = groupsForPath(pathname);
if ([...routeKeys].every((k) => prev.has(k))) return prev;
const next = new Set(prev);
for (const k of routeKeys) next.add(k);
return next;
});
}, [pathname]);
// Warm cutover page chunks as soon as that nav group is open / in use.
useEffect(() => {
if (pathname.startsWith("/network/cutover") || expandedKeys.has("cutoverCompare")) {
prefetchCutoverPages();
}
}, [pathname, expandedKeys]);
const activeGroupIds = useMemo(() => {
const ids = new Set<NetworkNavGroupId>();
for (const g of NETWORK_NAV) {
if (groupContainsPath(g.id, pathname)) ids.add(g.id);
}
return ids;
}, [pathname]);
return (
<div className="network-shell">
<aside className="network-nav" aria-label={t("network.title")}>
<nav className="network-nav__scroll">
<Accordion
className="network-nav__accordion"
hideSeparator
allowsMultipleExpanded
expandedKeys={expandedKeys}
onExpandedChange={(keys) => {
setExpandedKeys(keys as Set<Key>);
if ((keys as Set<Key>).has("cutoverCompare")) {
prefetchCutoverPages();
}
}}
>
{NETWORK_NAV.map((group) => {
const activeGroup = activeGroupIds.has(group.id);
return (
<Accordion.Item
key={group.id}
id={group.id}
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
>
<Accordion.Heading>
<Accordion.Trigger
className="network-nav__group-toggle"
onHoverStart={
group.id === "cutoverCompare" ? () => prefetchCutoverPages() : undefined
}
>
<span className="network-nav__group-label">{t(group.labelKey)}</span>
<Accordion.Indicator className="network-nav__chevron" />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body className="network-nav__panel-body">
<ul className="network-nav__list">
{group.items.map((item) => (
<li key={item.id}>
<NavLink
to={item.path}
className={({ isActive }) =>
`network-nav__link${isActive ? " is-active" : ""}`
}
end={
item.path === "/network/devices" ||
item.path === "/network/tasks/port-traffic"
}
onMouseEnter={
group.id === "cutoverCompare"
? () => prefetchCutoverPages()
: undefined
}
>
{t(item.labelKey)}
</NavLink>
</li>
))}
</ul>
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</nav>
</aside>
<div className="network-main">
<Outlet />
</div>
</div>
);
}

View file

@ -0,0 +1,13 @@
type Props = {
kind?: string;
};
/** Generic placeholder for unfinished network sub-pages. */
export function NetworkPlaceholderPage(_props: Props) {
return (
<section className="panel network-placeholder">
<h2>Coming soon</h2>
<p className="muted">This capability is not available yet.</p>
</section>
);
}

View file

@ -0,0 +1,592 @@
import { Button, Input } from "@heroui/react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
bulkTagFabricNodes,
deleteFabricNode,
deleteFabricNodes,
fetchFabricNodes,
fetchTopologyTree,
matchFabricNodes,
patchFabricNodeTags,
} from "../../services/api";
import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { pageCount } from "../../utils/display";
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
if (!root) return [];
return (root.children || []).filter((c) => String(c.kind) === "region");
}
/** Prefer API flag; fall back so UI still works if backend is stale. */
function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
if (typeof n.deletable === "boolean") return n.deletable;
const status = String(n.link_status || "").toLowerCase();
if (status === "ume" || status === "both") return false;
if (status === "orphaned") return true;
if (!String(n.managed_ne_id || "").trim()) {
return !String(n.ume_ne_id || "").trim();
}
if (n.managed_alive === false) return true;
const src = String(n.managed_source || "").toLowerCase();
return src === "lldp" || src === "topology" || src === "webcrt";
}
const LEVEL_PRESETS = [
{ value: "0", labelKey: "topoClassify.levelExternal" },
{ value: "1", labelKey: "topoClassify.levelCore" },
{ value: "1.1", labelKey: "topoClassify.levelCoreSub" },
{ value: "2", labelKey: "topoClassify.levelAgg" },
{ value: "2.1", labelKey: "topoClassify.levelAggSub" },
{ value: "3", labelKey: "topoClassify.levelAccess" },
{ value: "3.1", labelKey: "topoClassify.levelAccessSub" },
] as const;
const FILTER_MAJOR = [
{ value: "", labelKey: "topoClassify.filterLevelAll" },
{ value: "0", labelKey: "topoClassify.levelExternal" },
{ value: "1", labelKey: "topoClassify.levelCore" },
{ value: "2", labelKey: "topoClassify.levelAgg" },
{ value: "3", labelKey: "topoClassify.levelAccess" },
] as const;
function formatLevel(level: number | null | undefined): string {
if (level === null || level === undefined || Number.isNaN(Number(level))) return "—";
const n = Number(level);
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return String(Math.round(n * 10) / 10);
}
function parseLevelInput(raw: string): number | null {
const s = String(raw || "").trim();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n < 0 || n > 99.9) throw new Error("level_invalid");
return Math.round(n * 10) / 10;
}
function levelPresetLabel(t: (k: string) => string, value: string): string {
const hit = LEVEL_PRESETS.find((p) => p.value === value);
return hit ? t(hit.labelKey) : value;
}
function managedSourceLabel(t: (k: string) => string, source: string): string {
const s = String(source || "").trim().toLowerCase();
if (s === "manual") return t("topoClassify.sourceManual");
if (s === "ume_sync") return t("topoClassify.sourceUmeSync");
if (s === "lldp") return t("topoClassify.sourceLldp");
if (s === "topology") return t("topoClassify.sourceTopology");
if (s === "webcrt") return t("topoClassify.sourceWebcrt");
return source;
}
export function TopologyClassifyPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState("");
const [filterMajor, setFilterMajor] = useState("");
const [filterRegion, setFilterRegion] = useState("");
const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const linkLabel = (status: string | undefined) => {
switch (String(status || "").toLowerCase()) {
case "managed":
return t("topoClassify.linkManaged");
case "ume":
return t("topoClassify.linkUme");
case "both":
return t("topoClassify.linkBoth");
case "orphaned":
return t("topoClassify.linkOrphaned");
default:
return status || "—";
}
};
const [regex, setRegex] = useState("");
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
const [matchedIds, setMatchedIds] = useState<string[]>([]);
const [matchTotal, setMatchTotal] = useState(0);
const [assignLevel, setAssignLevel] = useState("1");
const [assignRegion, setAssignRegion] = useState("");
const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level");
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [editingId, setEditingId] = useState("");
const [editLevel, setEditLevel] = useState("");
const [editRegion, setEditRegion] = useState("");
const treeQuery = useQuery({
queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree,
});
const regions = useMemo(() => flattenRegions(treeQuery.data?.root), [treeQuery.data?.root]);
const regionName = useMemo(() => {
const m: Record<string, string> = {};
for (const r of regions) m[r.id] = r.name;
return m;
}, [regions]);
const listQuery = useQuery({
queryKey: queryKeys.fabricNodeInventory(
keyword,
"",
filterRegion,
unmatched,
linkStatus,
page,
filterMajor,
pageSize,
),
queryFn: () =>
fetchFabricNodes({
keyword,
levelMajor: filterMajor || undefined,
regionFolderId: filterRegion,
unmatched,
linkStatus,
page,
pageSize,
}),
});
const invalidateList = async () => {
await queryClient.invalidateQueries({ queryKey: ["fabricNodeInventory"] });
};
const matchMut = useMutation({
mutationFn: () =>
matchFabricNodes({ pattern: regex, match_field: matchField, sample_limit: 80 }),
onSuccess: (out) => {
setMatchedIds(out.fabric_node_ids || []);
setMatchTotal(out.total_matched || 0);
const next: Record<string, boolean> = {};
for (const id of out.fabric_node_ids || []) next[id] = true;
setSelected(next);
showOk(t("topoClassify.matchOk").replace("{{count}}", String(out.total_matched)));
},
onError: (err) => showError(String(err)),
});
const bulkMut = useMutation({
mutationFn: () => {
const ids = Object.keys(selected).filter((k) => selected[k]);
const body: Parameters<typeof bulkTagFabricNodes>[0] = {
fabric_node_ids: ids.length ? ids : matchedIds,
dry_run: false,
};
if (assignWhat === "level" || assignWhat === "both") {
body.level = parseLevelInput(assignLevel);
}
if (assignWhat === "region" || assignWhat === "both") {
body.region_folder_id = assignRegion || "";
}
return bulkTagFabricNodes(body);
},
onSuccess: async (out) => {
showOk(t("topoClassify.bulkOk").replace("{{count}}", String(out.updated)));
setMatchedIds([]);
setMatchTotal(0);
setSelected({});
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const patchMut = useMutation({
mutationFn: () => {
let level: number | null;
try {
level = parseLevelInput(editLevel);
} catch {
throw new Error(t("topoClassify.levelInvalid"));
}
return patchFabricNodeTags(editingId, {
level,
region_folder_id: editRegion || "",
});
},
onSuccess: async () => {
showOk(t("topoClassify.rowSaved"));
setEditingId("");
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const deleteOneMut = useMutation({
mutationFn: (id: string) => deleteFabricNode(id),
onSuccess: async (out) => {
showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted)));
setSelected({});
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const deleteBulkMut = useMutation({
mutationFn: (ids: string[]) => deleteFabricNodes(ids),
onSuccess: async (out) => {
showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted)));
setSelected({});
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const items = listQuery.data?.items || [];
const deletableSelected = items
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
.map((n) => n.id);
const total = listQuery.data?.total || 0;
const pages = pageCount(total, pageSize);
const selectedCount = Object.values(selected).filter(Boolean).length;
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
const startEdit = (n: FabricNodeSearchHit) => {
setEditingId(n.id);
setEditLevel(n.level == null ? "" : formatLevel(n.level));
setEditRegion(n.region_folder_id || "");
};
const toggleRow = (id: string) => {
setSelected((s) => ({ ...s, [id]: !s[id] }));
};
const togglePage = (on: boolean) => {
setSelected((s) => {
const next = { ...s };
for (const n of items) next[n.id] = on;
return next;
});
};
return (
<div className="panel topo-classify nm-page-panel">
<div className="panel__toolbar">
<h2>{t("topoClassify.title")}</h2>
<div className="btn-row">
<Button
variant="primary"
onPress={() => {
const regionId = filterRegion || assignRegion || "";
const folder = regions.find((r) => r.id === regionId);
const views = folder?.views || [];
const primary =
views.find((v) => String(v.kind || "") === "physical") || views[0];
const path = primary?.id
? `/topology?view=${encodeURIComponent(primary.id)}`
: "/topology";
openOrFocusModule({ moduleId: "topology", path });
}}
>
{t("topoClassify.openTopo")}
</Button>
</div>
</div>
<section className="topo-classify__section topo-classify__section--first">
<h3>{t("topoClassify.inventory")}</h3>
<div className="topo-classify__draft">
<Input
placeholder={t("topoClassify.keywordPh")}
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<select className="input"
value={filterMajor}
onChange={(e) => {
setFilterMajor(e.target.value);
setPage(1);
}}
>
{FILTER_MAJOR.map((r) => (
<option key={r.value || "all"} value={r.value}>
{t(r.labelKey)}
</option>
))}
</select>
<select className="input"
value={filterRegion}
onChange={(e) => {
setFilterRegion(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterRegionAll")}</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<select className="input"
value={unmatched}
onChange={(e) => {
setUnmatched(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
<option value="any">{t("topoClassify.kindAny")}</option>
<option value="level">{t("topoClassify.kindLevel")}</option>
<option value="region">{t("topoClassify.kindRegion")}</option>
</select>
<select className="input"
value={linkStatus}
onChange={(e) => {
setLinkStatus(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterLinkAll")}</option>
<option value="linked">{t("topoClassify.filterLinkLinked")}</option>
<option value="orphaned">{t("topoClassify.filterLinkOrphaned")}</option>
</select>
</div>
<div className="topo-classify__regex-bar">
<strong>{t("topoClassify.regexTitle")}</strong>
<Input
className="topo-classify__regex-input"
placeholder={t("topoClassify.patternPh")}
value={regex}
onChange={(e) => setRegex(e.target.value)}
/>
<select className="input"
value={matchField}
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
>
<option value="name">{t("topoClassify.fieldName")}</option>
<option value="ip">IP</option>
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
</select>
<Button
size="sm"
variant="secondary"
isDisabled={!regex.trim() || matchMut.isPending}
onPress={() => matchMut.mutate()}
>
{t("topoClassify.findMatches")}
</Button>
{matchTotal > 0 ? (
<span className="panel__hint">
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
</span>
) : null}
</div>
<div className="topo-classify__assign-bar">
<select className="input"
value={assignWhat}
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
>
<option value="level">{t("topoClassify.assignLevel")}</option>
<option value="region">{t("topoClassify.assignRegion")}</option>
<option value="both">{t("topoClassify.assignBoth")}</option>
</select>
{assignWhat !== "region" ? (
<select className="input" value={assignLevel} onChange={(e) => setAssignLevel(e.target.value)}>
{LEVEL_PRESETS.map((r) => (
<option key={r.value} value={r.value}>
{levelPresetLabel(t, r.value)}
</option>
))}
</select>
) : null}
{assignWhat !== "level" ? (
<select className="input" value={assignRegion} onChange={(e) => setAssignRegion(e.target.value)}>
<option value="">{t("topoClassify.clearRegion")}</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
) : null}
<Button
size="sm"
variant="secondary"
isDisabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
onPress={() => {
const n = selectedCount || matchedIds.length;
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
if (window.confirm(msg)) bulkMut.mutate();
}}
>
{t("topoClassify.confirmAssign")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteBulkMut.isPending || deletableSelected.length === 0}
onPress={() => {
const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}",
String(deletableSelected.length),
);
if (window.confirm(msg)) deleteBulkMut.mutate(deletableSelected);
}}
>
{t("topoClassify.deleteSelected").replace(
"{{count}}",
String(deletableSelected.length),
)}
</Button>
<span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}>
?
<span className="topo-classify__help-tip" role="tooltip">
{t("topoClassify.deleteHint")}
</span>
</span>
</div>
<table className="data-table topo-classify__table">
<thead>
<tr>
<th>
<input
type="checkbox"
checked={items.length > 0 && items.every((n) => selected[n.id])}
onChange={(e) => togglePage(e.target.checked)}
aria-label={t("topoClassify.selectPage")}
/>
</th>
<th>{t("topoClassify.colNe")}</th>
<th>IP</th>
<th>{t("topoClassify.colLink")}</th>
<th>{t("topoClassify.colLevel")}</th>
<th>{t("topoClassify.colRegion")}</th>
<th>{t("topoClassify.colActions")}</th>
</tr>
</thead>
<tbody>
{items.map((n) => {
const editing = editingId === n.id;
return (
<tr
key={n.id}
className={highlight.has(n.id) || selected[n.id] ? "is-match" : undefined}
>
<td>
<input
type="checkbox"
checked={Boolean(selected[n.id])}
onChange={() => toggleRow(n.id)}
/>
</td>
<td>{n.name || n.id}</td>
<td>{n.ip}</td>
<td>
{linkLabel(n.link_status)}
{n.managed_source ? ` · ${managedSourceLabel(t, n.managed_source)}` : ""}
</td>
<td>
{editing ? (
<Input
value={editLevel}
placeholder="1 / 1.1 / 2.1"
onChange={(e) => setEditLevel(e.target.value)}
/>
) : (
formatLevel(n.level)
)}
</td>
<td>
{editing ? (
<select className="input" value={editRegion} onChange={(e) => setEditRegion(e.target.value)}>
<option value="">-</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
) : (
(n.region_folder_id && regionName[n.region_folder_id]) ||
n.region_folder_id ||
"-"
)}
</td>
<td>
{editing ? (
<div className="topo-classify__row-actions">
<Button
size="sm"
variant="secondary"
isDisabled={patchMut.isPending}
onPress={() => patchMut.mutate()}
>
{t("topoClassify.save")}
</Button>
<Button size="sm" variant="ghost" onPress={() => setEditingId("")}>
{t("topoClassify.cancel")}
</Button>
</div>
) : (
<div className="topo-classify__row-actions">
<Button size="sm" variant="ghost" onPress={() => startEdit(n)}>
{t("topoClassify.edit")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
onPress={() => {
if (!isFabricNodeDeletable(n)) return;
const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}",
"1",
);
if (window.confirm(msg)) deleteOneMut.mutate(n.id);
}}
>
{t("topoClassify.delete")}
</Button>
</div>
)}
</td>
</tr>
);
})}
{items.length === 0 ? (
<tr>
<td colSpan={7} className="panel__hint">
{listQuery.isLoading ? t("topoClassify.loading") : t("topoClassify.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</section>
</div>
);
}

View file

@ -0,0 +1,144 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficBoard,
deletePortTrafficBoard,
fetchPortTrafficBoards,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openPortTrafficBoardWindow } from "./lib/portTrafficBoardWindow";
import { formatSystemTime } from "../../utils/time";
export function PortTrafficBoardListPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const boardsQuery = useQuery({
queryKey: queryKeys.portTrafficBoards,
queryFn: fetchPortTrafficBoards,
staleTime: 5000,
});
const createMut = useMutation({
mutationFn: () =>
createPortTrafficBoard({
name: t("portTraffic.boardDefaultName"),
cols: 2,
panels: [],
}),
onSuccess: (created) => {
showOk(t("portTraffic.boardCreated"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
openPortTrafficBoardWindow(created.id);
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: (boardId: string) => deletePortTrafficBoard(boardId),
onSuccess: () => {
showOk(t("portTraffic.boardDeleted"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
},
onError: (e: Error) => showError(e.message),
});
const boards = boardsQuery.data?.items || [];
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row">
<Button
size="sm"
variant="primary"
isDisabled={createMut.isPending}
onPress={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</Button>
</div>
</div>
<div className="pt-list">
{boardsQuery.isLoading ? (
<p className="muted">…</p>
) : boardsQuery.isError ? (
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
) : !boards.length ? (
<div className="pt-list-empty">
<p>{t("portTraffic.boardEmpty")}</p>
<Button
size="sm"
variant="primary"
isDisabled={createMut.isPending}
onPress={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</Button>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.boardName")}</th>
<th>{t("portTraffic.boardPanelCount")}</th>
<th>{t("portTraffic.boardCols")}</th>
<th>{t("portTraffic.boardUpdated")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{boards.map((b) => (
<tr key={b.id}>
<td>
<Button
size="sm"
variant="ghost"
className="link-btn pt-list-task-name"
onPress={() => openPortTrafficBoardWindow(b.id)}
>
{b.name || t("portTraffic.boardDefaultName")}
</Button>
</td>
<td>{b.panel_count}</td>
<td>{b.cols}</td>
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
<td>
<div className="btn-row table-actions">
<Button
size="sm"
variant="ghost"
onPress={() => openPortTrafficBoardWindow(b.id)}
>
{t("portTraffic.boardOpen")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteMut.isPending}
onPress={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
deleteMut.mutate(b.id);
}
}}
>
{t("portTraffic.delete")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,720 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
deletePortTrafficBoard,
fetchPortTrafficBoard,
fetchPortTrafficDevices,
fetchPortTrafficTargets,
putPortTrafficBoardPanels,
updatePortTrafficBoard,
} from "../../services/api";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { PortTrafficBoardPanel, PortTrafficBoardPanelIn } from "../../types";
import { PortTrafficBoardPanelCell } from "./components/PortTrafficBoardPanelCell";
import type { WallYMode } from "./components/PortTrafficWall";
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
function FullscreenIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
function newPanelId() {
return `p_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
function panelToIn(p: PortTrafficBoardPanel): PortTrafficBoardPanelIn {
return {
id: p.id,
title: p.title || "",
target_id: p.target_id,
range_hours: p.range_hours,
baseline: p.baseline || "off",
offset_hours: p.offset_hours || 0,
ahead_hours: p.ahead_hours ?? 1,
baseline_target_id: p.baseline_target_id || "",
y_mode: p.y_mode || "auto",
ord: p.ord,
col_span: p.col_span || 1,
row_span: p.row_span || 1,
};
}
function normalizePanel(p: PortTrafficBoardPanel): PortTrafficBoardPanel {
return { ...p, ahead_hours: p.ahead_hours ?? 1 };
}
/** Dedicated board wall tab at `/port-traffic/wall/:boardId` (outside Network shell). */
export function PortTrafficWallPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const { boardId: boardIdParam = "" } = useParams<{ boardId: string }>();
const boardId = decodeURIComponent(boardIdParam).trim();
const stageRef = useRef<HTMLDivElement | null>(null);
const [editing, setEditing] = useState(false);
const [dirty, setDirty] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [draftPanels, setDraftPanels] = useState<PortTrafficBoardPanel[]>([]);
const [draftName, setDraftName] = useState("");
const [draftCols, setDraftCols] = useState(2);
const [editPanelId, setEditPanelId] = useState("");
const [editPickDeviceId, setEditPickDeviceId] = useState("");
const [editMapBaselineDeviceId, setEditMapBaselineDeviceId] = useState("");
const [addDeviceId, setAddDeviceId] = useState("");
const [addTargetId, setAddTargetId] = useState("");
const [deleted, setDeleted] = useState(false);
const boardQuery = useQuery({
queryKey: queryKeys.portTrafficBoard(boardId),
queryFn: () => fetchPortTrafficBoard(boardId),
enabled: Boolean(boardId) && !deleted,
staleTime: 2000,
});
const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(0),
queryFn: () => fetchPortTrafficDevices({ page: 1, pageSize: 100 }),
enabled: editing,
staleTime: 5000,
});
const addTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(addDeviceId),
queryFn: () => fetchPortTrafficTargets(addDeviceId),
enabled: editing && Boolean(addDeviceId),
staleTime: 2000,
});
const editPanel = draftPanels.find((p) => p.id === editPanelId) || null;
const editTargetsDeviceId = editPickDeviceId || editPanel?.target?.device_id || "";
const editTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(editTargetsDeviceId),
queryFn: () => fetchPortTrafficTargets(editTargetsDeviceId),
enabled: editing && Boolean(editPanelId) && Boolean(editTargetsDeviceId),
staleTime: 2000,
});
const mapBaselineTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(editMapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(editMapBaselineDeviceId),
enabled: editing && Boolean(editPanelId) && Boolean(editMapBaselineDeviceId),
staleTime: 2000,
});
const mapBaselineOptions = useMemo(
() =>
(mapBaselineTargetsQuery.data?.items || []).filter(
(x) =>
(x.status === "active" || x.id === editPanel?.baseline_target_id) &&
x.id !== editPanel?.target_id,
),
[mapBaselineTargetsQuery.data?.items, editPanel?.baseline_target_id, editPanel?.target_id],
);
const closePanelSettings = () => {
setEditPanelId("");
setEditPickDeviceId("");
setEditMapBaselineDeviceId("");
};
const openPanelSettings = (panelId: string) => {
const p = draftPanels.find((x) => x.id === panelId);
setEditPanelId(panelId);
setEditPickDeviceId(p?.target?.device_id || "");
setEditMapBaselineDeviceId(p?.baseline_target?.device_id || "");
};
const board = boardQuery.data || null;
const panels = editing ? draftPanels : board?.panels || [];
const cols = editing ? draftCols : board?.cols || 2;
useEffect(() => {
if (!board || editing) return;
setDraftPanels((board.panels || []).map(normalizePanel));
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
}, [board, editing]);
useEffect(() => {
if (!board?.name) return;
try {
document.title = "NETX";
} catch {
/* ignore */
}
}, [board?.name]);
const savePanelsMut = useMutation({
mutationFn: async () => {
if (draftName.trim() && draftName.trim() !== (board?.name || "")) {
await updatePortTrafficBoard(boardId, { name: draftName.trim(), cols: draftCols });
} else if (draftCols !== (board?.cols || 2)) {
await updatePortTrafficBoard(boardId, { cols: draftCols });
}
return putPortTrafficBoardPanels(
boardId,
draftPanels.map((p, i) => ({ ...panelToIn(p), ord: i })),
);
},
onSuccess: () => {
showOk(t("portTraffic.boardSaved"));
setDirty(false);
setEditing(false);
closePanelSettings();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoard(boardId) });
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: () => deletePortTrafficBoard(boardId),
onSuccess: () => {
showOk(t("portTraffic.boardDeleted"));
setDeleted(true);
setEditing(false);
setDirty(false);
closePanelSettings();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
},
onError: (e: Error) => showError(e.message),
});
const startEdit = () => {
if (!board) return;
setDraftPanels((board.panels || []).map(normalizePanel));
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
setEditing(true);
setDirty(false);
};
const cancelEdit = () => {
if (dirty && !window.confirm(t("portTraffic.boardUnsavedLeave"))) return;
setEditing(false);
setDirty(false);
closePanelSettings();
if (board) {
setDraftPanels((board.panels || []).map(normalizePanel));
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
}
};
useEffect(() => {
const syncFs = () => {
const el = stageRef.current;
setFullscreen(Boolean(el && document.fullscreenElement === el));
};
document.addEventListener("fullscreenchange", syncFs);
return () => document.removeEventListener("fullscreenchange", syncFs);
}, []);
const toggleFullscreen = async () => {
const el = stageRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch (err) {
showError(String(err));
}
};
const markDirty = (next: PortTrafficBoardPanel[]) => {
setDraftPanels(next);
setDirty(true);
};
const addPanel = () => {
if (!addTargetId) {
showError(t("portTraffic.pickPort"));
return;
}
const tgt = (addTargetsQuery.data?.items || []).find((x) => x.id === addTargetId);
const panel: PortTrafficBoardPanel = {
id: newPanelId(),
board_id: boardId,
title: "",
target_id: addTargetId,
range_hours: 24,
baseline: "off",
offset_hours: 0,
ahead_hours: 1,
baseline_target_id: "",
y_mode: "auto",
ord: draftPanels.length,
col_span: 1,
row_span: 1,
stale: false,
target: tgt || null,
baseline_target: null,
};
markDirty([...draftPanels, panel]);
setAddTargetId("");
};
const updatePanel = (id: string, patch: Partial<PortTrafficBoardPanel>) => {
markDirty(draftPanels.map((p) => (p.id === id ? { ...p, ...patch } : p)));
};
const movePanel = (id: string, dir: -1 | 1) => {
const idx = draftPanels.findIndex((p) => p.id === id);
if (idx < 0) return;
const j = idx + dir;
if (j < 0 || j >= draftPanels.length) return;
const next = [...draftPanels];
const tmp = next[idx];
next[idx] = next[j];
next[j] = tmp;
markDirty(next.map((p, i) => ({ ...p, ord: i })));
};
const activeAddTargets = useMemo(
() => (addTargetsQuery.data?.items || []).filter((x) => x.status === "active"),
[addTargetsQuery.data?.items],
);
if (!boardId) {
return (
<section className="pt-wall-shell">
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardPickHint")}</p>
</div>
</section>
);
}
if (deleted) {
return (
<section className="pt-wall-shell">
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardDeleted")}</p>
<p className="muted">{t("portTraffic.boardClosedHint")}</p>
</div>
</section>
);
}
return (
<section className={`pt-wall-shell${editing ? " is-editing" : ""}`}>
<div className="pt-board-toolbar">
<div className="pt-board-toolbar__left">
{editing ? (
<>
<label className="pt-board-toolbar__field">
{t("portTraffic.boardName")}
<Input
value={draftName}
onChange={(e) => {
setDraftName(e.target.value);
setDirty(true);
}}
/>
</label>
<FieldSelect
className="pt-board-toolbar__field pt-board-toolbar__field--sm"
label={t("portTraffic.boardCols")}
value={draftCols}
onChange={(e) => {
setDraftCols(Number(e.target.value) || 2);
setDirty(true);
}}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</FieldSelect>
</>
) : (
<div className="pt-board-toolbar__title">
{board?.name || t("portTraffic.wallTitle")}
{dirty ? " *" : ""}
</div>
)}
</div>
<div className="btn-row pt-board-toolbar__actions">
{panels.length && !editing ? (
<Button
size="sm"
variant="secondary"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onPress={() => void toggleFullscreen()}
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
</Button>
) : null}
{!editing ? (
<Button variant="primary" onPress={startEdit} isDisabled={!board}>
{t("portTraffic.boardEdit")}
</Button>
) : (
<>
<Button
variant="primary"
isDisabled={savePanelsMut.isPending}
onPress={() => savePanelsMut.mutate()}
>
{t("portTraffic.boardSave")}
</Button>
<Button variant="tertiary" onPress={cancelEdit}>
{t("portTraffic.boardCancel")}
</Button>
</>
)}
{!editing ? (
<Button
variant="danger"
isDisabled={deleteMut.isPending || !board}
onPress={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
}}
>
{t("portTraffic.delete")}
</Button>
) : null}
</div>
</div>
{boardQuery.isLoading ? (
<div className="pt-wall-shell__empty">
<p>…</p>
</div>
) : boardQuery.isError ? (
<div className="pt-wall-shell__empty">
<p>{(boardQuery.error as Error)?.message || t("common.opFailed")}</p>
</div>
) : (
<div className="pt-wall-page pt-board-page">
{editing ? (
<div className="pt-board-add">
<label className="pt-wall-page__field">
{t("portTraffic.wallDevice")}
<select
value={addDeviceId}
onChange={(e) => {
setAddDeviceId(e.target.value);
setAddTargetId("");
}}
>
<option value="">{t("portTraffic.boardPickDevice")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.ne_id}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select value={addTargetId} onChange={(e) => setAddTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{activeAddTargets.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
<Button variant="primary" onPress={addPanel}>
{t("portTraffic.boardAddPanel")}
</Button>
</div>
) : null}
{!panels.length ? (
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardNoPanels")}</p>
{!editing ? (
<Button variant="primary" onPress={startEdit}>
{t("portTraffic.boardEdit")}
</Button>
) : null}
</div>
) : (
<div
ref={stageRef}
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
>
<div
className="pt-board-grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, cols)}, minmax(0, 1fr))` }}
>
{panels.map((p) => (
<PortTrafficBoardPanelCell
key={p.id}
boardId={boardId}
panel={p}
editing={editing}
dense={fullscreen}
onEdit={() => openPanelSettings(p.id)}
onRemove={() => markDirty(draftPanels.filter((x) => x.id !== p.id))}
onMove={(dir) => movePanel(p.id, dir)}
/>
))}
</div>
{fullscreen ? (
<div className="pt-board-fs-legend" aria-hidden={false}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</div>
) : null}
</div>
)}
</div>
)}
{editing && editPanel ? (
<AppModalShell open onClose={closePanelSettings} size="lg">
<Modal.Header>
<Modal.Heading>{t("portTraffic.boardPanelSettings")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<div className="form-grid">
<label>
{t("portTraffic.boardPanelTitle")}
<Input
value={editPanel.title}
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
/>
</label>
<label>
{t("portTraffic.boardPickDevice")}
<FieldSelect
value={editTargetsDeviceId}
onChange={(e) => {
setEditPickDeviceId(e.target.value);
updatePanel(editPanel.id, { target_id: "", target: null, stale: true });
}}
>
<option value="">{t("portTraffic.boardPickDevice")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</FieldSelect>
</label>
<label>
{t("portTraffic.wallPort")}
<FieldSelect
value={editPanel.target_id}
disabled={!editTargetsDeviceId}
onChange={(e) => {
const tid = e.target.value;
const tgt = (editTargetsQuery.data?.items || []).find((x) => x.id === tid);
updatePanel(editPanel.id, {
target_id: tid,
target: tgt || null,
stale: !tgt,
...(tid && tid === editPanel.baseline_target_id
? { baseline_target_id: "", baseline_target: null }
: {}),
});
}}
>
<option value="">{t("portTraffic.pickPort")}</option>
{(editTargetsQuery.data?.items || [])
.filter((x) => x.status === "active" || x.id === editPanel.target_id)
.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</FieldSelect>
</label>
<label>
{t("portTraffic.range")}
<FieldSelect
value={String(editPanel.range_hours)}
onChange={(e) =>
updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 })
}
>
<option value={1}>1h</option>
<option value={6}>6h</option>
<option value={24}>24h</option>
</FieldSelect>
</label>
<label>
{t("portTraffic.compare")}
<FieldSelect
value={editPanel.baseline}
onChange={(e) => {
const next = e.target.value as BaselineMode;
updatePanel(editPanel.id, {
baseline: next,
...(next !== "off" && !(editPanel.ahead_hours > 0)
? { ahead_hours: 1 }
: {}),
});
}}
>
<option value="off">{t("portTraffic.compareOff")}</option>
<option value="day">{t("portTraffic.compareDay")}</option>
<option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option>
</FieldSelect>
</label>
{editPanel.baseline === "custom" ? (
<label>
{t("portTraffic.offsetHours")}
<Input
type="number"
min={1}
max={24 * 90}
value={String(editPanel.offset_hours || 48)}
onChange={(e) =>
updatePanel(editPanel.id, {
offset_hours: Number(e.target.value) || 24,
})
}
/>
</label>
) : null}
<label>
{t("portTraffic.yMode")}
<FieldSelect
value={editPanel.y_mode}
onChange={(e) =>
updatePanel(editPanel.id, { y_mode: e.target.value as WallYMode })
}
>
<option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option>
</FieldSelect>
</label>
<label>
{t("portTraffic.boardColSpan")}
<FieldSelect
value={String(editPanel.col_span || 1)}
onChange={(e) =>
updatePanel(editPanel.id, {
col_span: Math.min(cols, Number(e.target.value) || 1),
})
}
>
{Array.from({ length: cols }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</FieldSelect>
</label>
<label>
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
<FieldSelect
value={editMapBaselineDeviceId}
disabled={!editPanel.target_id}
onChange={(e) => {
setEditMapBaselineDeviceId(e.target.value);
updatePanel(editPanel.id, {
baseline_target_id: "",
baseline_target: null,
});
}}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</FieldSelect>
</label>
<label>
{t("portTraffic.mapBaselinePort")}
<FieldSelect
value={editPanel.baseline_target_id || ""}
disabled={!editMapBaselineDeviceId}
onChange={(e) => {
const tid = e.target.value;
const tgt = mapBaselineOptions.find((x) => x.id === tid) || null;
updatePanel(editPanel.id, {
baseline_target_id: tid,
baseline_target: tgt,
...(tid && !(editPanel.ahead_hours > 0) ? { ahead_hours: 1 } : {}),
});
}}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</FieldSelect>
</label>
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
<label>
{t("portTraffic.aheadHours")}
<Input
type="number"
min={0}
max={24}
step={1}
aria-label={t("portTraffic.aheadHoursHint")}
value={String(editPanel.ahead_hours ?? 1)}
onChange={(e) =>
updatePanel(editPanel.id, {
ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)),
})
}
/>
</label>
) : null}
</div>
{editPanel.baseline === "week" ? (
<p className="muted">{t("portTraffic.retentionHint")}</p>
) : null}
{editPanel.baseline_target_id ? (
<p className="muted">{t("portTraffic.mapBaselineHint")}</p>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onPress={closePanelSettings}>
{t("portTraffic.logClose")}
</Button>
</Modal.Footer>
</AppModalShell>
) : null}
</section>
);
}

View file

@ -0,0 +1,139 @@
import { Button } from "@heroui/react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { fetchPortTrafficCompare } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import { useI18n } from "../../../i18n";
import type { PortTrafficBoardPanel } from "../../../types";
import { PortTrafficWall, type WallYMode } from "./PortTrafficWall";
const POLL_MS = 5000;
const EMPTY: never[] = [];
type Props = {
boardId: string;
panel: PortTrafficBoardPanel;
editing?: boolean;
dense?: boolean;
onEdit?: () => void;
onRemove?: () => void;
onMove?: (dir: -1 | 1) => void;
};
export function PortTrafficBoardPanelCell({
boardId,
panel,
editing,
dense,
onEdit,
onRemove,
onMove,
}: Props) {
const { t } = useI18n();
const baseline = panel.baseline || "off";
const aheadHours =
baseline === "off" && !panel.baseline_target_id
? 0
: Math.max(0, Number(panel.ahead_hours ?? 1) || 0);
const compareQuery = useQuery({
queryKey: [
...queryKeys.portTrafficCompare(
panel.target_id,
panel.range_hours,
baseline,
baseline === "custom" ? panel.offset_hours : 0,
panel.baseline_target_id || "",
aheadHours,
),
boardId,
panel.id,
],
queryFn: () =>
fetchPortTrafficCompare({
targetId: panel.target_id,
rangeHours: panel.range_hours,
baseline,
offsetHours: baseline === "custom" ? panel.offset_hours : undefined,
aheadHours: aheadHours || undefined,
baselineTargetId: panel.baseline_target_id || undefined,
}),
enabled: Boolean(panel.target_id) && !panel.stale,
staleTime: 1000,
placeholderData: keepPreviousData,
refetchInterval: POLL_MS,
});
const points = compareQuery.data?.current ?? EMPTY;
const baselinePoints =
baseline === "off" && !panel.baseline_target_id
? EMPTY
: (compareQuery.data?.baseline ?? EMPTY);
const target = panel.target || compareQuery.data?.meta?.current_target || null;
const baselineTarget =
compareQuery.data?.meta?.baseline_target || panel.baseline_target || null;
const title =
panel.title ||
(target ? `${target.ne_name || target.ne_ip || "—"} · ${target.ifname}` : t("portTraffic.pickPort"));
const rangeLabel = `${panel.range_hours}h${
baseline === "off"
? ""
: baseline === "day"
? ` · ${t("portTraffic.compareDay")}`
: baseline === "week"
? ` · ${t("portTraffic.compareWeek")}`
: baseline === "shift"
? ` · ${t("portTraffic.compareShift")}`
: ` · ${t("portTraffic.compareCustom")}`
}${panel.baseline_target_id ? ` · ${t("portTraffic.mapBaselinePort")}` : ""}`;
return (
<div
className={`pt-board-panel${panel.stale ? " is-stale" : ""}`}
style={{ gridColumn: `span ${Math.max(1, panel.col_span || 1)}` }}
>
{editing ? (
<div className="pt-board-panel__editbar">
<span className="pt-board-panel__edit-title" title={title}>
{title}
</span>
<div className="btn-row">
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(-1)}>
↑
</Button>
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(1)}>
↓
</Button>
<Button size="sm" variant="secondary" onPress={() => onEdit?.()}>
{t("portTraffic.edit")}
</Button>
<Button size="sm" variant="danger" onPress={() => onRemove?.()}>
{t("portTraffic.delete")}
</Button>
</div>
</div>
) : null}
{panel.stale ? (
<div className="pt-board-panel__stale">
<p>{t("portTraffic.boardPanelStale")}</p>
{editing ? (
<Button variant="primary" onPress={() => onEdit?.()}>
{t("portTraffic.boardRetarget")}
</Button>
) : null}
</div>
) : (
<PortTrafficWall
target={target}
baselineTarget={baselineTarget}
points={points}
baselinePoints={baselinePoints}
yMode={(panel.y_mode as WallYMode) || "auto"}
rangeLabel={rangeLabel}
loading={compareQuery.isLoading || compareQuery.isFetching}
hint={t("portTraffic.waitSamples")}
dense={dense}
/>
)}
</div>
);
}

View file

@ -0,0 +1,536 @@
import { useEffect, useMemo, useRef } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../../types";
import { formatSystemTime, parseApiTime } from "../../../utils/time";
import { useI18n } from "../../../i18n";
export type WallYMode = "auto" | "current" | "util";
function formatBps(n: number): string {
const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`;
if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`;
if (v > 0 && v < 1) return n.toFixed(2);
return `${Math.round(n)}`;
}
function formatBwLabel(bps: number): string {
if (!bps) return "—";
return `${formatBps(bps)}bit/s`;
}
function formatUtilPct(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0%";
if (n < 0.01) return "<0.01%";
if (n < 1) return `${n.toFixed(2)}%`;
return `${n.toFixed(1)}%`;
}
function resolveUtil(vendorUtil: number, bps: number, bw: number): number {
if (vendorUtil > 0) return vendorUtil;
if (bw > 0 && bps > 0) return (bps / bw) * 100;
return vendorUtil || 0;
}
function formatHoverTime(sec: number): string {
return formatSystemTime(new Date(sec * 1000));
}
function yPadRange(min: number, max: number): [number, number] {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) {
const pad = Math.max(max * 0.25 || 0.1, max > 0 && max < 1 ? max * 0.5 : 10);
return [Math.max(0, min - pad * 0.1), max + pad];
}
return [Math.max(0, min * 0.95), max * 1.05];
}
function finiteMax(vals: number[]): number {
let m = Number.NEGATIVE_INFINITY;
for (const v of vals) {
if (Number.isFinite(v) && v > m) m = v;
}
return Number.isFinite(m) ? m : 0;
}
function pointValue(
p: PortTrafficSamplePoint,
dir: "in" | "out",
mode: WallYMode,
bwFallback: number,
): number {
const bps = dir === "in" ? Number(p.in_bps) || 0 : Number(p.out_bps) || 0;
if (mode !== "util") return bps;
const bw = Number(p.bw_bps) || bwFallback || 0;
const vendor = dir === "in" ? Number(p.in_util_pct) || 0 : Number(p.out_util_pct) || 0;
return resolveUtil(vendor, bps, bw);
}
function toAlignedSeries(
current: PortTrafficSamplePoint[],
baseline: PortTrafficSamplePoint[],
mode: WallYMode,
bwFallback: number,
baseBwFallback: number,
): [number[], number[], number[], number[], number[]] {
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
// Shared X = union of both timelines; each series only has values at its own samples.
// Gaps stay NaN; series use spanGaps so each line connects on its own cadence.
const map = new Map<number, Row>();
for (const p of current) {
const d = parseApiTime(p.ts);
if (!d) continue;
const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x };
row.inC = pointValue(p, "in", mode, bwFallback);
row.outC = pointValue(p, "out", mode, bwFallback);
map.set(x, row);
}
for (const p of baseline) {
const d = parseApiTime(p.ts);
if (!d) continue;
const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x };
row.inB = pointValue(p, "in", mode, baseBwFallback || bwFallback);
row.outB = pointValue(p, "out", mode, baseBwFallback || bwFallback);
map.set(x, row);
}
const rows = [...map.values()].sort((a, b) => a.x - b.x);
if (rows.length === 1) {
rows.push({ ...rows[0], x: rows[0].x + 60 });
}
return [
rows.map((r) => r.x),
rows.map((r) => r.inC ?? NaN),
rows.map((r) => r.outC ?? NaN),
rows.map((r) => r.inB ?? NaN),
rows.map((r) => r.outB ?? NaN),
];
}
function chartSize(el: HTMLElement): { width: number; height: number } {
const width = Math.max(120, el.clientWidth || el.parentElement?.clientWidth || 800);
const parentH = el.clientHeight || el.parentElement?.clientHeight || 0;
// Follow container height in board/fullscreen panels (may be well under 180px).
if (parentH > 0) {
return { width, height: Math.max(64, parentH) };
}
return { width, height: Math.max(220, Math.min(440, Math.floor(width * 0.34))) };
}
const EMPTY_POINTS: PortTrafficSamplePoint[] = [];
type Props = {
target: PortTrafficTarget | null;
baselineTarget?: PortTrafficTarget | null;
points: PortTrafficSamplePoint[];
baselinePoints?: PortTrafficSamplePoint[];
rangeLabel: string;
yMode?: WallYMode;
loading?: boolean;
hint?: string;
/** Board fullscreen: denser chrome, no per-panel legend. */
dense?: boolean;
};
export function PortTrafficWall({
target,
baselineTarget = null,
points,
baselinePoints = EMPTY_POINTS,
rangeLabel,
yMode = "auto",
loading,
hint,
dense = false,
}: Props) {
const { t } = useI18n();
const mountRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null);
const tipRef = useRef<HTMLDivElement | null>(null);
const pointsRef = useRef(points);
const baselineRef = useRef(baselinePoints);
const yModeRef = useRef(yMode);
const bwRef = useRef(0);
pointsRef.current = points;
baselineRef.current = baselinePoints;
yModeRef.current = yMode;
const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => {
const bw = latest?.bw_bps || target?.bw_bps || 0;
const inBps = latest?.in_bps ?? 0;
const outBps = latest?.out_bps ?? 0;
return {
bw,
inBps,
outBps,
inUtil: resolveUtil(latest?.in_util_pct ?? 0, inBps, bw),
outUtil: resolveUtil(latest?.out_util_pct ?? 0, outBps, bw),
ts: latest?.ts ?? null,
};
}, [latest, target]);
bwRef.current = kpi.bw;
useEffect(() => {
const el = mountRef.current;
if (!el) return;
const { width, height } = chartSize(el);
const placeTip = (u: uPlot, left: number, top: number) => {
const tip = tipRef.current;
if (!tip) return;
const wrap = tip.parentElement;
if (!wrap) return;
const overRect = u.over.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
const tipW = tip.offsetWidth || 160;
const tipH = tip.offsetHeight || 72;
let x = overRect.left - wrapRect.left + left + 14;
let y = overRect.top - wrapRect.top + top - tipH - 8;
x = Math.min(Math.max(8, x), Math.max(8, wrapRect.width - tipW - 8));
y = Math.max(8, y);
tip.style.transform = `translate(${x}px, ${y}px)`;
};
const opts: uPlot.Options = {
width,
height,
series: [
{},
{
label: t("portTraffic.seriesCurrentIn"),
stroke: "#2dd4bf",
width: 2,
fill: "rgba(45, 212, 191, 0.08)",
points: { show: false },
spanGaps: true,
},
{
label: t("portTraffic.seriesCurrentOut"),
stroke: "#fbbf24",
width: 2,
fill: "rgba(251, 191, 36, 0.07)",
points: { show: false },
spanGaps: true,
},
{
label: t("portTraffic.seriesBaselineIn"),
stroke: "rgba(45, 212, 191, 0.45)",
width: 1.5,
dash: [6, 4],
points: { show: false },
spanGaps: true,
show: false,
},
{
label: t("portTraffic.seriesBaselineOut"),
stroke: "rgba(251, 191, 36, 0.4)",
width: 1.5,
dash: [6, 4],
points: { show: false },
spanGaps: true,
show: false,
},
],
axes: [
{
stroke: "#94a3b8",
grid: { show: false },
ticks: { stroke: "rgba(148,163,184,0.35)", size: 4 },
border: { show: false },
gap: 6,
},
{
stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
border: { show: false },
values: (_u, splits) =>
splits.map((v) => (yModeRef.current === "util" ? `${v}` : formatBps(v))),
size: 64,
label: yMode === "util" ? "%" : "bit/s",
labelFont: "12px sans-serif",
labelSize: 14,
},
],
scales: {
x: { time: true },
y: {
auto: true,
range: (u, min, max) => {
const mode = yModeRef.current;
// "current": scale to live series only. "auto"/util: include baseline too,
// otherwise a near-zero current + busy mapped baseline clips baseline off-chart.
const seriesIdxs =
mode === "current" ? [1, 2] : mode === "util" ? [1, 2, 3, 4] : [1, 2, 3, 4];
const vals: number[] = [];
for (const i of seriesIdxs) {
for (const v of u.data[i] || []) {
if (Number.isFinite(v)) vals.push(v as number);
}
}
if (vals.length) return yPadRange(0, finiteMax(vals));
return yPadRange(min, max);
},
},
},
legend: { show: false },
cursor: {
drag: { x: true, y: false },
focus: { prox: 32 },
points: { show: true, size: 8 },
},
hooks: {
setCursor: [
(u) => {
const tip = tipRef.current;
const idx = u.cursor.idx;
const left = u.cursor.left ?? 0;
const top = u.cursor.top ?? 0;
if (idx == null || idx < 0 || !u.data[0]?.length) {
if (tip) tip.style.display = "none";
return;
}
const tsSec = Number(u.data[0][idx]);
const sample = pointsRef.current.find((p) => {
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
});
const baseSample = baselineRef.current.find((p) => {
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
});
const mode = yModeRef.current;
const yIn = Number(u.data[1]?.[idx]);
const yOut = Number(u.data[2]?.[idx]);
const yInB = Number(u.data[3]?.[idx]);
const yOutB = Number(u.data[4]?.[idx]);
const unit = mode === "util" ? "%" : "bit/s";
const fmtY = (v: number) =>
mode === "util" ? formatUtilPct(v).replace(/%$/, "") : formatBps(v);
if (!tip) return;
tip.style.display = "block";
const rows = [
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
Number.isFinite(yIn)
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${fmtY(yIn)} ${unit}</strong></div>`
: "",
Number.isFinite(yOut)
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${fmtY(yOut)} ${unit}</strong></div>`
: "",
];
if (sample) {
const bw = Number(sample.bw_bps) || bwRef.current || 0;
const inU = resolveUtil(Number(sample.in_util_pct) || 0, Number(sample.in_bps) || 0, bw);
const outU = resolveUtil(Number(sample.out_util_pct) || 0, Number(sample.out_bps) || 0, bw);
rows.push(
`<div class="pt-wall__tip-row"><span>Util</span><strong>${formatUtilPct(inU)} / ${formatUtilPct(outU)}</strong></div>`,
);
}
if (Number.isFinite(yInB) || Number.isFinite(yOutB)) {
rows.push(
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${
Number.isFinite(yInB) ? fmtY(yInB) : "—"
} / ${Number.isFinite(yOutB) ? fmtY(yOutB) : "—"} ${unit}</strong></div>`,
);
if (baseSample?.ts_raw) {
rows.push(
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baselineAt")}</span><strong>${formatSystemTime(baseSample.ts_raw)}</strong></div>`,
);
}
}
tip.innerHTML = rows.join("");
placeTip(u, left, top);
},
],
},
};
plotRef.current = new uPlot(opts, [[], [], [], [], []], el);
const onLeave = () => {
if (tipRef.current) tipRef.current.style.display = "none";
};
plotRef.current.over.addEventListener("mouseleave", onLeave);
const onResize = () => {
if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize(chartSize(mountRef.current));
};
window.addEventListener("resize", onResize);
document.addEventListener("fullscreenchange", onResize);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => onResize())
: null;
ro?.observe(el);
const wrap = el.parentElement;
if (wrap) ro?.observe(wrap);
// Observe panel / stage ancestors so fullscreen grid row shrinks resize plots.
let node: HTMLElement | null = wrap?.parentElement ?? null;
for (let i = 0; i < 4 && node; i += 1) {
ro?.observe(node);
node = node.parentElement;
}
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onResize);
ro?.disconnect();
plotRef.current?.over.removeEventListener("mouseleave", onLeave);
plotRef.current?.destroy();
plotRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const plot = plotRef.current;
if (!plot) return;
const bw = kpi.bw || target?.bw_bps || 0;
const baseBw = baselineTarget?.bw_bps || bw;
const data = toAlignedSeries(points, baselinePoints, yMode, bw, baseBw);
const showBaseline = baselinePoints.length > 0;
plot.setData(data, true);
const s3 = Boolean(plot.series[3]?.show);
const s4 = Boolean(plot.series[4]?.show);
if (s3 !== showBaseline || s4 !== showBaseline) {
plot.setSeries(3, { show: showBaseline }, false);
plot.setSeries(4, { show: showBaseline }, false);
}
const yAxis = plot.axes[1];
if (yAxis) {
const nextLabel = yMode === "util" ? "%" : "bit/s";
if (yAxis.label !== nextLabel) {
yAxis.label = nextLabel;
plot.redraw(false, false);
}
}
const el = mountRef.current;
if (el) {
requestAnimationFrame(() => {
if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize(chartSize(mountRef.current));
});
}
}, [points, baselinePoints, yMode, kpi.bw, target?.bw_bps, baselineTarget?.bw_bps]);
const empty = points.length === 0 && baselinePoints.length === 0;
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
const ifLabel = target?.ifname || "—";
const ipLabel = target?.ne_ip || "";
const showBaselineMeta = Boolean(baselineTarget) && baselinePoints.length > 0;
const baseNeLabel = baselineTarget
? baselineTarget.ne_name || baselineTarget.ne_ip || "—"
: "—";
const baseIfLabel = baselineTarget?.ifname || "—";
const baseIpLabel = baselineTarget?.ne_ip || "";
return (
<div className={`pt-wall${dense ? " pt-wall--dense" : ""}`}>
<div className="pt-wall__head">
<div className="pt-wall__title">
<div className="pt-wall__ne">
{neLabel}
{ipLabel ? ` · ${ipLabel}` : ""}
{dense && showBaselineMeta ? (
<span className="pt-wall__ne-base" title={`${baseNeLabel} · ${baseIfLabel}`}>
{" · "}
{t("portTraffic.baseline")}: {baseNeLabel} · {baseIfLabel}
</span>
) : null}
</div>
<div className="pt-wall__if" title={ifLabel}>
{ifLabel}
</div>
</div>
<div className="pt-wall__range">
{rangeLabel}
{kpi.ts ? ` · ${formatSystemTime(kpi.ts)}` : ""}
</div>
</div>
<div className="pt-wall__kpis">
<div className="pt-wall__kpi">
<div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</div>
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
</div>
<div className="pt-wall__kpi pt-wall__kpi--in">
<div className="pt-wall__kpi-label">In</div>
<div className="pt-wall__kpi-value">
{formatBps(kpi.inBps)}
<span className="pt-wall__kpi-unit">bit/s</span>
</div>
</div>
<div className="pt-wall__kpi pt-wall__kpi--out">
<div className="pt-wall__kpi-label">Out</div>
<div className="pt-wall__kpi-value">
{formatBps(kpi.outBps)}
<span className="pt-wall__kpi-unit">bit/s</span>
</div>
</div>
<div className="pt-wall__kpi pt-wall__kpi--util">
<div className="pt-wall__kpi-label">Util</div>
<div className="pt-wall__kpi-value">
{formatUtilPct(kpi.inUtil)}
<span className="pt-wall__kpi-sep">/</span>
{formatUtilPct(kpi.outUtil)}
</div>
</div>
</div>
<div className="pt-wall__chart-wrap">
<div className="pt-wall__chart" ref={mountRef} />
<div className="pt-wall__tip" ref={tipRef} style={{ display: "none" }} />
{empty ? (
<div className="pt-wall__empty">
{loading ? "Loading samples…" : hint || "Waiting for samples…"}
</div>
) : null}
</div>
{!dense ? (
<div className="pt-wall__foot">
<div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
{showBaselineMeta ? (
<>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</>
) : null}
</div>
{showBaselineMeta ? (
<div className="pt-wall__foot-meta">
<div className="pt-wall__foot-ne" title={baseNeLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
<span className="pt-wall__foot-value">{baseNeLabel}</span>
{baseIpLabel ? <span className="pt-wall__foot-ip">{baseIpLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={baseIfLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselinePort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,17 @@
import { openNewModuleWindow } from "../../../utils/moduleWindows";
export const PORT_TRAFFIC_WALL_MODULE_ID = "port-traffic-wall";
export function portTrafficBoardPath(boardId: string): string {
return `/port-traffic/wall/${encodeURIComponent(boardId)}`;
}
/** Open a dedicated browser tab for one board (outside Network management). */
export function openPortTrafficBoardWindow(boardId: string): void {
const id = String(boardId || "").trim();
if (!id) return;
openNewModuleWindow({
moduleId: PORT_TRAFFIC_WALL_MODULE_ID,
path: portTrafficBoardPath(id),
});
}

View file

@ -0,0 +1,169 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { HelpHint } from "../../components/HelpHint";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { apiDelete, apiGet, apiPost } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
type SessionRow = {
id: string;
created_at: string | null;
expires_at: string | null;
refresh_expires_at: string | null;
last_seen_at: string | null;
client_ip: string;
user_agent: string;
current: boolean;
};
/** Short browser/OS label so same NAT IP can still be told apart. */
function summarizeUa(ua: string): string {
const s = String(ua || "").trim();
if (!s) return "—";
const browser = /Edg\//.test(s)
? "Edge"
: /Chrome\//.test(s)
? "Chrome"
: /Firefox\//.test(s)
? "Firefox"
: /Safari\//.test(s) && !/Chrome\//.test(s)
? "Safari"
: /curl\//i.test(s)
? "curl"
: /python/i.test(s)
? "python"
: "Client";
const os = /Windows NT/i.test(s)
? "Windows"
: /Android/i.test(s)
? "Android"
: /iPhone|iPad/i.test(s)
? "iOS"
: /Mac OS X|Macintosh/i.test(s)
? "macOS"
: /Linux/i.test(s)
? "Linux"
: "";
return os ? `${browser} · ${os}` : browser;
}
export function SessionsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const sessionsQuery = useQuery({
queryKey: ["auth-sessions"],
queryFn: () => apiGet<{ items: SessionRow[]; total: number }>("/v1/auth/sessions"),
});
const revokeMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/auth/sessions/${encodeURIComponent(id)}`),
onSuccess: () => {
showOk(t("auth.sessionRevoked"));
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
},
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
});
const revokeOthersMut = useMutation({
mutationFn: () => apiPost<{ revoked: number }>("/v1/auth/sessions/revoke-others", {}),
onSuccess: (data) => {
showOk(t("auth.sessionsRevokedOthers", { count: data.revoked ?? 0 }));
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
},
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
});
const items = sessionsQuery.data?.items || [];
return (
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("auth.sessionsTitle")}
<HelpHint text={t("auth.sessionsHelp")} ariaLabel={t("common.help")} />
</h2>
</div>
<div className="pt-list">
<div className="filter-inline">
<Button
size="sm"
variant="secondary"
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onPress={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
</Button>
<Button
size="sm"
variant="secondary"
onPress={() => void sessionsQuery.refetch()}
isDisabled={sessionsQuery.isFetching}
>
{t("common.refresh")}
</Button>
</div>
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !sessionsQuery.isLoading ? (
<div className="pt-list-empty">
<p className="muted">{t("auth.sessionsEmpty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.colSession")}</th>
<th>{t("auth.colIp")}</th>
<th>{t("auth.colClient")}</th>
<th>{t("auth.colLastSeen")}</th>
<th>{t("auth.colCreated")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td>
<code title={row.id}>{row.id.slice(0, 10)}…</code>
{row.current ? (
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
{t("auth.sessionCurrent")}
</span>
) : null}
</td>
<td>
<code>{row.client_ip || "—"}</code>
</td>
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
<td>
<Button
size="sm"
variant="secondary"
isDisabled={revokeMut.isPending}
onPress={() => {
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
revokeMut.mutate(row.id);
}}
>
{t("auth.revokeSession")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
import { BaseEdge, EdgeLabelRenderer, getStraightPath, type EdgeProps } from "@xyflow/react";
/** Offset parallel physical links so they do not stack on one line. */
export function ParallelEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
style,
markerEnd,
label,
labelStyle,
data,
selected,
}: EdgeProps) {
const index = Number((data as { parallelIndex?: number } | undefined)?.parallelIndex || 0);
const count = Math.max(1, Number((data as { parallelCount?: number } | undefined)?.parallelCount || 1));
const offset = (index - (count - 1) / 2) * 10;
const dx = targetX - sourceX;
const dy = targetY - sourceY;
const len = Math.hypot(dx, dy) || 1;
const ox = (-dy / len) * offset;
const oy = (dx / len) * offset;
const [path, labelX, labelY] = getStraightPath({
sourceX: sourceX + ox,
sourceY: sourceY + oy,
targetX: targetX + ox,
targetY: targetY + oy,
});
return (
<>
<BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />
{label ? (
<EdgeLabelRenderer>
<div
className={`topo-edge-label${selected ? " is-selected" : ""}`}
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "none",
...labelStyle,
}}
>
{label}
</div>
</EdgeLabelRenderer>
) : null}
</>
);
}

View file

@ -0,0 +1 @@
export { AppModalShell, TopoModalShell, type AppModalShellProps } from "../../../components/ui/AppModalShell";

View file

@ -0,0 +1,522 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { Button } from "@heroui/react";
import type { Edge, Node } from "@xyflow/react";
import { useI18n } from "../../../i18n";
import type { CtxMenu } from "../lib/pageTypes";
import type { EdgeStyleData } from "../lib/edgeStyle";
import { formatPortPairLabel } from "../lib/linkDisplay";
import { isPlaceholderSource } from "../lib/searchUtils";
import type { NeNodeData } from "./TopologyReactFlowView";
export type ResolvedEdgeStyle = {
stroke: string;
strokeWidth: number;
strokeDasharray?: string;
};
export type TopologyCtxMenuProps = {
ctxMenu: CtxMenu;
menuRef: RefObject<HTMLUListElement | null>;
portalTarget: HTMLElement | null;
fullscreen: boolean;
selectedNodeCount: number;
selectedNode: Node<NeNodeData> | null;
selectedEdge: Edge | null;
selectedEdgeData: EdgeStyleData;
selectedEdgeResolved: ResolvedEdgeStyle | null;
selectedEdgeSourceNode: Node<NeNodeData> | null;
selectedEdgeTargetNode: Node<NeNodeData> | null;
expandPhysicalLinks: boolean;
discovering: boolean;
isWorldFlatCanvas: boolean;
createRegionPending: boolean;
onClose: () => void;
onToggleFullscreen: () => void;
onRemoveSelected: () => void;
onDiscoverSelected: () => void;
onConnectTestSelected: () => void;
selectedDiscoverableCount: number;
selectedConnectableCount: number;
onOpenCreateNe: (flowX: number, flowY: number) => void;
onPromptNewSubRegion: () => void;
onRenameSelectedNode: () => void;
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
onOpenNe: (node: Node<NeNodeData> | null) => void;
onOpenAlarms: (node: Node<NeNodeData> | null) => void;
onConnectTest: (node: Node<NeNodeData> | null) => void;
connectTestBusy?: boolean;
onPurgePlaceholder: (id: string) => void;
onRemoveNode: (id: string) => void;
onExpandPhysicalLinks: () => void;
onPushHistory: () => void;
onPatchSelectedEdgeStyle: (
patch: Partial<EdgeStyleData>,
opts?: { skipHistory?: boolean },
) => void;
onOpenPortTraffic: (edge: Edge | null) => void;
onRemoveEdge: (id: string) => void;
};
export function TopologyCtxMenu({
ctxMenu,
menuRef,
portalTarget,
fullscreen,
selectedNodeCount,
selectedNode,
selectedEdge,
selectedEdgeData,
selectedEdgeResolved,
selectedEdgeSourceNode,
selectedEdgeTargetNode,
expandPhysicalLinks,
discovering,
isWorldFlatCanvas,
createRegionPending,
onClose,
onToggleFullscreen,
onRemoveSelected,
onDiscoverSelected,
onConnectTestSelected,
selectedDiscoverableCount,
selectedConnectableCount,
onOpenCreateNe,
onPromptNewSubRegion,
onRenameSelectedNode,
onDiscoverOne,
onOpenWebcrt,
onOpenNe,
onOpenAlarms,
onConnectTest,
connectTestBusy = false,
onPurgePlaceholder,
onRemoveNode,
onExpandPhysicalLinks,
onPushHistory,
onPatchSelectedEdgeStyle,
onOpenPortTraffic,
onRemoveEdge,
}: TopologyCtxMenuProps) {
const { t } = useI18n();
return createPortal(
<ul
ref={menuRef}
className={`topo-ctx${ctxMenu.kind === "edge" ? " topo-ctx--edge" : ""}`}
style={{ left: ctxMenu.x, top: ctxMenu.y }}
role="menu"
onContextMenu={(e) => e.preventDefault()}
>
{ctxMenu.kind === "selection" ? (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.selectionMenu")}
<span className="topo-ctx__count"> · {selectedNodeCount}</span>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={discovering || selectedDiscoverableCount === 0}
title={
selectedDiscoverableCount === 0 ? t("topology.discoverOneNeedNe") : undefined
}
onClick={() => onDiscoverSelected()}
>
{t("topology.discoverSelected").replace(
"{{count}}",
String(selectedDiscoverableCount || selectedNodeCount),
)}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={connectTestBusy || selectedConnectableCount === 0}
title={
selectedConnectableCount === 0
? t("topology.connectTestNeedManaged")
: undefined
}
onClick={() => onConnectTestSelected()}
>
{connectTestBusy
? t("managedNe.connect.running")
: t("topology.connectTestSelected").replace(
"{{count}}",
String(selectedConnectableCount || selectedNodeCount),
)}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => {
onClose();
void onToggleFullscreen();
}}
>
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => onRemoveSelected()}
>
{t("topology.removeSelected").replace("{{count}}", String(selectedNodeCount))}
</button>
</li>
</>
) : ctxMenu.kind === "pane" ? (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.paneMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={isWorldFlatCanvas}
onClick={() => onOpenCreateNe(ctxMenu.flowX, ctxMenu.flowY)}
>
{t("topology.createNe")}
</button>
</li>
{!isWorldFlatCanvas ? (
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={createRegionPending}
onClick={() => onPromptNewSubRegion()}
>
{t("topology.newSubRegion")}
</button>
</li>
) : null}
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => {
onClose();
void onToggleFullscreen();
}}
>
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
</button>
</li>
</>
) : ctxMenu.kind === "node" ? (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.nodeMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => {
onClose();
void onToggleFullscreen();
}}
>
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={onRenameSelectedNode}
>
{t("topology.renameNode")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={discovering}
onClick={() => onDiscoverOne(selectedNode)}
>
{t("topology.discoverOne")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => onOpenWebcrt(selectedNode)}
>
{t("topology.openWebcrt")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => onOpenNe(selectedNode)}
>
{t("topology.openNe")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => onOpenAlarms(selectedNode)}
>
{t("topology.openAlarms")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={
connectTestBusy || !String(selectedNode?.data.managed_ne_id || "").trim()
}
title={
!String(selectedNode?.data.managed_ne_id || "").trim()
? t("topology.connectTestNeedManaged")
: undefined
}
onClick={() => onConnectTest(selectedNode)}
>
{connectTestBusy ? t("managedNe.connect.running") : t("topology.connectTest")}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
{selectedNode &&
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => void onPurgePlaceholder(ctxMenu.id)}
>
{t("topology.deletePlaceholder")}
</button>
</li>
) : null}
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => onRemoveNode(ctxMenu.id)}
>
{t("topology.removeNode")}
</button>
</li>
</>
) : (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.edgeMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => {
onClose();
void onToggleFullscreen();
}}
>
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
</button>
</li>
<li className="topo-ctx__section" role="none">
<div className="topo-ctx__style" onMouseDown={(e) => e.stopPropagation()}>
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
{selectedEdgeResolved ? (
<div className="topo-ctx__style-grid">
{Number(selectedEdgeData.member_count || 1) > 1 ? (
<div className="topo-ctx__style-members">
<div className="topo-ctx__style-title">
{t("topology.linkMembers").replace(
"{{count}}",
String(selectedEdgeData.member_count || 0),
)}
</div>
<ul className="topo-ctx__member-list">
{(selectedEdgeData.members || []).map((m) => (
<li key={m.id}>
{formatPortPairLabel(m.a_port, m.b_port, m.display_label) ||
m.id.slice(0, 8)}
</li>
))}
</ul>
{!expandPhysicalLinks ? (
<Button
size="sm"
variant="secondary"
onPress={() => {
onExpandPhysicalLinks();
onClose();
}}
>
{t("topology.expandPhysicalLinks")}
</Button>
) : null}
</div>
) : null}
<label className="topo-ctx__style-row">
<span>
{(selectedEdgeSourceNode?.data.label ||
selectedEdgeSourceNode?.data.ne_ip ||
t("topology.endpointA")) +
" · " +
t("topology.port")}
</span>
<input
type="text"
className="topo-ctx__style-input"
value={selectedEdgeData.source_port || ""}
placeholder="?"
disabled={
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
}
onFocus={() => onPushHistory()}
onChange={(e) =>
onPatchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true })
}
/>
</label>
<label className="topo-ctx__style-row">
<span>
{(selectedEdgeTargetNode?.data.label ||
selectedEdgeTargetNode?.data.ne_ip ||
t("topology.endpointB")) +
" · " +
t("topology.port")}
</span>
<input
type="text"
className="topo-ctx__style-input"
value={selectedEdgeData.target_port || ""}
placeholder="?"
disabled={
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
}
onFocus={() => onPushHistory()}
onChange={(e) =>
onPatchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true })
}
/>
</label>
<label className="topo-ctx__style-row">
<span>{t("topology.edgeColor")}</span>
<input
type="color"
value={selectedEdgeResolved.stroke}
onFocus={() => onPushHistory()}
onChange={(e) =>
onPatchSelectedEdgeStyle(
{ stroke_color: e.target.value },
{ skipHistory: true },
)
}
/>
</label>
<label className="topo-ctx__style-row">
<span>{t("topology.edgeLineStyle")}</span>
<select
value={
selectedEdgeData.line_style ||
(selectedEdgeResolved.strokeDasharray ? "dashed" : "solid")
}
onChange={(e) => onPatchSelectedEdgeStyle({ line_style: e.target.value })}
>
<option value="solid">{t("topology.edgeLineSolid")}</option>
<option value="dashed">{t("topology.edgeLineDashed")}</option>
<option value="dotted">{t("topology.edgeLineDotted")}</option>
</select>
</label>
<label className="topo-ctx__style-row">
<span>{t("topology.edgeWidth")}</span>
<select
value={String(
Number(selectedEdgeData.stroke_width || 0) > 0
? Number(selectedEdgeData.stroke_width)
: Math.round(Number(selectedEdgeResolved.strokeWidth || 2)),
)}
onChange={(e) =>
onPatchSelectedEdgeStyle({ stroke_width: Number(e.target.value) || 0 })
}
>
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
<option key={w} value={w}>
{w}px
</option>
))}
</select>
</label>
</div>
) : null}
</div>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => onOpenPortTraffic(selectedEdge)}
>
{t("topology.openPortTraffic")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() =>
onPatchSelectedEdgeStyle({ stroke_color: "", stroke_width: 0, line_style: "" })
}
>
{t("topology.edgeStyleReset")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => onRemoveEdge(ctxMenu.id)}
>
{t("topology.removeEdge")}
</button>
</li>
</>
)}
</ul>,
portalTarget || document.body,
);
}

View file

@ -0,0 +1,119 @@
import { Button } from "@heroui/react";
import type { TopologyDiscoverOut } from "../../../types";
import { useI18n } from "../../../i18n";
import { openOrFocusModule } from "../../../utils/moduleWindows";
export type DiscoverProgress = {
index: number;
total: number;
neName: string;
neIp: string;
edgesAdded: number;
edgesUpdated: number;
};
export type DiscoverSummary = {
scanned: number;
added: number;
updated: number;
missing: number;
failed: number;
};
export type TopologyDiscoverPanelProps = {
discovering: boolean;
discoverProgress: DiscoverProgress;
discoverPct: number;
discoverReport: TopologyDiscoverOut | null;
discoverError: string;
hasResults: boolean;
discoverSummary: DiscoverSummary;
onCancelDiscover: () => void;
onClose: () => void;
};
export function TopologyDiscoverPanel({
discovering,
discoverProgress,
discoverPct,
discoverReport,
discoverError,
hasResults,
discoverSummary,
onCancelDiscover,
onClose,
}: TopologyDiscoverPanelProps) {
const { t } = useI18n();
return (
<div className="topo-discover">
<div className="topo-discover__head">
<strong>{t("topology.discoverReport")}</strong>
<Button
size="sm"
variant="ghost"
onPress={() => {
if (discovering) {
void onCancelDiscover();
return;
}
onClose();
}}
>
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
</Button>
</div>
{discovering && discoverProgress.total > 0 ? (
<div className="topo-discover__progress" aria-live="polite">
<div className="topo-discover__bar">
<div className="topo-discover__bar-fill" style={{ width: `${discoverPct}%` }} />
</div>
<div className="topo-discover__progress-meta">
<span>
{t("topology.discoverProgressLive")
.replace("{{index}}", String(discoverProgress.index))
.replace("{{total}}", String(discoverProgress.total))
.replace("{{name}}", discoverProgress.neName || discoverProgress.neIp || "?")}
</span>
<span>{discoverPct}%</span>
</div>
</div>
) : null}
{discovering && discoverProgress.total <= 0 ? (
<p className="panel__hint panel__hint--live">{t("topology.discoverNoTargets")}</p>
) : null}
{!discovering && discoverReport && discoverReport.scanned === 0 ? (
<p className="panel__hint">{t("topology.discoverNoTargets")}</p>
) : null}
{discoverError ? <p className="topo-discover__error">{discoverError}</p> : null}
{hasResults ? (
<>
<p className="topo-discover__summary">
{t("topology.discoverSummary")
.replace("{{scanned}}", String(discoverSummary.scanned))
.replace("{{added}}", String(discoverSummary.added))
.replace("{{updated}}", String(discoverSummary.updated))
.replace("{{stale}}", String(discoverSummary.missing))
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<p className="panel__hint">
<Button
size="sm"
variant="ghost"
onPress={() => {
const jobId = String(discoverReport?.job_id || "").trim();
const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : "";
openOrFocusModule({
moduleId: "network",
path: `/network/topology/lldp${qs}`,
});
}}
>
{t("topology.discoverGoLldp")}
</Button>
</p>
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,315 @@
import { Button } from "@heroui/react";
import { Fragment } from "react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types";
import { useI18n } from "../../../i18n";
import {
displayViewName,
folderNeCount,
formatNeCount,
isUmeWorldContainer,
regionDisplayName,
} from "../lib/treeUtils";
import { LayerGlyph, RegionGlyph } from "./topologyIcons";
export type UmeWorldHexModules = {
drill: TopologyTreeFolderItem | null;
flatView: TopologyTreeViewItem | null;
};
export type TopologyHexBrowserProps = {
titleText: string;
breadcrumbFolders: TopologyTreeFolderItem[];
hexBrowseRegion: TopologyTreeFolderItem | null;
umeWorldHexModules: UmeWorldHexModules | null;
browseEntries: TopologyTreeViewItem[];
regions: TopologyTreeFolderItem[];
rootFolderId: string;
selectedFolderId: string;
mapId: string;
hotBrowseKey: string;
treeLoading: boolean;
treeFailed: boolean;
treeError: unknown;
createRegionPending: boolean;
onTreeRetry: () => void;
onHotBrowseKey: (key: string) => void;
onClearHotBrowseKey: (key: string) => void;
goRoot: () => void;
goRegion: (folderId: string) => void;
goUmeWorldNav: () => void;
goCanvas: (viewId: string, folderId: string) => void;
promptNewRegion: () => void;
promptNewSubRegion: () => void;
onSelectFolder: (folderId: string) => void;
};
export function TopologyHexBrowser({
titleText,
breadcrumbFolders,
hexBrowseRegion,
umeWorldHexModules,
browseEntries,
regions,
rootFolderId,
selectedFolderId,
mapId,
hotBrowseKey,
treeLoading,
treeFailed,
treeError,
createRegionPending,
onTreeRetry,
onHotBrowseKey,
onClearHotBrowseKey,
goRoot,
goRegion,
goUmeWorldNav,
goCanvas,
promptNewRegion,
promptNewSubRegion,
onSelectFolder,
}: TopologyHexBrowserProps) {
const { t } = useI18n();
const regionCountLabel = hexBrowseRegion
? isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules
? t("topology.browserRegionSub").replace(
"{{count}}",
String((umeWorldHexModules.drill ? 1 : 0) + (umeWorldHexModules.flatView ? 1 : 0)),
)
: t("topology.browserRegionSub").replace("{{count}}", String(browseEntries.length))
: t("topology.browserRegionsSub").replace("{{count}}", String(regions.length));
return (
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
<div className="topo-browser__head">
<div>
{hexBrowseRegion ? (
<div className="topo-breadcrumb">
<button type="button" className="topo-breadcrumb__link" onClick={() => goRoot()}>
{t("topology.rootName")}
</button>
{breadcrumbFolders.map((folder, idx) => {
const isLast = idx === breadcrumbFolders.length - 1;
return (
<Fragment key={folder.id}>
<span className="topo-breadcrumb__sep">/</span>
{isLast ? (
<span className="topo-breadcrumb__current">{regionDisplayName(folder, t)}</span>
) : (
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRegion(folder.id)}
>
{regionDisplayName(folder, t)}
</button>
)}
</Fragment>
);
})}
</div>
) : (
<strong>{titleText}</strong>
)}
<p className="topo-browser__sub">{regionCountLabel}</p>
</div>
</div>
{!hexBrowseRegion ? (
treeLoading ? (
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</div>
) : treeFailed ? (
<div className="topo-browser__empty">
<p>{t("topology.treeLoadFailed")}</p>
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}
<div className="topo-browser__empty-actions">
<Button size="sm" variant="ghost" onPress={onTreeRetry}>
{t("topology.treeRetry")}
</Button>
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionPending}
>
{t("topology.newRegion")}
</Button>
</div>
</div>
) : regions.length === 0 ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
<RegionGlyph size={36} />
</span>
<p>{t("topology.emptyMaps")}</p>
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionPending}
>
{t("topology.newRegion")}
</Button>
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">
{regions.map((region, idx) => (
<button
key={region.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
selectedFolderId === region.id ? " is-selected" : ""
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
onMouseEnter={() => onHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`region:${region.id}`)}
onClick={() =>
isUmeWorldContainer(region) ? goUmeWorldNav() : goRegion(region.id)
}
title={t("topology.openRegion")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{regionDisplayName(region, t)}</span>
<span className="topo-region-hex__meta">{formatNeCount(folderNeCount(region))}</span>
</span>
</button>
))}
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={promptNewRegion}
disabled={createRegionPending}
title={t("topology.newRegion")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
</button>
</div>
)
) : isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules ? (
<div className="topo-browser__grid topo-browser__grid--regions">
{umeWorldHexModules.drill ? (
<button
key={umeWorldHexModules.drill.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-0${
hotBrowseKey === `region:${umeWorldHexModules.drill.id}` ? " is-hot" : ""
}`}
onMouseEnter={() => onHotBrowseKey(`region:${umeWorldHexModules.drill!.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`region:${umeWorldHexModules.drill!.id}`)}
onClick={() => goRegion(umeWorldHexModules.drill!.id)}
title={t("topology.openRegion")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">
{regionDisplayName(umeWorldHexModules.drill, t)}
</span>
<span className="topo-region-hex__meta">
{formatNeCount(folderNeCount(umeWorldHexModules.drill))}
</span>
</span>
</button>
) : null}
{umeWorldHexModules.flatView ? (
<button
key={umeWorldHexModules.flatView.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-1${
hotBrowseKey === `view:${umeWorldHexModules.flatView.id}` ? " is-hot" : ""
}`}
onMouseEnter={() => onHotBrowseKey(`view:${umeWorldHexModules.flatView!.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`view:${umeWorldHexModules.flatView!.id}`)}
onClick={() => goCanvas(umeWorldHexModules.flatView!.id, hexBrowseRegion.id)}
title={t("topology.openMap")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{umeWorldHexModules.flatView.name}</span>
<span className="topo-region-hex__meta">
{formatNeCount(umeWorldHexModules.flatView.node_count)}
</span>
</span>
</button>
) : null}
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">
{(hexBrowseRegion.children || []).map((region, idx) => (
<button
key={region.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
selectedFolderId === region.id ? " is-selected" : ""
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
onMouseEnter={() => onHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`region:${region.id}`)}
onClick={() => goRegion(region.id)}
title={t("topology.openRegion")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{regionDisplayName(region, t)}</span>
<span className="topo-region-hex__meta">{formatNeCount(folderNeCount(region))}</span>
</span>
</button>
))}
{browseEntries.map((v, idx) => {
const isPhysical = String(v.kind) === "physical";
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
return (
<button
key={v.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${tone}${
mapId === v.id ? " is-selected" : ""
}${hotBrowseKey === `view:${v.id}` ? " is-hot" : ""}`}
onMouseEnter={() => onHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`view:${v.id}`)}
onClick={() => goCanvas(v.id, hexBrowseRegion.id)}
title={t("topology.openMap")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{displayViewName(v.name, t)}</span>
<span className="topo-region-hex__meta">{formatNeCount(v.node_count)}</span>
</span>
</button>
);
})}
{String(hexBrowseRegion.parent_id || "") !== rootFolderId ? (
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={() => {
onSelectFolder(hexBrowseRegion.id);
promptNewSubRegion();
}}
title={t("topology.newSubRegion")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newSubRegion")}</span>
</button>
) : null}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,192 @@
import type { DragEvent } from "react";
import type { TopologyOutsidePeer } from "../../../types";
import type { PaletteItem, PaletteSource } from "../lib/pageTypes";
import { AddNePaletteDialog } from "./modals/AddNePaletteDialog";
import { CreateNeModeDialog } from "./modals/CreateNeModeDialog";
import { NewRootDialog } from "./modals/NewRootDialog";
import {
PlaceholderCreateDialog,
type PlaceholderCreateDialogState,
} from "./modals/PlaceholderCreateDialog";
import { OutsidePeersDialog } from "./modals/OutsidePeersDialog";
import { ManagedNeFormDialog } from "../../managed-ne/components/ManagedNeFormDialog";
import { ManagedNeConnectDetailDialog } from "../../managed-ne/components/ManagedNeConnectDetailDialog";
import type { ManagedNeItem } from "../../../types";
import type { ManagedNeFormState } from "../../managed-ne/lib/formState";
export type { PlaceholderCreateDialogState as CreateNeDialogState };
export type TopologyModalsProps = {
canvasMode: boolean;
newRootDialog: { name: string } | null;
onNewRootNameChange: (name: string) => void;
onCloseNewRoot: () => void;
onSubmitNewRoot: () => void;
createRegionPending: boolean;
createNeModeOpen: boolean;
onCloseCreateNeMode: () => void;
onPickCreateManaged: () => void;
onPickCreatePlaceholder: () => void;
placeholderDialog: PlaceholderCreateDialogState | null;
placeholderBusy: boolean;
onPlaceholderChange: (patch: Partial<Pick<PlaceholderCreateDialogState, "name" | "ip_address">>) => void;
onClosePlaceholder: () => void;
onSubmitPlaceholder: () => void;
managedFormOpen: boolean;
managedFormInitial?: Partial<ManagedNeFormState>;
onCloseManagedForm: () => void;
onManagedFormSaved: (item: ManagedNeItem) => void;
connectDetailRow: ManagedNeItem | null;
onCloseConnectDetail: () => void;
onConnectRetestSubmitted: (rowId: string) => void;
outsidePeersOpen: boolean;
outsidePeers: TopologyOutsidePeer[];
outsidePeersVisible: TopologyOutsidePeer[];
outsidePeerQuery: string;
onOutsidePeerQueryChange: (q: string) => void;
outsidePeerSelectedIds: string[];
onOutsidePeerSelectedIdsChange: (ids: string[] | ((prev: string[]) => string[])) => void;
outsidePeerNameById: Map<string, string>;
outsidePeersAdding: boolean;
onCloseOutsidePeers: () => void;
onAddOutsidePeers: (ids: string[]) => void;
addNeOpen: boolean;
paletteSource: PaletteSource;
onPaletteSourceChange: (source: PaletteSource) => void;
keyword: string;
onKeywordChange: (keyword: string) => void;
paletteVisible: PaletteItem[];
paletteSelectedKeys: string[];
onPaletteSelectedKeysChange: (keys: string[] | ((prev: string[]) => string[])) => void;
paletteLoading: boolean;
paletteAdding: boolean;
onCloseAddNe: () => void;
onPaletteDragStart: (e: DragEvent, item: PaletteItem) => void;
onAddSelectedPalette: () => void;
};
/** Thin composer — keep individual dialogs in `modals/` / `managedNe/`. */
export function TopologyModals({
canvasMode,
newRootDialog,
onNewRootNameChange,
onCloseNewRoot,
onSubmitNewRoot,
createRegionPending,
createNeModeOpen,
onCloseCreateNeMode,
onPickCreateManaged,
onPickCreatePlaceholder,
placeholderDialog,
placeholderBusy,
onPlaceholderChange,
onClosePlaceholder,
onSubmitPlaceholder,
managedFormOpen,
managedFormInitial,
onCloseManagedForm,
onManagedFormSaved,
connectDetailRow,
onCloseConnectDetail,
onConnectRetestSubmitted,
outsidePeersOpen,
outsidePeers,
outsidePeersVisible,
outsidePeerQuery,
onOutsidePeerQueryChange,
outsidePeerSelectedIds,
onOutsidePeerSelectedIdsChange,
outsidePeerNameById,
outsidePeersAdding,
onCloseOutsidePeers,
onAddOutsidePeers,
addNeOpen,
paletteSource,
onPaletteSourceChange,
keyword,
onKeywordChange,
paletteVisible,
paletteSelectedKeys,
onPaletteSelectedKeysChange,
paletteLoading,
paletteAdding,
onCloseAddNe,
onPaletteDragStart,
onAddSelectedPalette,
}: TopologyModalsProps) {
return (
<>
<NewRootDialog
dialog={newRootDialog}
pending={createRegionPending}
onNameChange={onNewRootNameChange}
onClose={onCloseNewRoot}
onSubmit={onSubmitNewRoot}
/>
{canvasMode ? (
<>
<CreateNeModeDialog
open={createNeModeOpen}
onClose={onCloseCreateNeMode}
onPickManaged={onPickCreateManaged}
onPickPlaceholder={onPickCreatePlaceholder}
/>
<PlaceholderCreateDialog
dialog={placeholderDialog}
busy={placeholderBusy}
onChange={onPlaceholderChange}
onClose={onClosePlaceholder}
onSubmit={onSubmitPlaceholder}
/>
<ManagedNeFormDialog
open={managedFormOpen}
editing={null}
initialValues={managedFormInitial}
onClose={onCloseManagedForm}
onSaved={onManagedFormSaved}
/>
<ManagedNeConnectDetailDialog
row={connectDetailRow}
onClose={onCloseConnectDetail}
onRetestSubmitted={onConnectRetestSubmitted}
/>
<OutsidePeersDialog
open={outsidePeersOpen}
outsidePeers={outsidePeers}
outsidePeersVisible={outsidePeersVisible}
outsidePeerQuery={outsidePeerQuery}
onOutsidePeerQueryChange={onOutsidePeerQueryChange}
outsidePeerSelectedIds={outsidePeerSelectedIds}
onOutsidePeerSelectedIdsChange={onOutsidePeerSelectedIdsChange}
outsidePeerNameById={outsidePeerNameById}
outsidePeersAdding={outsidePeersAdding}
onClose={onCloseOutsidePeers}
onAddOutsidePeers={onAddOutsidePeers}
/>
<AddNePaletteDialog
open={addNeOpen}
paletteSource={paletteSource}
onPaletteSourceChange={onPaletteSourceChange}
keyword={keyword}
onKeywordChange={onKeywordChange}
paletteVisible={paletteVisible}
paletteSelectedKeys={paletteSelectedKeys}
onPaletteSelectedKeysChange={onPaletteSelectedKeysChange}
paletteLoading={paletteLoading}
paletteAdding={paletteAdding}
onClose={onCloseAddNe}
onPaletteDragStart={onPaletteDragStart}
onAddSelected={onAddSelectedPalette}
/>
</>
) : null}
</>
);
}

View file

@ -0,0 +1,576 @@
import { createContext, memo, useContext, type Dispatch, type SetStateAction } from "react";
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
ControlButton,
MiniMap,
Handle,
Position,
ConnectionLineType,
ConnectionMode,
SelectionMode,
applyNodeChanges,
applyEdgeChanges,
type Connection,
type Edge,
type Node,
type NodeChange,
type EdgeChange,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
import { ParallelEdge } from "./ParallelEdge";
import { isAggregateEdgeId } from "../lib/linkDisplay";
import type { ToolMode, ToolModeBehavior } from "../lib/toolMode";
import type { TopologyViewRole, TopologyWorldTransform } from "../../../types";
import { isPlaceholderSource } from "../lib/searchUtils";
import { nodeIconTone } from "../lib/vendorTone";
import { levelToneKey } from "../lib/levelTone";
import type { NodeAlarmSummary } from "../lib/alarmOverlay";
export { isPlaceholderSource };
export type NeNodeData = {
label: string;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
vendor: string;
connect_status: string;
managed_source?: string;
/** Fabric classify level (from graph). */
level?: number | null;
/** Fabric role alias (external|core|aggregation|access|edge). */
fabric_role?: string;
kind?: "ne" | "region" | "layer";
folder_id?: string;
view_id?: string;
role?: TopologyViewRole | string;
subtitle?: string;
node_count?: number;
};
export type TopoDisplayOpts = {
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
connectMode: boolean;
showPlaceholderBadge: boolean;
showConnectStatus: boolean;
showAlarmOverlay: boolean;
showLevelColors: boolean;
worldVisualLod: "dot" | "pin" | "full";
};
const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true,
hideVendor: true,
hidePorts: true,
connectMode: false,
showPlaceholderBadge: false,
showConnectStatus: true,
showAlarmOverlay: true,
showLevelColors: true,
worldVisualLod: "full",
});
const TopoAlarmContext = createContext<ReadonlyMap<string, NodeAlarmSummary>>(new Map());
const emptyAlarmMap: ReadonlyMap<string, NodeAlarmSummary> = new Map();
const SEP = " / ";
function normalizeConnectStatus(raw: string | undefined): "pass" | "fail" | "testing" | "unknown" {
const s = String(raw || "").trim().toLowerCase();
if (s === "pass" || s === "ok") return "pass";
if (s === "fail" || s === "error") return "fail";
if (s === "testing") return "testing";
return "unknown";
}
function RouterIcon() {
return (
<span className="topo-node__icon" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/ne-router.png" alt="" draggable={false} />
<span className="topo-node__icon-tint" />
</span>
);
}
function RegionCanvasIcon() {
return (
<span className="topo-node__icon topo-node__icon--region" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/region-building.png" alt="" draggable={false} />
</span>
);
}
function FullscreenIcon({ exit }: { exit?: boolean }) {
if (exit) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNodeData>>) {
const {
hideIp,
hideVendor,
connectMode,
showPlaceholderBadge,
showConnectStatus,
showAlarmOverlay,
showLevelColors,
worldVisualLod,
} = useContext(TopoDisplayContext);
const alarmMap = useContext(TopoAlarmContext);
const isRegion = data.kind === "region";
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
const showBadge = placeholder && showPlaceholderBadge;
const connect = normalizeConnectStatus(data.connect_status);
const levelKey = levelToneKey(data.level, data.fabric_role);
const alarm = !isRegion && showAlarmOverlay ? alarmMap.get(id) : undefined;
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
const secondary = isRegion
? []
: [
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
const statusClass = [
`topo-node--${tone}`,
selected ? "is-selected" : "",
connectMode && !isRegion ? "is-connect-mode" : "",
showBadge ? "is-placeholder" : "",
isRegion ? "is-region" : "",
showConnectStatus && !isRegion ? `is-connect-${connect}` : "",
showLevelColors && !isRegion && levelKey ? `is-level-${levelKey}` : "",
alarm ? `has-alarm has-alarm--${alarm.severity}` : "",
]
.filter(Boolean)
.join(" ");
if (!isRegion && worldVisualLod === "dot") {
return (
<div className={`topo-node topo-node--dot ${statusClass}`} title={name}>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pixel" aria-hidden="true" />
</div>
);
}
if (!isRegion && worldVisualLod === "pin") {
return (
<div className={`topo-node topo-node--pin ${statusClass}`} title={name}>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pin" aria-hidden="true" />
</div>
);
}
return (
<div
className={`topo-node ${statusClass}`}
title={
alarm
? `${name} · alarm ${alarm.severity}×${alarm.count}`
: isRegion
? name
: showBadge
? data.managed_source || "placeholder"
: name
}
>
<div className="topo-node__glyph">
<Handle
type="target"
position={Position.Left}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
<Handle
type="source"
position={Position.Right}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
{showConnectStatus && !isRegion ? (
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
) : null}
{alarm ? (
<span className={`topo-node__alarm topo-node__alarm--${alarm.severity}`} aria-hidden>
{alarm.count > 99 ? "99+" : alarm.count}
</span>
) : showBadge ? (
<span className="topo-node__badge" aria-hidden>
{String(data.managed_source || "ph").slice(0, 4)}
</span>
) : null}
</div>
<div className="topo-node__caption">
<span className="topo-node__caption-name">{name}</span>
{secondary.length ? (
<span className="topo-node__caption-meta">{secondary.join(SEP)}</span>
) : null}
</div>
</div>
);
});
const nodeTypes = { neNode: NeNode };
const edgeTypes = { topoParallel: ParallelEdge };
const SNAP_GRID: [number, number] = [16, 16];
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
function canvasBgRgb(bg: string): { r: number; g: number; b: number } | null {
const hex = String(bg || "").replace("#", "").trim();
if (hex.length !== 6) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (![r, g, b].every((n) => Number.isFinite(n))) return null;
return { r, g, b };
}
function canvasDotColor(bg: string): string {
const rgb = canvasBgRgb(bg);
if (!rgb) return "#93c5fd";
const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return lum > 0.55 ? "#93c5fd" : "#64748b";
}
/** MiniMap colors follow the canvas bg so a light map doesn't get a dark inset. */
function minimapTheme(bg: string): {
bgColor: string;
nodeColor: string;
nodeStrokeColor: string;
maskColor: string;
maskStrokeColor: string;
} {
const rgb = canvasBgRgb(bg) || { r: 11, g: 18, b: 32 };
const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
const light = lum > 0.55;
return {
bgColor: bg || (light ? "#e8eef6" : "#0b1220"),
nodeColor: "#3b82f6",
nodeStrokeColor: light ? "#93c5fd" : "#1e3a5f",
maskColor: light ? "rgba(15, 23, 42, 0.14)" : "rgba(2, 8, 23, 0.62)",
maskStrokeColor: light ? "rgba(59, 130, 246, 0.55)" : "rgba(148, 163, 184, 0.45)",
};
}
function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
x: number;
y: number;
width: number;
height: number;
} | null {
if (!wt) return null;
const scale = Number(wt.scale) || 1;
const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale);
const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return { x: 0, y: 0, width, height };
}
type CtxMenuLike =
| { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number }
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
export type TopologyReactFlowViewProps = {
displayOpts: TopoDisplayOpts;
alarmByNodeId?: ReadonlyMap<string, NodeAlarmSummary>;
nodes: Node<NeNodeData>[];
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
edges: Edge[];
setEdges: Dispatch<SetStateAction<Edge[]>>;
displayEdges: Edge[];
searchHitIds: string[];
isWorldFlatCanvas: boolean;
worldVisualLod: "dot" | "pin" | "full";
toolBehavior: ToolModeBehavior;
toolMode: ToolMode;
snapToGrid: boolean;
canvasBg: string;
worldScatter: WorldScatterPoint[];
showWorldScatter: boolean;
fullscreen: boolean;
connectHint: string;
fullscreenLabel: string;
exitFullscreenLabel: string;
worldTransform: TopologyWorldTransform | null | undefined;
nodesRef: { current: Node<NeNodeData>[] };
rfRef: { current: ReactFlowInstance<Node<NeNodeData>, Edge> | null };
pendingFitRef: { current: boolean };
markDirty: () => void;
pushHistory: () => void;
onConnect: (connection: Connection) => void;
isValidConnection: (connection: Connection | Edge) => boolean;
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
onNodeDoubleClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
focusEdge: (id: string) => void;
focusNode: (id: string, additive: boolean) => void;
clearSelection: () => void;
setCtxMenu: (menu: CtxMenuLike | null) => void;
placeCtxMenu: (
x: number,
y: number,
size?: { w?: number; h?: number },
) => { x: number; y: number };
selectedNodeIds: string[];
closeCtxMenu: () => void;
setCanvasZoom: (z: number) => void;
scheduleFlatViewportRefresh: () => void;
toggleFullscreen: () => void | Promise<void>;
readOnly?: boolean;
};
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
const {
displayOpts,
alarmByNodeId,
nodes,
setNodes,
setEdges,
displayEdges,
searchHitIds,
isWorldFlatCanvas,
worldVisualLod,
toolBehavior,
toolMode,
snapToGrid,
canvasBg,
worldScatter,
showWorldScatter,
fullscreen,
connectHint,
fullscreenLabel,
exitFullscreenLabel,
worldTransform,
nodesRef,
rfRef,
pendingFitRef,
markDirty,
pushHistory,
onConnect,
isValidConnection,
onNodeClick,
onNodeDoubleClick,
focusEdge,
focusNode,
clearSelection,
setCtxMenu,
placeCtxMenu,
selectedNodeIds,
closeCtxMenu,
setCanvasZoom,
scheduleFlatViewportRefresh,
toggleFullscreen,
} = props;
return (
<TopoDisplayContext.Provider value={displayOpts}>
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
<ReactFlow
nodes={
isWorldFlatCanvas && worldVisualLod !== "full"
? []
: nodes.map((n) => {
const isRegion = n.data.kind === "region";
const lod = isWorldFlatCanvas ? worldVisualLod : "full";
const sized =
!isRegion && lod === "dot"
? { ...n, width: 6, height: 6 }
: !isRegion && lod === "pin"
? { ...n, width: 14, height: 14 }
: n;
return searchHitIds.includes(n.id)
? { ...sized, selected: true, className: "is-search-hit" }
: { ...sized, className: undefined };
})
}
edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onlyRenderVisibleElements={isWorldFlatCanvas ? worldVisualLod === "dot" : true}
connectionMode={ConnectionMode.Loose}
connectionLineType={ConnectionLineType.Straight}
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
proOptions={{ hideAttribution: true }}
// Composed metro canvases can span 40k–70k; default 0.05 clips fitView.
minZoom={isWorldFlatCanvas || nodes.length >= 400 ? 0.002 : 0.05}
maxZoom={isWorldFlatCanvas ? 12 : 4}
nodesDraggable={
isWorldFlatCanvas
? worldVisualLod === "full" && toolBehavior.nodesDraggable
: toolBehavior.nodesDraggable
}
nodesConnectable={toolBehavior.nodesConnectable}
elementsSelectable={toolBehavior.elementsSelectable}
panOnDrag={toolBehavior.panOnDrag}
selectionOnDrag={toolBehavior.selectionOnDrag}
panOnScroll={toolBehavior.panOnScroll}
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode="Shift"
snapToGrid={snapToGrid}
snapGrid={SNAP_GRID}
onNodeDragStart={() => {
pushHistory();
}}
onNodesChange={(changes: NodeChange<Node<NeNodeData>>[]) => {
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
markDirty();
}
setNodes((nds) => applyNodeChanges(changes, nds));
}}
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
const physical = changes.filter((c) => {
if (c.type === "select") return false;
if ("id" in c && isAggregateEdgeId(String(c.id))) return false;
return true;
});
if (physical.some((c) => c.type !== "select")) {
markDirty();
}
if (physical.length) setEdges((eds) => applyEdgeChanges(physical, eds));
}}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onEdgeClick={(_e, edge) => {
setCtxMenu(null);
focusEdge(edge.id);
}}
onPaneClick={() => {
setCtxMenu(null);
clearSelection();
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id);
if (!multi) focusNode(node.id, false);
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu(multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos });
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
focusEdge(edge.id);
// Modest estimate; useLayoutEffect measures real height and clamps into state.
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 280, h: 320 });
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
}}
onSelectionContextMenu={(e) => {
e.preventDefault();
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu({ kind: "selection", ...pos });
}}
onPaneContextMenu={(e) => {
e.preventDefault();
if (!rfRef.current) return;
const flow = rfRef.current.screenToFlowPosition({
x: e.clientX,
y: e.clientY,
});
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 });
setCtxMenu({
kind: "pane",
...pos,
flowX: flow.x,
flowY: flow.y,
});
}}
onMoveStart={closeCtxMenu}
onMove={(_e, vp) => {
if (isWorldFlatCanvas) setCanvasZoom(vp.zoom);
}}
onMoveEnd={(_e, vp) => {
if (!isWorldFlatCanvas) return;
setCanvasZoom(vp.zoom);
scheduleFlatViewportRefresh();
}}
onInit={(inst) => {
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
if (pendingFitRef.current) {
pendingFitRef.current = false;
window.requestAnimationFrame(() => {
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else if (nodesRef.current.length > 0) {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
}
setCanvasZoom(inst.getZoom());
});
}
}}
fitView={false}
deleteKeyCode={null}
edgesFocusable
>
<WorldScatterLayer
points={worldScatter}
mode={worldVisualLod === "pin" ? "pin" : "dot"}
visible={showWorldScatter}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color={canvasDotColor(canvasBg)}
bgColor={canvasBg}
/>
{toolMode === "connect" ? (
<div className="topo-mode-hint" role="status">
{connectHint}
</div>
) : null}
<Controls showInteractive>
<ControlButton
className="topo-fs-control"
onClick={() => void toggleFullscreen()}
title={fullscreen ? exitFullscreenLabel : fullscreenLabel}
aria-label={fullscreen ? exitFullscreenLabel : fullscreenLabel}
>
<FullscreenIcon exit={fullscreen} />
</ControlButton>
</Controls>
{!isWorldFlatCanvas ? (
<MiniMap
pannable
zoomable
{...minimapTheme(canvasBg)}
nodeStrokeWidth={1}
maskStrokeWidth={1}
/>
) : null}
</ReactFlow>
</TopoAlarmContext.Provider>
</TopoDisplayContext.Provider>
);
}

View file

@ -0,0 +1,246 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { Button, Input } from "@heroui/react";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../../types";
import { useI18n } from "../../../i18n";
import { WORLD_MAP_ENABLED } from "../lib/constants";
import { isWorldFlatViewName } from "../lib/treeUtils";
import { PlusIcon, SidebarFoldIcon } from "./topologyIcons";
import {
TopologyTreeNavFolder,
type TopologyTreeNavFolderProps,
} from "./TopologyTreeNavFolder";
export type TopologySidebarProps = {
collapsed: boolean;
resizing: boolean;
treeSearchRef: RefObject<HTMLDivElement | null>;
treeNeQuery: string;
onTreeNeQueryChange: (value: string) => void;
treeSearchOpen: boolean;
onTreeSearchOpen: (open: boolean) => void;
debouncedTreeNeQuery: string;
treeSearchFetching: boolean;
treeSearchItems: FabricNodeSearchHit[];
treeSearchTotal: number;
onJumpToSearchHit: (
hit: FabricNodeSearchHit,
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
) => void;
promptNewRegion: () => void;
createRegionPending: boolean;
onCollapse: () => void;
onExpand: () => void;
treeRoot: TopologyTreeFolderItem | null | undefined;
treeLoading: boolean;
treeFailed: boolean;
treeError: unknown;
onTreeRetry: () => void;
regions: TopologyTreeFolderItem[];
treeNav: Omit<TopologyTreeNavFolderProps, "folder" | "depth">;
onResizeDown: (e: ReactMouseEvent<HTMLDivElement>) => void;
};
export function TopologySidebar({
collapsed,
resizing,
treeSearchRef,
treeNeQuery,
onTreeNeQueryChange,
treeSearchOpen,
onTreeSearchOpen,
debouncedTreeNeQuery,
treeSearchFetching,
treeSearchItems,
treeSearchTotal,
onJumpToSearchHit,
promptNewRegion,
createRegionPending,
onCollapse,
onExpand,
treeRoot,
treeLoading,
treeFailed,
treeError,
onTreeRetry,
regions,
treeNav,
onResizeDown,
}: TopologySidebarProps) {
const { t } = useI18n();
return (
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
{collapsed ? (
<div className="topo-sidebar__rail-wrap">
<Button
isIconOnly
variant="ghost"
className="topo-sidebar__rail"
aria-label={t("topology.expandSidebar")}
onPress={onExpand}
>
<span className="topo-sidebar__rail-icon" aria-hidden="true">
<SidebarFoldIcon expand />
</span>
</Button>
</div>
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar">
<Input
className="input"
type="search"
value={treeNeQuery}
placeholder={t("topology.treeSearchPh")}
aria-label={t("topology.treeSearch")}
onChange={(e) => {
onTreeNeQueryChange(e.target.value);
onTreeSearchOpen(true);
}}
onFocus={() => onTreeSearchOpen(true)}
/>
<Button
isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn"
onPress={promptNewRegion}
isDisabled={createRegionPending}
aria-label={t("topology.newRegion")}
>
<PlusIcon />
</Button>
<Button
isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn"
aria-label={t("topology.collapseSidebar")}
onPress={onCollapse}
>
<SidebarFoldIcon />
</Button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox">
{debouncedTreeNeQuery.length < 1 || treeSearchFetching ? (
<p className="topo-tree-search__hint muted">…</p>
) : !treeSearchItems.length ? (
<p className="topo-tree-search__hint muted">{t("topology.treeSearchEmpty")}</p>
) : (
treeSearchItems.map((hit) => {
const views = hit.views || [];
const title = hit.name || hit.ip || hit.id.slice(0, 8);
return (
<div key={hit.id} className="topo-tree-search__item">
<button
type="button"
className="topo-tree-search__ne"
onClick={() => onJumpToSearchHit(hit)}
title={
views.length
? t("topology.locateOnCanvas")
: t("topology.treeSearchNotOnMap")
}
>
<span className="topo-tree-search__name">{title}</span>
{hit.ip ? (
<span className="topo-tree-search__ip muted">{hit.ip}</span>
) : null}
{!views.length ? (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchNoViews")}
</span>
) : (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchViewCount").replace(
"{{count}}",
String(views.length),
)}
</span>
)}
</button>
{views.length > 0 ? (
<div className="topo-tree-search__views">
{views
.filter(
(v) => WORLD_MAP_ENABLED || !isWorldFlatViewName(v.view_name),
)
.map((v) => (
<button
key={`${hit.id}-${v.view_id}`}
type="button"
className="topo-tree-search__view"
onClick={() => onJumpToSearchHit(hit, v)}
title={`${v.folder_name ? `${v.folder_name} / ` : ""}${v.view_name}`}
>
{v.folder_name
? `${v.folder_name} / ${v.view_name || v.view_id.slice(0, 8)}`
: v.view_name || v.view_id.slice(0, 8)}
</button>
))}
</div>
) : null}
</div>
);
})
)}
{treeSearchTotal > 30 ? (
<p className="topo-tree-search__hint muted">
{t("topology.treeSearchTruncated").replace(
"{{total}}",
String(treeSearchTotal),
)}
</p>
) : null}
</div>
) : null}
</div>
{!treeRoot ? (
<p className="panel__hint topo-tree-status" role="status">
{treeLoading ? (
<>
<span className="topo-loading-spinner" aria-hidden="true" />
{t("topology.treeLoading")}
</>
) : treeFailed ? (
<>
{t("topology.treeLoadFailed")}
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}{" "}
<Button size="sm" variant="ghost" onPress={onTreeRetry}>
{t("topology.treeRetry")}
</Button>
</>
) : (
t("topology.emptyMaps")
)}
</p>
) : regions.length === 0 ? (
<p className="panel__hint">{t("topology.emptyMaps")}</p>
) : (
<div className="topo-region-list-scroll">
<ul className="topo-map-list topo-region-list">
{regions.map((region) => (
<TopologyTreeNavFolder key={region.id} folder={region} depth={0} {...treeNav} />
))}
</ul>
</div>
)}
</div>
</>
)}
{!collapsed ? (
<div
className={`topo-sidebar__resizer${resizing ? " is-dragging" : ""}`}
role="separator"
aria-orientation="vertical"
aria-label={t("topology.resizeSidebar")}
title={t("topology.resizeSidebar")}
onMouseDown={onResizeDown}
/>
) : null}
</aside>
);
}

View file

@ -0,0 +1,376 @@
import { Button, Dropdown } from "@heroui/react";
import { Fragment, type Key, type RefObject, useMemo } from "react";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types";
import { HelpHint } from "../../../components/HelpHint";
import { useI18n } from "../../../i18n";
import { displayViewName, isRegionCanvasFolder, regionDisplayName } from "../lib/treeUtils";
import type { NeNodeData } from "./TopologyReactFlowView";
import type { ToolMode } from "../lib/toolMode";
export type TopologyToolbarProps = {
readOnly?: boolean;
breadcrumbFolders: TopologyTreeFolderItem[];
activeView: TopologyTreeViewItem | null | undefined;
activeRegion: TopologyTreeFolderItem | null | undefined;
rootFolderId: string;
dirty: boolean;
selectedNodes: Node<NeNodeData>[];
selectedEdgeId: string | null;
liveSync: boolean;
onLiveSyncToggle: () => void;
isWorldFlatCanvas: boolean;
onAddNe: () => void;
onCreateNe: (flowX: number, flowY: number) => void;
nodes: Node<NeNodeData>[];
rfRef: RefObject<ReactFlowInstance<Node<NeNodeData>, Edge> | null>;
onBackUp: () => void;
canUndo: boolean;
onUndo: () => void;
canRedo: boolean;
onRedo: () => void;
savePending: boolean;
onSave: () => void;
onFit: () => void;
exporting: boolean;
onExport: (kind: "svg" | "xml") => void;
mapId: string;
staleEdgeCount: number;
onRemoveStale: () => void;
toolMode: ToolMode;
onToolModeChange: (mode: ToolMode) => void;
onConnectClickReset: () => void;
fullscreen: boolean;
viewToolsToolbarSlotRef: (el: HTMLDivElement | null) => void;
outsidePeerCount: number;
onOpenOutsidePeers: () => void;
goRoot: () => void;
goRegion: (folderId: string) => void;
primaryViewOfFolder: (
folder: TopologyTreeFolderItem | null | undefined,
) => TopologyTreeViewItem | null | undefined;
};
type MoreActionKey =
| "live-sync"
| "back"
| "undo"
| "redo"
| "fit"
| "export-svg"
| "export-xml"
| "remove-stale";
export function TopologyToolbar({
readOnly = false,
breadcrumbFolders,
activeView,
activeRegion,
rootFolderId,
dirty,
selectedNodes,
selectedEdgeId,
liveSync,
onLiveSyncToggle,
isWorldFlatCanvas,
onAddNe,
onCreateNe,
nodes,
rfRef,
onBackUp,
canUndo,
onUndo,
canRedo,
onRedo,
savePending,
onSave,
onFit,
exporting,
onExport,
mapId,
staleEdgeCount,
onRemoveStale,
toolMode,
onToolModeChange,
onConnectClickReset,
fullscreen,
viewToolsToolbarSlotRef,
outsidePeerCount,
onOpenOutsidePeers,
goRoot,
goRegion,
primaryViewOfFolder,
}: TopologyToolbarProps) {
const { t } = useI18n();
const canvasEmpty = !isWorldFlatCanvas && nodes.length === 0;
const moreDisabledKeys = useMemo(() => {
const keys: MoreActionKey[] = [];
if (readOnly || !canUndo) keys.push("undo");
if (readOnly || !canRedo) keys.push("redo");
if (canvasEmpty) keys.push("fit");
if (exporting || canvasEmpty) keys.push("export-svg");
if (exporting || !mapId) keys.push("export-xml");
if (readOnly || !staleEdgeCount) keys.push("remove-stale");
return keys;
}, [
readOnly,
canUndo,
canRedo,
canvasEmpty,
exporting,
mapId,
staleEdgeCount,
]);
const handleCreateNe = () => {
let flowX = 80 + nodes.length * 24;
let flowY = 80 + nodes.length * 24;
if (rfRef.current) {
const pane = document.querySelector(".react-flow__pane");
const rect = pane?.getBoundingClientRect();
if (rect && rect.width > 0 && rect.height > 0) {
const center = rfRef.current.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
flowX = center.x;
flowY = center.y;
}
}
onCreateNe(flowX, flowY);
};
const handleMoreAction = (key: Key) => {
switch (key as MoreActionKey) {
case "live-sync":
onLiveSyncToggle();
break;
case "back":
onBackUp();
break;
case "undo":
onUndo();
break;
case "redo":
onRedo();
break;
case "fit":
onFit();
break;
case "export-svg":
void onExport("svg");
break;
case "export-xml":
void onExport("xml");
break;
case "remove-stale":
void onRemoveStale();
break;
default:
break;
}
};
return (
<div className="topo-toolbar">
<div className="topo-toolbar__row">
<div className="topo-toolbar__title">
<div className="topo-breadcrumb">
<Button
type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link"
onPress={() => goRoot()}
>
{t("topology.rootName")}
</Button>
{breadcrumbFolders.map((folder, idx) => {
const isLast = idx === breadcrumbFolders.length - 1;
const primaryView = primaryViewOfFolder(folder);
const showExtraView =
isLast &&
Boolean(activeView) &&
!(
isRegionCanvasFolder(folder, rootFolderId) &&
primaryView?.id === activeView?.id
);
const asCurrent = isLast && !showExtraView;
return (
<Fragment key={folder.id}>
<span className="topo-breadcrumb__sep">/</span>
{asCurrent ? (
<span className="topo-breadcrumb__current">
{regionDisplayName(folder, t)}
{dirty ? " *" : ""}
</span>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link"
onPress={() => goRegion(folder.id)}
>
{regionDisplayName(folder, t)}
</Button>
)}
</Fragment>
);
})}
{activeView &&
!(
activeRegion &&
isRegionCanvasFolder(activeRegion, rootFolderId) &&
primaryViewOfFolder(activeRegion)?.id === activeView.id
) ? (
<>
<span className="topo-breadcrumb__sep">/</span>
<span className="topo-breadcrumb__current">
{displayViewName(activeView.name, t)}
{dirty ? " *" : ""}
</span>
</>
) : null}
</div>
{selectedNodes.length > 0 || selectedEdgeId ? (
<span className="topo-toolbar__meta">
{selectedNodes.length > 0
? t("topology.selectedCount").replace("{{count}}", String(selectedNodes.length))
: t("topology.selectedEdge")}
</span>
) : null}
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
</div>
<div className="topo-toolbar__actions topo-toolbar__primary">
<Button
type="button"
size="sm"
variant="secondary"
isDisabled={readOnly || isWorldFlatCanvas}
onPress={onAddNe}
>
{t("topology.addNe")}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
isDisabled={readOnly || isWorldFlatCanvas}
onPress={handleCreateNe}
>
{t("topology.createNe")}
</Button>
<Button
type="button"
size="sm"
className={dirty ? "topo-toolbar__save topo-toolbar__save--dirty" : "topo-toolbar__save"}
variant={dirty ? "primary" : "ghost"}
isDisabled={readOnly || savePending || !dirty}
onPress={onSave}
>
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</Button>
<Dropdown>
<Button type="button" size="sm" variant="ghost" aria-label={t("topology.more")}>
{t("topology.more")}
</Button>
<Dropdown.Popover className="topo-toolbar__more-popover" placement="bottom end">
<Dropdown.Menu
aria-label={t("topology.more")}
disabledKeys={moreDisabledKeys}
onAction={handleMoreAction}
>
<Dropdown.Item id="live-sync" textValue={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</Dropdown.Item>
<Dropdown.Item id="back" textValue={t("topology.backUp")}>
{t("topology.backUp")}
</Dropdown.Item>
<Dropdown.Item id="undo" textValue={t("topology.undo")}>
{t("topology.undo")}
</Dropdown.Item>
<Dropdown.Item id="redo" textValue={t("topology.redo")}>
{t("topology.redo")}
</Dropdown.Item>
<Dropdown.Item id="fit" textValue={t("topology.fit")}>
{t("topology.fit")}
</Dropdown.Item>
<Dropdown.Item
id="export-svg"
textValue={exporting ? t("topology.exporting") : t("topology.exportSvg")}
>
{exporting ? t("topology.exporting") : t("topology.exportSvg")}
</Dropdown.Item>
<Dropdown.Item
id="export-xml"
textValue={exporting ? t("topology.exporting") : t("topology.exportXml")}
>
{exporting ? t("topology.exporting") : t("topology.exportXml")}
</Dropdown.Item>
<Dropdown.Item
id="remove-stale"
textValue={t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</div>
<div className="topo-toolbar__row topo-toolbar__row--tools">
<div className="topo-tools" role="toolbar" aria-label={t("topology.toolModes")}>
{(
[
["select", t("topology.toolSelect"), "V"],
["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"],
] as const
).map(([mode, label, key]) => {
const active = toolMode === mode;
return (
<Button
key={mode}
type="button"
className={`topo-tools__btn${active ? " is-active" : ""}`}
variant={active ? "primary" : "ghost"}
size="sm"
aria-pressed={active}
isDisabled={readOnly && mode === "connect"}
onPress={() => {
onToolModeChange(mode);
onConnectClickReset();
}}
>
<span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</Button>
);
})}
</div>
{!fullscreen ? (
<div
className="topo-toolbar__cluster"
role="toolbar"
aria-label={t("topology.display")}
ref={viewToolsToolbarSlotRef}
/>
) : null}
{outsidePeerCount > 0 ? (
<Button
type="button"
size="sm"
variant="ghost"
className="topo-toolbar__outside-peers"
onPress={onOpenOutsidePeers}
>
{t("topology.outsidePeersView").replace("{{count}}", String(outsidePeerCount))}
</Button>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,393 @@
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types";
import { useI18n } from "../../../i18n";
import { WORLD_MAP_ENABLED } from "../lib/constants";
import {
displayViewName,
folderNeCount,
formatNeCount,
isManualRootMapFolder,
isRegionCanvasFolder,
isUmeStructuralFolder,
isUmeSyncedSubRegion,
isUmeWorldNavFolder,
isWorldDrillFolder,
isWorldFlatViewName,
regionDisplayName,
} from "../lib/treeUtils";
import {
ChevronIcon,
CloseIcon,
LayerGlyph,
PencilIcon,
RegionGlyph,
} from "./topologyIcons";
export type TopologyTreeNavFolderProps = {
folder: TopologyTreeFolderItem;
depth: number;
treeRootId: string;
expandedIds: Record<string, boolean>;
mapId: string;
worldViewId: string;
selectedFolderId: string;
hotBrowseKey: string;
dirty: boolean;
onToggleExpand: (folderId: string, nextOpen: boolean) => void;
onHotBrowseKey: (key: string) => void;
onClearHotBrowseKey: (key: string) => void;
onWorldFocusClear: () => void;
goUmeWorldNav: () => void;
goRegion: (folderId: string) => void;
goCanvas: (viewId: string, folderId: string) => void;
promptRenameRegion: (folderId: string, name: string) => void;
promptRenameMap: (viewId: string, name: string) => void;
renameRegionPending: boolean;
renameMapPending: boolean;
onDeleteFolder: (folderId: string) => void;
deleteFolderPending: boolean;
onDeleteMap: (viewId: string) => void;
};
function ViewRow({
v,
folder,
umeNav,
mapId,
dirty,
hotBrowseKey,
renameMapPending,
onHotBrowseKey,
onClearHotBrowseKey,
onWorldFocusClear,
goCanvas,
promptRenameMap,
onDeleteMap,
}: {
v: TopologyTreeViewItem;
folder: TopologyTreeFolderItem;
umeNav: boolean;
mapId: string;
dirty: boolean;
hotBrowseKey: string;
renameMapPending: boolean;
onHotBrowseKey: (key: string) => void;
onClearHotBrowseKey: (key: string) => void;
onWorldFocusClear: () => void;
goCanvas: (viewId: string, folderId: string) => void;
promptRenameMap: (viewId: string, name: string) => void;
onDeleteMap: (viewId: string) => void;
}) {
const { t } = useI18n();
const isPhysical = String(v.kind) === "physical";
const viewHot = hotBrowseKey === `view:${v.id}`;
const viewActive = mapId === v.id;
return (
<li
key={v.id}
className={`topo-region-list__view${viewActive ? " is-active" : ""}${viewHot ? " is-hot" : ""}`}
>
<div
className="topo-map-list__row"
onMouseEnter={() => onHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`view:${v.id}`)}
>
<span className="topo-region-list__chevron-spacer" aria-hidden="true" />
<button
type="button"
className="topo-map-list__item"
onClick={() => {
onWorldFocusClear();
goCanvas(v.id, folder.id);
}}
onDoubleClick={() => (!umeNav ? promptRenameMap(v.id, v.name) : undefined)}
title={!umeNav ? t("topology.renameHint") : undefined}
>
<span className="topo-map-list__name">
<span
className={`topo-region-list__glyph topo-dir__icon--${
isPhysical ? "core" : "aggregation"
}`}
aria-hidden="true"
>
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={13} />
</span>
<span className="topo-map-list__title">
{displayViewName(v.name, t)}
{viewActive && dirty ? " *" : ""}
</span>
<span className="topo-map-list__count">{formatNeCount(v.node_count)}</span>
</span>
</button>
{!umeNav ? (
<div className="topo-map-list__actions">
<button
type="button"
className="topo-map-list__icon"
title={t("topology.rename")}
aria-label={t("topology.rename")}
disabled={renameMapPending}
onClick={() => promptRenameMap(v.id, v.name)}
>
<PencilIcon />
</button>
<button
type="button"
className="topo-map-list__icon"
title={t("topology.deleteMap")}
aria-label={t("topology.deleteMap")}
disabled={isPhysical}
onClick={() => {
const msg = t("topology.deleteMapConfirm").replace("{{name}}", v.name);
if (window.confirm(msg)) onDeleteMap(v.id);
}}
>
<CloseIcon />
</button>
</div>
) : null}
</div>
</li>
);
}
export function TopologyTreeNavFolder({
folder,
depth,
treeRootId,
expandedIds,
mapId,
worldViewId,
selectedFolderId,
hotBrowseKey,
dirty,
onToggleExpand,
onHotBrowseKey,
onClearHotBrowseKey,
onWorldFocusClear,
goUmeWorldNav,
goRegion,
goCanvas,
promptRenameRegion,
promptRenameMap,
renameRegionPending,
renameMapPending,
onDeleteFolder,
deleteFolderPending,
onDeleteMap,
}: TopologyTreeNavFolderProps) {
const { t } = useI18n();
const kids = folder.children || [];
const containerFolder = folder.external_ref === "ume:world" || folder.name === "UME World";
const drillFolder = isWorldDrillFolder(folder);
const umeNav = isUmeWorldNavFolder(folder);
const canvasRegion = isRegionCanvasFolder(folder, treeRootId);
const visibleViews = (
canvasRegion
? (folder.views || []).filter((v) => String(v.kind) !== "physical")
: containerFolder
? (folder.views || []).filter((v) => isWorldFlatViewName(v.name))
: folder.views || []
).filter((v) => WORLD_MAP_ENABLED || !isWorldFlatViewName(v.name));
const hasKids = kids.length > 0 || visibleViews.length > 0;
const open = Boolean(expandedIds[folder.id]);
const viewActiveUnder =
(folder.views || []).some((v) => v.id === mapId) ||
(drillFolder && Boolean(worldViewId) && mapId === worldViewId);
const isSelected = selectedFolderId === folder.id;
const focused = viewActiveUnder || (isSelected && Boolean(mapId));
const regionActive = isSelected && !mapId;
const hot = hotBrowseKey === `region:${folder.id}`;
const canRenameFolder =
!isUmeStructuralFolder(folder) && (!folder.is_system || isManualRootMapFolder(folder));
const canDeleteFolder =
!isUmeStructuralFolder(folder) &&
!isManualRootMapFolder(folder) &&
(!folder.is_system || isUmeSyncedSubRegion(folder));
return (
<li
key={folder.id}
className={`topo-region-list__block${focused ? " is-branch-active" : ""}${
regionActive ? " is-active" : ""
}${hot ? " is-hot" : ""}`}
>
<div
className="topo-map-list__row topo-region-list__row"
onMouseEnter={() => onHotBrowseKey(`region:${folder.id}`)}
onMouseLeave={() => onClearHotBrowseKey(`region:${folder.id}`)}
>
<button
type="button"
className="topo-map-list__icon topo-region-list__chevron"
title={open ? t("topology.collapseRegion") : t("topology.expandRegion")}
aria-label={open ? t("topology.collapseRegion") : t("topology.expandRegion")}
aria-expanded={open}
disabled={!hasKids}
onClick={(e) => {
e.stopPropagation();
if (!hasKids) return;
onToggleExpand(folder.id, !open);
}}
>
<ChevronIcon open={open} />
</button>
<button
type="button"
className="topo-map-list__item"
onClick={() => {
if (containerFolder) {
goUmeWorldNav();
} else {
goRegion(folder.id);
}
}}
title={t("topology.openRegion")}
>
<span className="topo-map-list__name">
<span className="topo-region-list__glyph" aria-hidden="true">
<RegionGlyph size={14} />
</span>
<span className="topo-map-list__title">{regionDisplayName(folder, t)}</span>
<span className="topo-map-list__count">{formatNeCount(folderNeCount(folder))}</span>
</span>
</button>
{canRenameFolder || canDeleteFolder ? (
<div className="topo-map-list__actions">
{canRenameFolder ? (
<button
type="button"
className="topo-map-list__icon"
title={t("topology.renameRegion")}
aria-label={t("topology.renameRegion")}
disabled={renameRegionPending}
onClick={() => promptRenameRegion(folder.id, folder.name)}
>
<PencilIcon />
</button>
) : null}
{canDeleteFolder ? (
<button
type="button"
className="topo-map-list__icon"
title={t("topology.deleteRegion")}
aria-label={t("topology.deleteRegion")}
disabled={deleteFolderPending}
onClick={() => {
const msg = t("topology.deleteRegionConfirm").replace("{{name}}", folder.name);
if (window.confirm(msg)) onDeleteFolder(folder.id);
}}
>
<CloseIcon />
</button>
) : null}
</div>
) : null}
</div>
{open ? (
<ul className="topo-map-list topo-region-list__maps">
{containerFolder ? (
<>
{kids.map((child) => (
<TopologyTreeNavFolder
key={child.id}
folder={child}
depth={depth + 1}
treeRootId={treeRootId}
expandedIds={expandedIds}
mapId={mapId}
worldViewId={worldViewId}
selectedFolderId={selectedFolderId}
hotBrowseKey={hotBrowseKey}
dirty={dirty}
onToggleExpand={onToggleExpand}
onHotBrowseKey={onHotBrowseKey}
onClearHotBrowseKey={onClearHotBrowseKey}
onWorldFocusClear={onWorldFocusClear}
goUmeWorldNav={goUmeWorldNav}
goRegion={goRegion}
goCanvas={goCanvas}
promptRenameRegion={promptRenameRegion}
promptRenameMap={promptRenameMap}
renameRegionPending={renameRegionPending}
renameMapPending={renameMapPending}
onDeleteFolder={onDeleteFolder}
deleteFolderPending={deleteFolderPending}
onDeleteMap={onDeleteMap}
/>
))}
{visibleViews.map((v) => (
<ViewRow
key={v.id}
v={v}
folder={folder}
umeNav={umeNav}
mapId={mapId}
dirty={dirty}
hotBrowseKey={hotBrowseKey}
renameMapPending={renameMapPending}
onHotBrowseKey={onHotBrowseKey}
onClearHotBrowseKey={onClearHotBrowseKey}
onWorldFocusClear={onWorldFocusClear}
goCanvas={goCanvas}
promptRenameMap={promptRenameMap}
onDeleteMap={onDeleteMap}
/>
))}
</>
) : (
<>
{visibleViews.map((v) => (
<ViewRow
key={v.id}
v={v}
folder={folder}
umeNav={umeNav}
mapId={mapId}
dirty={dirty}
hotBrowseKey={hotBrowseKey}
renameMapPending={renameMapPending}
onHotBrowseKey={onHotBrowseKey}
onClearHotBrowseKey={onClearHotBrowseKey}
onWorldFocusClear={onWorldFocusClear}
goCanvas={goCanvas}
promptRenameMap={promptRenameMap}
onDeleteMap={onDeleteMap}
/>
))}
{kids.map((child) => (
<TopologyTreeNavFolder
key={child.id}
folder={child}
depth={depth + 1}
treeRootId={treeRootId}
expandedIds={expandedIds}
mapId={mapId}
worldViewId={worldViewId}
selectedFolderId={selectedFolderId}
hotBrowseKey={hotBrowseKey}
dirty={dirty}
onToggleExpand={onToggleExpand}
onHotBrowseKey={onHotBrowseKey}
onClearHotBrowseKey={onClearHotBrowseKey}
onWorldFocusClear={onWorldFocusClear}
goUmeWorldNav={goUmeWorldNav}
goRegion={goRegion}
goCanvas={goCanvas}
promptRenameRegion={promptRenameRegion}
promptRenameMap={promptRenameMap}
renameRegionPending={renameRegionPending}
renameMapPending={renameMapPending}
onDeleteFolder={onDeleteFolder}
deleteFolderPending={deleteFolderPending}
onDeleteMap={onDeleteMap}
/>
))}
</>
)}
</ul>
) : null}
</li>
);
}

View file

@ -0,0 +1,615 @@
import { Button, Input } from "@heroui/react";
import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { Node } from "@xyflow/react";
import { createPortal } from "react-dom";
import { queryKeys } from "../../../constants/queryKeys";
import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import { updateLldpCollectPolicy } from "../../../services/api";
import {
DEFAULT_CANVAS_BG,
DISCOVER_AUTO_ADD_KEY,
DISCOVER_PROJECT_NEIGHBORS_KEY,
SCALE_BUNDLE_WIDTH_KEY,
SEP,
SHOW_PLACEHOLDER_BADGE_KEY,
SHOW_CONNECT_STATUS_KEY,
SHOW_ALARM_OVERLAY_KEY,
SHOW_LEVEL_COLORS_KEY,
} from "../lib/constants";
import {
DEFAULT_LABEL_COLORS,
DEFAULT_VENDOR_COLORS,
VENDOR_TONE_KEYS,
isHexColor,
loadCanvasBg,
loadLabelColors,
loadVendorColors,
persistAutoLayoutAfterDiscover,
persistBoolFlag,
persistCanvasBg,
persistLabelColors,
persistVendorColors,
type LabelColors,
type VendorColors,
} from "../lib/displayPrefs";
import type { EdgeDefaultStyle, EdgeDefaults, EdgeLineStyle, EdgeSourceKind } from "../lib/edgeStyle";
import type { alignNodes } from "../lib/layoutGraph";
import type { NeNodeData } from "./TopologyReactFlowView";
export type AlignKind = Parameters<typeof alignNodes>[2];
export type TopologyViewToolsProps = {
fullscreen: boolean;
toolbarSlot: HTMLDivElement | null;
displayMenuRef: RefObject<HTMLDetailsElement | null>;
findBoxRef: RefObject<HTMLDivElement | null>;
hideIp: boolean;
onHideIpChange: (v: boolean) => void;
hideVendor: boolean;
onHideVendorChange: (v: boolean) => void;
showPlaceholderBadge: boolean;
onShowPlaceholderBadgeChange: (v: boolean) => void;
showConnectStatus: boolean;
onShowConnectStatusChange: (v: boolean) => void;
showAlarmOverlay: boolean;
onShowAlarmOverlayChange: (v: boolean) => void;
showLevelColors: boolean;
onShowLevelColorsChange: (v: boolean) => void;
hidePorts: boolean;
onHidePortsChange: (v: boolean) => void;
expandPhysicalLinks: boolean;
onExpandPhysicalLinksChange: (v: boolean) => void;
scaleBundleWidth: boolean;
onScaleBundleWidthChange: (v: boolean) => void;
edgeFlow: boolean;
onEdgeFlowChange: (v: boolean) => void;
snapToGrid: boolean;
onSnapToGridChange: (v: boolean) => void;
autoLayoutAfterDiscover: boolean;
onAutoLayoutAfterDiscoverChange: (v: boolean) => void;
discoverAutoAddUnmatched: boolean;
onDiscoverAutoAddUnmatchedChange: (v: boolean) => void;
discoverProjectNeighbors: boolean;
onDiscoverProjectNeighborsChange: (v: boolean) => void;
canvasBg: string;
onCanvasBgChange: (v: string) => void;
labelColors: LabelColors;
onLabelColorsChange: (v: LabelColors) => void;
vendorColors: VendorColors;
onVendorColorsChange: (v: VendorColors) => void;
edgeDefaults: EdgeDefaults;
onUpdateEdgeDefault: (kind: EdgeSourceKind, patch: Partial<EdgeDefaultStyle>) => void;
onResetEdgeDefaults: () => void;
selectedNodeCount: number;
onAlign: (kind: AlignKind) => void;
mapId: string;
nodeCount: number;
canvasQuery: string;
onCanvasQueryChange: (v: string) => void;
findOpen: boolean;
onFindOpenChange: (v: boolean) => void;
findActiveIdx: number;
onFindActiveIdxChange: (v: number | ((i: number) => number)) => void;
canvasHits: Node<NeNodeData>[];
onFindOnCanvas: (nodeId?: string) => void;
};
export function TopologyViewTools(props: TopologyViewToolsProps) {
const { t } = useI18n();
const { showError } = useToast();
const queryClient = useQueryClient();
const {
fullscreen,
toolbarSlot,
displayMenuRef,
findBoxRef,
hideIp,
onHideIpChange,
hideVendor,
onHideVendorChange,
showPlaceholderBadge,
onShowPlaceholderBadgeChange,
showConnectStatus,
onShowConnectStatusChange,
showAlarmOverlay,
onShowAlarmOverlayChange,
showLevelColors,
onShowLevelColorsChange,
hidePorts,
onHidePortsChange,
expandPhysicalLinks,
onExpandPhysicalLinksChange,
scaleBundleWidth,
onScaleBundleWidthChange,
edgeFlow,
onEdgeFlowChange,
snapToGrid,
onSnapToGridChange,
autoLayoutAfterDiscover,
onAutoLayoutAfterDiscoverChange,
discoverAutoAddUnmatched,
onDiscoverAutoAddUnmatchedChange,
discoverProjectNeighbors,
onDiscoverProjectNeighborsChange,
canvasBg,
onCanvasBgChange,
labelColors,
onLabelColorsChange,
vendorColors,
onVendorColorsChange,
edgeDefaults,
onUpdateEdgeDefault,
onResetEdgeDefaults,
selectedNodeCount,
onAlign,
mapId,
nodeCount,
canvasQuery,
onCanvasQueryChange,
findOpen,
onFindOpenChange,
findActiveIdx,
onFindActiveIdxChange,
canvasHits,
onFindOnCanvas,
} = props;
const body = (
<>
<details className="topo-toolbar__display" ref={displayMenuRef}>
<summary>{t("topology.display")}</summary>
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
<label className="topo-display-toggles__item">
<input type="checkbox" checked={hideIp} onChange={(e) => onHideIpChange(e.target.checked)} />
{t("topology.hideIp")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hideVendor}
onChange={(e) => onHideVendorChange(e.target.checked)}
/>
{t("topology.hideVendor")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={showPlaceholderBadge}
onChange={(e) => {
const next = e.target.checked;
onShowPlaceholderBadgeChange(next);
persistBoolFlag(SHOW_PLACEHOLDER_BADGE_KEY, next);
}}
/>
{t("topology.showPlaceholderBadge")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={showConnectStatus}
onChange={(e) => {
const next = e.target.checked;
onShowConnectStatusChange(next);
persistBoolFlag(SHOW_CONNECT_STATUS_KEY, next);
}}
/>
{t("topology.showConnectStatus")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={showAlarmOverlay}
onChange={(e) => {
const next = e.target.checked;
onShowAlarmOverlayChange(next);
persistBoolFlag(SHOW_ALARM_OVERLAY_KEY, next);
}}
/>
{t("topology.showAlarmOverlay")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={showLevelColors}
onChange={(e) => {
const next = e.target.checked;
onShowLevelColorsChange(next);
persistBoolFlag(SHOW_LEVEL_COLORS_KEY, next);
}}
/>
{t("topology.showLevelColors")}
</label>
<label className="topo-display-toggles__item">
<input type="checkbox" checked={hidePorts} onChange={(e) => onHidePortsChange(e.target.checked)} />
{t("topology.hidePorts")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={expandPhysicalLinks}
onChange={(e) => onExpandPhysicalLinksChange(e.target.checked)}
/>
{t("topology.expandPhysicalLinks")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={scaleBundleWidth}
disabled={expandPhysicalLinks}
onChange={(e) => {
const next = e.target.checked;
onScaleBundleWidthChange(next);
persistBoolFlag(SCALE_BUNDLE_WIDTH_KEY, next);
}}
/>
{t("topology.scaleBundleWidth")}
</label>
<label className="topo-display-toggles__item">
<input type="checkbox" checked={edgeFlow} onChange={(e) => onEdgeFlowChange(e.target.checked)} />
{t("topology.edgeFlow")}
</label>
<label className="topo-display-toggles__item">
<input type="checkbox" checked={snapToGrid} onChange={(e) => onSnapToGridChange(e.target.checked)} />
{t("topology.snapGrid")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={autoLayoutAfterDiscover}
onChange={(e) => {
const next = e.target.checked;
onAutoLayoutAfterDiscoverChange(next);
persistAutoLayoutAfterDiscover(next);
}}
/>
{t("topology.autoLayoutDiscover")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={discoverAutoAddUnmatched}
onChange={(e) => {
const next = e.target.checked;
onDiscoverAutoAddUnmatchedChange(next);
persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
void updateLldpCollectPolicy({ auto_add_unmatched: next })
.then(() => {
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
})
.catch(() => {
showError(t("topology.policySyncFailed"));
});
}}
/>
{t("topology.discoverAutoAddUnmatched")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={discoverProjectNeighbors}
onChange={(e) => {
const next = e.target.checked;
onDiscoverProjectNeighborsChange(next);
persistBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, next);
}}
/>
{t("topology.discoverProjectNeighbors")}
</label>
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
<strong>{t("topology.canvasBg")}</strong>
<Button
type="button"
size="sm"
variant="ghost"
onPress={() => {
onCanvasBgChange(DEFAULT_CANVAS_BG);
persistCanvasBg(DEFAULT_CANVAS_BG);
}}
>
{t("topology.canvasBgReset")}
</Button>
</div>
<div className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{t("topology.canvasBgColor")}</span>
<input
type="color"
value={canvasBg}
title={t("topology.canvasBgColor")}
onChange={(e) => {
const next = e.target.value;
onCanvasBgChange(next);
persistCanvasBg(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={canvasBg}
spellCheck={false}
aria-label={t("topology.canvasBgColor")}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
onCanvasBgChange(raw);
if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase());
}}
onBlur={() => {
if (!isHexColor(canvasBg)) onCanvasBgChange(loadCanvasBg());
}}
/>
</div>
</div>
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
<strong>{t("topology.textColors")}</strong>
<Button
type="button"
size="sm"
variant="ghost"
onPress={() => {
onLabelColorsChange({ ...DEFAULT_LABEL_COLORS });
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
}}
>
{t("topology.textColorsReset")}
</Button>
</div>
{(
[
["name", t("topology.labelColor")],
["edgeLabel", t("topology.edgeLabelColor")],
] as const
).map(([key, label]) => (
<div key={key} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={labelColors[key]}
title={label}
onChange={(e) => {
const next = { ...labelColors, [key]: e.target.value.toLowerCase() };
onLabelColorsChange(next);
persistLabelColors(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={labelColors[key]}
spellCheck={false}
aria-label={label}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
const next = { ...labelColors, [key]: raw };
onLabelColorsChange(next);
if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() });
}}
onBlur={() => {
if (!isHexColor(labelColors[key])) onLabelColorsChange(loadLabelColors());
}}
/>
</div>
))}
</div>
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
<strong>{t("topology.vendorColors")}</strong>
<Button
type="button"
size="sm"
variant="ghost"
onPress={() => {
onVendorColorsChange({ ...DEFAULT_VENDOR_COLORS });
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
}}
>
{t("topology.vendorColorsReset")}
</Button>
</div>
{VENDOR_TONE_KEYS.map((key) => {
const label = t(`topology.vendorTone.${key}`);
return (
<div key={key} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={vendorColors[key]}
title={label}
onChange={(e) => {
const next = { ...vendorColors, [key]: e.target.value.toLowerCase() };
onVendorColorsChange(next);
persistVendorColors(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={vendorColors[key]}
spellCheck={false}
aria-label={label}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
const next = { ...vendorColors, [key]: raw };
onVendorColorsChange(next);
if (isHexColor(raw)) {
persistVendorColors({ ...next, [key]: raw.toLowerCase() });
}
}}
onBlur={() => {
if (!isHexColor(vendorColors[key])) onVendorColorsChange(loadVendorColors());
}}
/>
</div>
);
})}
</div>
<div className="topo-display-defaults">
<div className="topo-display-defaults__head">
<strong>{t("topology.edgeDefaults")}</strong>
<Button type="button" size="sm" variant="ghost" onPress={onResetEdgeDefaults}>
{t("topology.edgeDefaultsReset")}
</Button>
</div>
{(
[
["ume", t("topology.edgeUme")],
["discovered", t("topology.edgeDiscovered")],
["stale", t("topology.edgeStale")],
["manual", t("topology.edgeManual")],
] as const
).map(([kind, label]) => {
const d = edgeDefaults[kind];
return (
<div key={kind} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={d.stroke_color}
title={t("topology.edgeColor")}
onChange={(e) => onUpdateEdgeDefault(kind, { stroke_color: e.target.value })}
/>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeLineStyle")}
value={d.line_style}
onChange={(e) =>
onUpdateEdgeDefault(kind, {
line_style: e.target.value as EdgeLineStyle,
})
}
>
<option value="solid">{t("topology.edgeLineSolid")}</option>
<option value="dashed">{t("topology.edgeLineDashed")}</option>
<option value="dotted">{t("topology.edgeLineDotted")}</option>
</select>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeWidth")}
value={String(d.stroke_width)}
onChange={(e) =>
onUpdateEdgeDefault(kind, { stroke_width: Number(e.target.value) || 2 })
}
>
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
<option key={w} value={w}>
{w}px
</option>
))}
</select>
</div>
);
})}
</div>
</div>
</details>
<select
className="topo-toolbar__select"
aria-label={t("topology.align")}
disabled={selectedNodeCount < 2}
defaultValue=""
onChange={(e) => {
const v = e.target.value as AlignKind | "";
e.target.value = "";
if (v) onAlign(v);
}}
>
<option value="" disabled>
{t("topology.align")}
</option>
<option value="left">{t("topology.alignLeft")}</option>
<option value="right">{t("topology.alignRight")}</option>
<option value="top">{t("topology.alignTop")}</option>
<option value="bottom">{t("topology.alignBottom")}</option>
<option value="h-center">{t("topology.alignHCenter")}</option>
<option value="v-center">{t("topology.alignVCenter")}</option>
<option value="h-distribute">{t("topology.alignHDistribute")}</option>
<option value="v-distribute">{t("topology.alignVDistribute")}</option>
</select>
<div
className="topo-toolbar__group topo-toolbar__group--find"
aria-label={t("topology.findNode")}
ref={findBoxRef}
>
<Input
className="topo-toolbar__find"
value={canvasQuery}
onChange={(e) => onCanvasQueryChange(e.target.value)}
onFocus={() => {
if (canvasQuery.trim()) onFindOpenChange(true);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!canvasHits.length) return;
onFindOpenChange(true);
onFindActiveIdxChange((i) => (i + 1) % canvasHits.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (!canvasHits.length) return;
onFindOpenChange(true);
onFindActiveIdxChange((i) => (i - 1 + canvasHits.length) % canvasHits.length);
return;
}
if (e.key === "Escape") {
onFindOpenChange(false);
return;
}
if (e.key === "Enter") {
e.preventDefault();
onFindOnCanvas();
}
}}
placeholder={t("topology.findNodePh")}
disabled={!mapId || nodeCount === 0}
aria-label={t("topology.findNode")}
aria-autocomplete="list"
aria-expanded={findOpen}
/>
{findOpen && canvasQuery.trim() ? (
<div className="topo-find-suggest" role="listbox">
{canvasHits.length === 0 ? (
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
) : (
canvasHits.slice(0, 12).map((n, idx) => (
<Button
key={n.id}
type="button"
variant="ghost"
size="sm"
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onHoverStart={() => onFindActiveIdxChange(idx)}
onPress={() => onFindOnCanvas(n.id)}
>
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)}
</span>
</Button>
))
)}
{canvasHits.length > 12 ? (
<div className="topo-find-suggest__more">
{t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
</div>
) : null}
</div>
) : null}
</div>
</>
);
if (!fullscreen) {
if (!toolbarSlot) return null;
return createPortal(body, toolbarSlot);
}
return (
<div className="topo-view-tools" role="toolbar" aria-label={t("topology.display")}>
{body}
</div>
);
}

View file

@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { Panel, useStore, type ReactFlowState } from "@xyflow/react";
export type WorldScatterPoint = { x: number; y: number };
type Props = {
points: WorldScatterPoint[];
/** "dot" = tiny starfield; "pin" = slightly larger. */
mode: "dot" | "pin";
visible: boolean;
};
const selectViewport = (s: ReactFlowState) => s.transform;
/**
* Screen-space starfield for the flat world map.
* Mounted via Panel (outside the RF viewport transform) so dots stay ~constant
* pixel size when zoomed far out — unlike RF DOM nodes.
*/
export function WorldScatterLayer({ points, mode, visible }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const transform = useStore(selectViewport);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !visible) return;
const parent = canvas.parentElement;
if (!parent) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = parent.clientWidth || 1;
const h = parent.clientHeight || 1;
if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) {
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
if (!points.length) return;
const [tx, ty, zoom] = transform;
const z = Math.max(Number(zoom) || 0.001, 0.001);
const r = mode === "pin" ? 3.5 : 2;
const useDensity = points.length > 6000 || z < 0.02;
if (useDensity) {
const cell = mode === "pin" ? 8 : 6;
const cols = Math.ceil(w / cell) + 1;
const rows = Math.ceil(h / cell) + 1;
const counts = new Uint16Array(cols * rows);
let maxC = 1;
for (const p of points) {
const sx = Number(p.x) * z + tx;
const sy = Number(p.y) * z + ty;
if (sx < -cell || sy < -cell || sx > w + cell || sy > h + cell) continue;
const cx = Math.min(cols - 1, Math.max(0, Math.floor(sx / cell)));
const cy = Math.min(rows - 1, Math.max(0, Math.floor(sy / cell)));
const i = cy * cols + cx;
const n = (counts[i] = (counts[i] + 1) as number);
if (n > maxC) maxC = n;
}
for (let i = 0; i < counts.length; i++) {
const c = counts[i];
if (!c) continue;
const cx = (i % cols) * cell + cell / 2;
const cy = Math.floor(i / cols) * cell + cell / 2;
const t = Math.min(1, c / Math.max(4, maxC * 0.35));
const radius = r * (0.7 + t * 1.4);
ctx.beginPath();
ctx.fillStyle = `rgba(56, 189, 248, ${0.25 + t * 0.7})`;
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
}
return;
}
ctx.fillStyle = "rgba(56, 189, 248, 0.85)";
for (const p of points) {
const sx = Number(p.x) * z + tx;
const sy = Number(p.y) * z + ty;
if (sx < -4 || sy < -4 || sx > w + 4 || sy > h + 4) continue;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fill();
}
}, [points, mode, visible, transform]);
if (!visible) return null;
return (
<Panel
position="top-left"
className="topo-world-scatter-panel"
style={{
margin: 0,
width: "100%",
height: "100%",
maxWidth: "none",
pointerEvents: "none",
zIndex: 1,
}}
>
<canvas ref={canvasRef} className="topo-world-scatter" aria-hidden="true" />
</Panel>
);
}

View file

@ -0,0 +1,176 @@
import type { DragEvent } from "react";
import { Button, Chip, Input, Modal } from "@heroui/react";
import { TopoModalShell } from "../TopoModalShell";
import { useI18n } from "../../../../i18n";
import type { PaletteItem, PaletteSource } from "../../lib/pageTypes";
export type AddNePaletteDialogProps = {
open: boolean;
paletteSource: PaletteSource;
onPaletteSourceChange: (source: PaletteSource) => void;
keyword: string;
onKeywordChange: (keyword: string) => void;
paletteVisible: PaletteItem[];
paletteSelectedKeys: string[];
onPaletteSelectedKeysChange: (keys: string[] | ((prev: string[]) => string[])) => void;
paletteLoading: boolean;
paletteAdding: boolean;
onClose: () => void;
onPaletteDragStart: (e: DragEvent, item: PaletteItem) => void;
onAddSelected: () => void;
};
export function AddNePaletteDialog({
open,
paletteSource,
onPaletteSourceChange,
keyword,
onKeywordChange,
paletteVisible,
paletteSelectedKeys,
onPaletteSelectedKeysChange,
paletteLoading,
paletteAdding,
onClose,
onPaletteDragStart,
onAddSelected,
}: AddNePaletteDialogProps) {
const { t } = useI18n();
return (
<TopoModalShell open={open} onClose={onClose} dismissible={!paletteAdding} size="md">
<Modal.Header>
<Modal.Heading>{t("topology.addNe")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={paletteAdding} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.paletteHint")}</p>
<div className="topo-palette-source flex gap-2" role="tablist">
<Button
size="sm"
variant={paletteSource === "managed" ? "primary" : "secondary"}
isDisabled={paletteAdding}
className="flex-1"
onPress={() => onPaletteSourceChange("managed")}
>
{t("topology.paletteManaged")}
</Button>
<Button
size="sm"
variant={paletteSource === "ume" ? "primary" : "secondary"}
isDisabled={paletteAdding}
className="flex-1"
onPress={() => onPaletteSourceChange("ume")}
>
{t("topology.paletteUme")}
</Button>
</div>
<Input
className="w-full"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
onPaletteSelectedKeysChange(paletteVisible.map((item) => item.key));
return;
}
onPaletteSelectedKeysChange([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<Chip size="sm" variant="soft">
<Chip.Label>
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</Chip.Label>
</Chip>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
onPaletteSelectedKeysChange((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
onPaletteSelectedKeysChange((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={paletteAdding} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button
variant="primary"
size="sm"
isDisabled={paletteSelectedKeys.length === 0 || paletteAdding}
onPress={() => void onAddSelected()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -0,0 +1,50 @@
import { Button, Modal } from "@heroui/react";
import { TopoModalShell } from "../TopoModalShell";
import { useI18n } from "../../../../i18n";
export type CreateNeModeDialogProps = {
open: boolean;
onClose: () => void;
onPickManaged: () => void;
onPickPlaceholder: () => void;
};
/** Choose formal Managed NE vs topology placeholder before opening the create flow. */
export function CreateNeModeDialog({
open,
onClose,
onPickManaged,
onPickPlaceholder,
}: CreateNeModeDialogProps) {
const { t } = useI18n();
return (
<TopoModalShell open={open} onClose={onClose} size="sm">
<Modal.Header>
<Modal.Heading>{t("topology.createNe")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.createNeModeHint")}</p>
<div className="flex flex-col gap-2.5">
<Button
variant="primary"
className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
onPress={onPickManaged}
>
<span className="font-semibold">{t("topology.createNeManaged")}</span>
<span className="text-sm font-normal opacity-90">{t("topology.createNeManagedHint")}</span>
</Button>
<Button
variant="secondary"
className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
onPress={onPickPlaceholder}
>
<span className="font-semibold">{t("topology.createNePlaceholder")}</span>
<span className="text-sm font-normal text-muted">{t("topology.createNePlaceholderHint")}</span>
</Button>
</div>
</Modal.Body>
</TopoModalShell>
);
}

View file

@ -0,0 +1,54 @@
import { Button, Input, Label, Modal, TextField } from "@heroui/react";
import { TopoModalShell } from "../TopoModalShell";
import { useI18n } from "../../../../i18n";
export type NewRootDialogProps = {
dialog: { name: string } | null;
pending: boolean;
onNameChange: (name: string) => void;
onClose: () => void;
onSubmit: () => void;
};
export function NewRootDialog({ dialog, pending, onNameChange, onClose, onSubmit }: NewRootDialogProps) {
const { t } = useI18n();
const open = Boolean(dialog);
return (
<TopoModalShell open={open} onClose={onClose} dismissible={!pending} size="sm">
<Modal.Header>
<Modal.Heading>{t("topology.newRegion")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={pending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.folderHint")}</p>
<TextField
fullWidth
autoFocus
value={dialog?.name ?? ""}
onChange={onNameChange}
isDisabled={pending}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
}}
>
<Label>{t("topology.newRegionPrompt")}</Label>
<Input />
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={pending} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button
variant="primary"
size="sm"
isDisabled={pending || !String(dialog?.name || "").trim()}
onPress={onSubmit}
>
{pending ? "…" : t("topology.newRegion")}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -0,0 +1,165 @@
import { Button, Chip, Input, Modal } from "@heroui/react";
import { TopoModalShell } from "../TopoModalShell";
import type { TopologyOutsidePeer } from "../../../../types";
import { useI18n } from "../../../../i18n";
export type OutsidePeersDialogProps = {
open: boolean;
outsidePeers: TopologyOutsidePeer[];
outsidePeersVisible: TopologyOutsidePeer[];
outsidePeerQuery: string;
onOutsidePeerQueryChange: (q: string) => void;
outsidePeerSelectedIds: string[];
onOutsidePeerSelectedIdsChange: (ids: string[] | ((prev: string[]) => string[])) => void;
outsidePeerNameById: Map<string, string>;
outsidePeersAdding: boolean;
onClose: () => void;
onAddOutsidePeers: (ids: string[]) => void;
};
export function OutsidePeersDialog({
open,
outsidePeers,
outsidePeersVisible,
outsidePeerQuery,
onOutsidePeerQueryChange,
outsidePeerSelectedIds,
onOutsidePeerSelectedIdsChange,
outsidePeerNameById,
outsidePeersAdding,
onClose,
onAddOutsidePeers,
}: OutsidePeersDialogProps) {
const { t } = useI18n();
return (
<TopoModalShell open={open} onClose={onClose} dismissible={!outsidePeersAdding} size="lg">
<Modal.Header>
<Modal.Heading>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</Modal.Heading>
<Modal.CloseTrigger isDisabled={outsidePeersAdding} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.outsidePeersHint")}</p>
<Input
className="w-full"
value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")}
disabled={outsidePeersAdding}
autoFocus
/>
{outsidePeersVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
outsidePeersVisible.length > 0 &&
outsidePeersVisible.every((p) =>
outsidePeerSelectedIds.includes(p.fabric_node_id),
)
}
disabled={outsidePeersAdding}
onChange={(e) => {
if (e.target.checked) {
onOutsidePeerSelectedIdsChange((prev) => [
...new Set([...prev, ...outsidePeersVisible.map((p) => p.fabric_node_id)]),
]);
return;
}
const drop = new Set(outsidePeersVisible.map((p) => p.fabric_node_id));
onOutsidePeerSelectedIdsChange((prev) => prev.filter((id) => !drop.has(id)));
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<Chip size="sm" variant="soft">
<Chip.Label>
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</Chip.Label>
</Chip>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{outsidePeersVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.outsidePeersEmpty")}</span>
</li>
) : (
outsidePeersVisible.map((peer) => {
const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id);
const viaName =
outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8);
const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8);
return (
<li key={peer.fabric_node_id}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={outsidePeersAdding}
onChange={(e) => {
onOutsidePeerSelectedIdsChange((prev) =>
e.target.checked
? [...new Set([...prev, peer.fabric_node_id])]
: prev.filter((id) => id !== peer.fabric_node_id),
);
}}
aria-label={title}
/>
</label>
<button
type="button"
className="topo-palette__item"
disabled={outsidePeersAdding}
onClick={() => {
onOutsidePeerSelectedIdsChange((prev) =>
prev.includes(peer.fabric_node_id)
? prev.filter((id) => id !== peer.fabric_node_id)
: [...prev, peer.fabric_node_id],
);
}}
>
<span className="topo-palette__name">{title}</span>
<span className="topo-palette__meta">
{[peer.ip, t("topology.outsidePeersVia").replace("{{name}}", viaName)]
.filter(Boolean)
.join(" · ")}
</span>
</button>
</div>
</li>
);
})
)}
</ul>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={outsidePeersAdding} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button
variant="primary"
size="sm"
isDisabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onPress={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -0,0 +1,80 @@
import { Button, Input, Label, Modal, TextField } from "@heroui/react";
import { TopoModalShell } from "../TopoModalShell";
import { useI18n } from "../../../../i18n";
export type PlaceholderCreateDialogState = {
flowX: number;
flowY: number;
name: string;
ip_address: string;
};
export type PlaceholderCreateDialogProps = {
dialog: PlaceholderCreateDialogState | null;
busy: boolean;
onChange: (patch: Partial<Pick<PlaceholderCreateDialogState, "name" | "ip_address">>) => void;
onClose: () => void;
onSubmit: () => void;
};
export function PlaceholderCreateDialog({
dialog,
busy,
onChange,
onClose,
onSubmit,
}: PlaceholderCreateDialogProps) {
const { t } = useI18n();
const open = Boolean(dialog);
return (
<TopoModalShell
open={open}
onClose={onClose}
dismissible={!busy}
size="sm"
>
<Modal.Header>
<Modal.Heading>{t("topology.createNeTitle")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={busy} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.createNeHint")}</p>
<TextField
fullWidth
autoFocus
isRequired
value={dialog?.name ?? ""}
onChange={(name) => onChange({ name })}
isDisabled={busy}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
>
<Label>{t("topology.createNeName")}</Label>
<Input placeholder={t("topology.createNeNamePh")} />
</TextField>
<TextField
fullWidth
value={dialog?.ip_address ?? ""}
onChange={(ip_address) => onChange({ ip_address })}
isDisabled={busy}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
>
<Label>{t("topology.createNeIp")}</Label>
<Input placeholder={t("topology.createNeIpPh")} />
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={busy} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button variant="primary" isDisabled={busy} onPress={() => void onSubmit()}>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -0,0 +1,120 @@
export function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
width="12"
height="12"
aria-hidden="true"
className="topo-svg-icon"
style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.12s ease" }}
>
<path
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
d="M9 6l6 6-6 6"
/>
</svg>
);
}
export function SidebarFoldIcon({ expand }: { expand?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
d={expand ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function PencilIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
);
}
export function PlusIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
d="M12 5v14M5 12h14"
/>
</svg>
);
}
export function CloseIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
d="M6 6l12 12M18 6L6 18"
/>
</svg>
);
}
export function RegionGlyph({ size = 16 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<path
d="M4 8.5L12 4l8 4.5v7L12 20l-8-4.5v-7z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M12 4v16M4 8.5l8 4.5 8-4.5" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.7" />
</svg>
);
}
export function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) {
const r = String(role || "core").toLowerCase();
if (r === "aggregation") {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<rect x="4" y="5" width="16" height="4" rx="1.5" fill="currentColor" opacity="0.9" />
<rect x="6" y="11" width="12" height="3.5" rx="1.2" fill="currentColor" opacity="0.65" />
<rect x="8" y="16.5" width="8" height="3" rx="1" fill="currentColor" opacity="0.45" />
</svg>
);
}
if (r === "access") {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<circle cx="7" cy="12" r="2.4" fill="currentColor" />
<circle cx="17" cy="7" r="2.2" fill="currentColor" opacity="0.8" />
<circle cx="17" cy="17" r="2.2" fill="currentColor" opacity="0.8" />
<path d="M9.4 12H14.5M14.5 12L16 8.4M14.5 12L16 15.6" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<rect x="7" y="3.5" width="10" height="17" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="8" r="2.2" fill="currentColor" />
<path d="M9 13.5h6M9 16.5h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}

View file

@ -0,0 +1,131 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Node } from "@xyflow/react";
import { fetchUmeCurrentAlarms } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import type { UmeAlarmItem } from "../../../types";
import { fetchAllPages } from "../../../utils/csvExport";
import {
isStrongHostKey,
looksLikeIp,
normalizeAlarmSeverity,
normalizeHostKey,
worseSeverity,
type NodeAlarmSummary,
} from "../lib/alarmOverlay";
import type { NeNodeData } from "../components/TopologyReactFlowView";
function accumulate(
map: Map<string, NodeAlarmSummary>,
key: string,
severity: string,
) {
const k = key.trim();
if (!k) return;
const sev = normalizeAlarmSeverity(severity);
if (sev === "cleared") return;
const prev = map.get(k);
if (!prev) {
map.set(k, { severity: sev, count: 1 });
return;
}
map.set(k, {
severity: worseSeverity(prev.severity, sev),
count: prev.count + 1,
});
}
function buildIndexes(items: UmeAlarmItem[]) {
const byNeId = new Map<string, NodeAlarmSummary>();
const byHost = new Map<string, NodeAlarmSummary>();
for (const item of items) {
if (String(item.is_cleared || "").toLowerCase() === "true" || item.is_cleared === "1") {
continue;
}
accumulate(byNeId, String(item.ne_id || "").trim(), item.perceived_severity);
// Only index strong host keys — skip short names that false-match canvas labels.
for (const raw of [item.host_name, item.ne_name, item.user_label]) {
const key = normalizeHostKey(raw);
if (isStrongHostKey(key)) {
accumulate(byHost, key, item.perceived_severity);
}
}
}
return { byNeId, byHost };
}
type Args = {
enabled: boolean;
nodes: Node<NeNodeData>[];
};
const ALARM_PAGE_SIZE = 500;
const ALARM_MAX_ROWS = 5000;
/** Poll uncleared UME alarms and join onto canvas nodes by ume_ne_id / strong name / IP. */
export function useTopologyAlarmOverlay({ enabled, nodes }: Args) {
const query = useQuery({
queryKey: [...queryKeys.umeCurrentAlarmsAll, "topo-overlay", "uncleared"],
queryFn: () =>
fetchAllPages<UmeAlarmItem>({
pageSize: ALARM_PAGE_SIZE,
maxRows: ALARM_MAX_ROWS,
fetchPage: (page, pageSize) =>
fetchUmeCurrentAlarms({
severity: "",
isCleared: "false",
hostName: "",
keyword: "",
page,
pageSize,
}),
}),
enabled,
refetchInterval: enabled ? 30_000 : false,
staleTime: 15_000,
});
const alarmByNodeId = useMemo(() => {
const out = new Map<string, NodeAlarmSummary>();
if (!enabled) return out;
const items = query.data || [];
if (!items.length || !nodes.length) return out;
const { byNeId, byHost } = buildIndexes(items);
// Host keys claimed by >1 canvas node are ambiguous → skip name/IP join for those keys.
const hostClaimCount = new Map<string, number>();
for (const n of nodes) {
if (n.data.kind === "region") continue;
for (const raw of [n.data.label, n.data.ne_ip]) {
const key = normalizeHostKey(raw);
if (!isStrongHostKey(key)) continue;
hostClaimCount.set(key, (hostClaimCount.get(key) || 0) + 1);
}
}
for (const n of nodes) {
if (n.data.kind === "region") continue;
const umeId = String(n.data.ume_ne_id || "").trim();
let hit = umeId ? byNeId.get(umeId) : undefined;
if (!hit) {
const candidates = [normalizeHostKey(n.data.ne_ip), normalizeHostKey(n.data.label)].filter(
(k) => isStrongHostKey(k) && (hostClaimCount.get(k) || 0) === 1,
);
// Prefer IP over label when both present.
candidates.sort((a, b) => Number(looksLikeIp(b)) - Number(looksLikeIp(a)));
for (const key of candidates) {
hit = byHost.get(key);
if (hit) break;
}
}
if (hit) out.set(n.id, hit);
}
return out;
}, [enabled, query.data, nodes]);
return {
alarmByNodeId,
alarmLoading: query.isLoading,
alarmError: query.error ? String(query.error) : "",
};
}

View file

@ -0,0 +1,599 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import { fetchTopologyGraph, formatErr } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
import {
FIT_VIEW_OPTS,
WORLD_LOCATE_HALF,
WORLD_LOCATE_ZOOM,
WORLD_MAP_ENABLED,
} from "../lib/constants";
import type { EdgeDefaults, EdgeStyleData } from "../lib/edgeStyle";
import { withEdgeVisual } from "../lib/edgeStyle";
import { graphFingerprint, graphToFlow, mergeOverlayIntoNodes, overlayFingerprint } from "../lib/graphFlow";
import {
buildLinkDisplayEdges,
type LinkMember,
} from "../lib/linkDisplay";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { TOPO_HANDLE_X, TOPO_HANDLE_Y } from "../lib/topoGeometry";
import type { ToolMode } from "../lib/toolMode";
import {
displayViewName,
isWorldFlatViewName,
worldDisplayBounds,
worldVisualLodFromZoom,
} from "../lib/treeUtils";
import { mergeFlatWorldGraph } from "../lib/worldGraph";
import type { HistorySnap } from "../lib/pageTypes";
export type UseTopologyCanvasOptions = {
mapId: string;
treeFlatMap: boolean;
liveSync: boolean;
canvasMode: boolean;
activeView: TopologyTreeViewItem | null;
nodes: Node<NeNodeData>[];
edges: Edge[];
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
edgeDefaults: EdgeDefaults;
toolMode: ToolMode;
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
showPlaceholderBadge: boolean;
showConnectStatus: boolean;
showAlarmOverlay: boolean;
showLevelColors: boolean;
expandPhysicalLinks: boolean;
scaleBundleWidth: boolean;
edgeFlow: boolean;
selectedEdgeId: string | null;
setSelectedEdgeId: React.Dispatch<React.SetStateAction<string | null>>;
setSearchHitIds: React.Dispatch<React.SetStateAction<string[]>>;
searchHitTimerRef: React.MutableRefObject<number | null>;
queryClient: QueryClient;
dirtyRef: React.MutableRefObject<boolean>;
historyLockRef: React.MutableRefObject<boolean>;
historyRef: React.MutableRefObject<HistorySnap[]>;
redoRef: React.MutableRefObject<HistorySnap[]>;
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
clearDirty: () => void;
bumpHistory: () => void;
showError: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
const {
mapId,
treeFlatMap,
liveSync,
canvasMode,
activeView,
nodes,
edges,
setNodes,
setEdges,
edgeDefaults,
toolMode,
hideIp,
hideVendor,
hidePorts,
showPlaceholderBadge,
showConnectStatus,
showAlarmOverlay,
showLevelColors,
expandPhysicalLinks,
scaleBundleWidth,
edgeFlow,
selectedEdgeId,
setSelectedEdgeId,
setSearchHitIds,
searchHitTimerRef,
queryClient,
dirtyRef,
historyLockRef,
historyRef,
redoRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
clearDirty,
bumpHistory,
showError,
t,
} = opts;
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const appliedMapIdRef = useRef("");
const appliedGraphFpRef = useRef("");
const appliedOverlayFpRef = useRef("");
const pendingFitRef = useRef(false);
const nodesRef = useRef<Node<NeNodeData>[]>([]);
const [canvasZoom, setCanvasZoom] = useState(1);
const worldTransformRef = useRef<TopologyWorldTransform | null>(null);
const flatLodFetchGenRef = useRef(0);
const flatLodTimerRef = useRef<number | null>(null);
const isWorldFlatCanvasRef = useRef(false);
const mapIdRef = useRef(mapId);
nodesRef.current = nodes;
const graphQuery = useQuery({
queryKey: queryKeys.topologyGraph(mapId),
queryFn: () =>
fetchTopologyGraph(
mapId,
WORLD_MAP_ENABLED && treeFlatMap ? { lod: "overview" } : undefined,
),
enabled: Boolean(mapId) && (WORLD_MAP_ENABLED || !treeFlatMap),
staleTime: liveSync ? 0 : 30_000,
refetchOnWindowFocus: liveSync && !treeFlatMap,
refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false,
refetchIntervalInBackground: false,
});
const isWorldFlatCanvas =
WORLD_MAP_ENABLED &&
(treeFlatMap ||
isWorldFlatViewName(graphQuery.data?.view?.name) ||
Boolean(graphQuery.data?.view?.filter?.world_flat));
isWorldFlatCanvasRef.current = isWorldFlatCanvas;
mapIdRef.current = mapId;
const worldVisualLod = isWorldFlatCanvas ? worldVisualLodFromZoom(canvasZoom) : "full";
const displayOpts = useMemo(
() => ({
hideIp,
hideVendor,
hidePorts,
connectMode: toolMode === "connect",
showPlaceholderBadge,
showConnectStatus,
showAlarmOverlay,
showLevelColors,
worldVisualLod,
}),
[
hideIp,
hideVendor,
hidePorts,
toolMode,
showPlaceholderBadge,
showConnectStatus,
showAlarmOverlay,
showLevelColors,
worldVisualLod,
],
);
const displayEdges = useMemo(() => {
if (isWorldFlatCanvas && worldVisualLod !== "full") return [];
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) =>
withEdgeVisual(e, edgeDefaults),
);
return built.map((e) => {
const d = (e.data || {}) as EdgeStyleData;
const selected =
e.id === selectedEdgeId ||
Boolean(d.members?.some((m: LinkMember) => m.id === selectedEdgeId));
return {
...e,
selected,
label: e.label ? String(e.label) : "",
animated: edgeFlow,
};
});
}, [
edges,
expandPhysicalLinks,
hidePorts,
scaleBundleWidth,
edgeFlow,
edgeDefaults,
selectedEdgeId,
isWorldFlatCanvas,
worldVisualLod,
]);
useEffect(() => {
if (graphQuery.data?.world_transform) {
worldTransformRef.current = graphQuery.data.world_transform;
}
}, [graphQuery.data?.world_transform]);
const refreshFlatViewport = useCallback(async () => {
if (!WORLD_MAP_ENABLED || !mapId || !isWorldFlatCanvas || dirtyRef.current) return;
const inst = rfRef.current;
if (!inst) return;
const zoom = inst.getZoom();
setCanvasZoom(zoom);
const gen = ++flatLodFetchGenRef.current;
const commit = (g: TopologyViewGraph, centerX: number, centerY: number) => {
if (g.world_transform) worldTransformRef.current = g.world_transform;
queryClient.setQueryData(queryKeys.topologyGraph(mapId), (prev: TopologyViewGraph | undefined) =>
mergeFlatWorldGraph(prev, g, { centerX, centerY }),
);
};
try {
if (zoom < 0.12) {
const prev = queryClient.getQueryData<TopologyViewGraph>(queryKeys.topologyGraph(mapId));
if (!prev?.scatter?.length) {
const g = await fetchTopologyGraph(mapId, { lod: "overview" });
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
const bounds = worldDisplayBounds(g.world_transform);
const cx = bounds ? bounds.x + bounds.width / 2 : 0;
const cy = bounds ? bounds.y + bounds.height / 2 : 0;
commit(g, cx, cy);
} else if (prev.nodes.length > 0) {
commit(
{
...prev,
nodes: [],
edges: [],
scatter: prev.scatter,
world_transform: prev.world_transform
? { ...prev.world_transform, lod: "overview" }
: prev.world_transform,
},
0,
0,
);
}
return;
}
const pane = canvasRef.current?.getBoundingClientRect();
const w = pane?.width || 1200;
const h = pane?.height || 800;
const vp = inst.getViewport();
const z = Math.max(vp.zoom || 0.01, 0.01);
const min_x = -vp.x / z;
const min_y = -vp.y / z;
const max_x = (w - vp.x) / z;
const max_y = (h - vp.y) / z;
const g = await fetchTopologyGraph(mapId, {
lod: "detail",
min_x,
max_x,
min_y,
max_y,
});
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
commit(g, (min_x + max_x) / 2, (min_y + max_y) / 2);
} catch {
/* keep last good sample */
}
}, [mapId, isWorldFlatCanvas, queryClient, dirtyRef]);
const scheduleFlatViewportRefresh = useCallback(() => {
if (!WORLD_MAP_ENABLED || !isWorldFlatCanvas) return;
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
flatLodTimerRef.current = window.setTimeout(() => {
flatLodTimerRef.current = null;
void refreshFlatViewport();
}, 200);
}, [isWorldFlatCanvas, refreshFlatViewport]);
const fitCanvas = useCallback(() => {
const inst = rfRef.current;
if (!inst) return;
const bounds = isWorldFlatCanvas
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
: null;
if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
else inst.fitView({ ...FIT_VIEW_OPTS });
setCanvasZoom(inst.getZoom());
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
useEffect(() => {
return () => {
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
};
}, []);
useEffect(() => {
if (!canvasMode) return;
void import("../components/TopologyReactFlowView");
}, [canvasMode]);
useEffect(() => {
appliedMapIdRef.current = "";
appliedGraphFpRef.current = "";
appliedOverlayFpRef.current = "";
}, [mapId]);
useEffect(() => {
if (!canvasMode) return;
if (!mapId || !graphQuery.data) return;
const entering = appliedMapIdRef.current !== mapId;
// Dirty geometry: still refresh connect/level/inventory overlays from live graph.
if (!entering && dirtyRef.current) {
const ofp = overlayFingerprint(graphQuery.data);
if (ofp === appliedOverlayFpRef.current) return;
const { nodes: merged, changed } = mergeOverlayIntoNodes(nodesRef.current, graphQuery.data);
appliedOverlayFpRef.current = ofp;
if (changed) {
historyLockRef.current = true;
setNodes(merged);
historyLockRef.current = false;
}
return;
}
const fp = graphFingerprint(graphQuery.data);
if (!entering && fp === appliedGraphFpRef.current) return;
const selectedIds = entering
? new Set<string>()
: new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
const nextNodes =
selectedIds.size > 0
? rfNodes.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n))
: rfNodes;
historyLockRef.current = true;
setNodes(nextNodes);
setEdges(rfEdges);
appliedGraphFpRef.current = fp;
appliedOverlayFpRef.current = overlayFingerprint(graphQuery.data);
if (entering) {
appliedMapIdRef.current = mapId;
historyRef.current = [];
redoRef.current = [];
pendingEdgeDeletesRef.current = new Set();
pendingEdgeCreatesRef.current = new Set();
clearDirty();
bumpHistory();
const hasScatter = (graphQuery.data.scatter?.length || 0) > 0;
const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform));
pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld;
}
historyLockRef.current = false;
}, [
canvasMode,
mapId,
graphQuery.data,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
bumpHistory,
dirtyRef,
historyLockRef,
historyRef,
redoRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
]);
useEffect(() => {
if (!pendingFitRef.current) return;
const wt = graphQuery.data?.world_transform;
const bounds = isWorldFlatCanvas ? worldDisplayBounds(wt) : null;
const canFitNodes = nodes.length > 0;
if (!bounds && !canFitNodes) return;
let cancelled = false;
let tries = 0;
const run = () => {
if (cancelled) return;
const inst = rfRef.current;
if (!inst) {
if (tries++ < 40) window.setTimeout(run, 40);
return;
}
pendingFitRef.current = false;
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
}
setCanvasZoom(inst.getZoom());
};
const timer = window.setTimeout(run, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [nodes, mapId, isWorldFlatCanvas, graphQuery.data?.world_transform]);
useEffect(() => {
setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults)));
}, [edgeDefaults, setEdges]);
const focusNode = useCallback(
(nodeId: string, additive: boolean) => {
setSelectedEdgeId(null);
setNodes((ns) =>
ns.map((n) => {
if (additive) {
if (n.id === nodeId) return { ...n, selected: !n.selected };
return n;
}
return { ...n, selected: n.id === nodeId };
}),
);
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
},
[setNodes, setEdges, setSelectedEdgeId],
);
const focusWorldAround = useCallback(
async (nodeId: string, displayX: number, displayY: number) => {
const viewId = mapIdRef.current;
if (!viewId || !isWorldFlatCanvasRef.current || dirtyRef.current) return;
if (flatLodTimerRef.current) {
window.clearTimeout(flatLodTimerRef.current);
flatLodTimerRef.current = null;
}
const gen = ++flatLodFetchGenRef.current;
const inst = rfRef.current;
if (inst) {
setCanvasZoom(WORLD_LOCATE_ZOOM);
inst.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 200 });
}
const half = WORLD_LOCATE_HALF;
try {
const g = await fetchTopologyGraph(viewId, {
lod: "detail",
min_x: displayX - half,
max_x: displayX + half,
min_y: displayY - half,
max_y: displayY + half,
});
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
if (g.world_transform) worldTransformRef.current = g.world_transform;
queryClient.setQueryData(queryKeys.topologyGraph(viewId), (prev: TopologyViewGraph | undefined) =>
mergeFlatWorldGraph(prev, g, { centerX: displayX, centerY: displayY }),
);
} catch {
/* keep last sample */
}
if (gen !== flatLodFetchGenRef.current) return;
window.requestAnimationFrame(() => {
if (flatLodFetchGenRef.current !== gen) return;
rfRef.current?.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 0 });
setCanvasZoom(WORLD_LOCATE_ZOOM);
focusNode(nodeId, false);
setSearchHitIds([nodeId]);
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
searchHitTimerRef.current = window.setTimeout(() => {
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
searchHitTimerRef.current = null;
}, 8000);
});
},
[queryClient, focusNode, dirtyRef, setSearchHitIds, searchHitTimerRef],
);
const locateNode = useCallback(
(nodeId: string, opts?: { worldX?: number; worldY?: number }) => {
const onCanvas = nodesRef.current.find((n) => n.id === nodeId);
const wt = worldTransformRef.current;
const originX = Number(wt?.origin_x) || 0;
const originY = Number(wt?.origin_y) || 0;
const scale = Math.max(Number(wt?.scale) || 1, 1e-9);
let displayX: number;
let displayY: number;
if (onCanvas) {
displayX = onCanvas.position.x + TOPO_HANDLE_X;
displayY = onCanvas.position.y + TOPO_HANDLE_Y;
} else if (opts?.worldX != null && opts?.worldY != null) {
displayX = (Number(opts.worldX) - originX) * scale;
displayY = (Number(opts.worldY) - originY) * scale;
} else {
showError(t("topology.locateNotOnCanvas"));
return;
}
focusNode(nodeId, false);
setSearchHitIds([nodeId]);
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
searchHitTimerRef.current = window.setTimeout(() => {
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
searchHitTimerRef.current = null;
}, 8000);
if (isWorldFlatCanvasRef.current) {
void focusWorldAround(nodeId, displayX, displayY);
return;
}
window.setTimeout(() => {
rfRef.current?.fitView({
nodes: [{ id: nodeId }],
padding: 0.45,
duration: 280,
includeHiddenNodes: true,
});
}, 30);
},
[focusNode, focusWorldAround, showError, t, setSearchHitIds, searchHitTimerRef],
);
const graphTruncated = Boolean(graphQuery.data?.truncated);
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
const worldScatter = graphQuery.data?.scatter || [];
const worldTotal = Number(graphQuery.data?.world_transform?.total || 0);
const worldDockMe = Number(graphQuery.data?.world_transform?.dock_me_count || 0);
const showWorldScatter =
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
const canvasGraphEmpty =
Boolean(mapId) &&
graphQuery.isSuccess &&
!graphQuery.isFetching &&
(graphQuery.data?.nodes?.length ?? 0) === 0 &&
worldScatter.length === 0 &&
nodes.length === 0 &&
worldTotal === 0;
const worldNeedsApply = isWorldFlatCanvas && canvasGraphEmpty && worldDockMe > 0;
const canvasGraphRefreshing =
Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading;
const truncateBannerText = useMemo(() => {
if (!graphTruncated) return "";
if (isWorldFlatViewName(activeView?.name) || Boolean(graphQuery.data?.view?.filter?.world_flat)) {
return "";
}
if (truncateReason === "membership_cap") return t("topology.truncatedMembership");
if (truncateReason === "membership_frozen") return t("topology.truncatedFrozen");
if (truncateReason === "too_many_view_nodes") return t("topology.truncatedNodes");
if (truncateReason === "too_many_edges") return t("topology.truncatedEdges");
return t("topology.truncatedGeneric");
}, [graphTruncated, truncateReason, activeView?.name, graphQuery.data?.view?.filter?.world_flat, t]);
const activeLeafName = useMemo(() => {
if (!mapId) return "";
if (activeView?.name) return displayViewName(activeView.name, t);
return displayViewName(graphQuery.data?.view?.name, t);
}, [mapId, activeView, graphQuery.data?.view?.name, t]);
return {
rfRef,
canvasRef,
nodesRef,
appliedMapIdRef,
pendingFitRef,
isWorldFlatCanvasRef,
mapIdRef,
worldTransformRef,
graphQuery,
isWorldFlatCanvas,
canvasZoom,
setCanvasZoom,
worldVisualLod,
displayOpts,
displayEdges,
refreshFlatViewport,
scheduleFlatViewportRefresh,
fitCanvas,
focusNode,
locateNode,
graphTruncated,
truncateReason,
worldScatter,
worldTotal,
worldDockMe,
showWorldScatter,
canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,
truncateBannerText,
activeLeafName,
};
}

View file

@ -0,0 +1,181 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import type { Node } from "@xyflow/react";
import { useQueryClient } from "@tanstack/react-query";
import { connectTestManagedNe, fetchManagedNeById } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import type { ManagedNeItem } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
type Args = {
closeCtxMenu: () => void;
mapId?: string | null;
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
};
/** Patch canvas node connect_status without wiping dirty local edits. */
function patchNodesConnectStatus(
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>,
updates: Array<{ id: string; status: string }>,
) {
if (!updates.length) return;
const byId = new Map(updates.map((u) => [u.id, u.status]));
setNodes((ns) =>
ns.map((n) => {
const mid = String(n.data.managed_ne_id || "").trim();
if (!mid || !byId.has(mid)) return n;
const status = byId.get(mid) || "unknown";
if (n.data.connect_status === status) return n;
return { ...n, data: { ...n.data, connect_status: status } };
}),
);
}
export function useTopologyConnectTest({ closeCtxMenu, mapId, setNodes }: Args) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [detailRow, setDetailRow] = useState<ManagedNeItem | null>(null);
const [submitting, setSubmitting] = useState(false);
/** Managed NE ids whose canvas dots should track poll results. */
const [trackingIds, setTrackingIds] = useState<string[]>([]);
const finishTracking = useCallback(() => {
setTrackingIds([]);
if (mapId) {
void queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
}
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
}, [mapId, queryClient]);
// Poll tracked connect tests → update dots (works even when graph apply is dirty-locked).
useEffect(() => {
if (!trackingIds.length) return;
let cancelled = false;
const ids = trackingIds;
const tick = async () => {
try {
const rows = await Promise.all(ids.map((id) => fetchManagedNeById(id)));
if (cancelled) return;
patchNodesConnectStatus(
setNodes,
rows.map((row) => ({ id: row.id, status: row.connect_status || "unknown" })),
);
setDetailRow((prev) => {
if (!prev) {
return ids.length === 1 ? rows[0] : prev;
}
const match = rows.find((r) => r.id === prev.id);
return match || prev;
});
if (rows.every((r) => r.connect_status !== "testing")) {
finishTracking();
}
} catch {
/* ignore transient poll errors */
}
};
void tick();
const timer = window.setInterval(() => void tick(), 2000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [trackingIds, setNodes, finishTracking]);
const startTracking = useCallback(
(ids: string[], openDetail: boolean) => {
const unique = [...new Set(ids.map((x) => String(x || "").trim()).filter(Boolean))];
if (!unique.length) return;
patchNodesConnectStatus(
setNodes,
unique.map((id) => ({ id, status: "testing" })),
);
setTrackingIds(unique);
if (openDetail && unique.length === 1) {
void fetchManagedNeById(unique[0])
.then((row) => setDetailRow(row))
.catch(() => {
/* ignore */
});
}
},
[setNodes],
);
const runForNode = useCallback(
async (node: Node<NeNodeData> | null) => {
closeCtxMenu();
const managedId = String(node?.data.managed_ne_id || "").trim();
if (!managedId) {
showError(t("topology.connectTestNeedManaged"));
return;
}
setSubmitting(true);
try {
const res = await connectTestManagedNe([managedId]);
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
startTracking([managedId], true);
} catch (err) {
showError(String(err));
} finally {
setSubmitting(false);
}
},
[closeCtxMenu, showError, showOk, startTracking, t],
);
const runForNodes = useCallback(
async (nodes: Node<NeNodeData>[]) => {
closeCtxMenu();
const ids = [
...new Set(
nodes
.map((n) => String(n.data.managed_ne_id || "").trim())
.filter(Boolean),
),
];
if (!ids.length) {
showError(t("topology.connectTestNeedManaged"));
return;
}
const skipped = Math.max(0, nodes.length - ids.length);
setSubmitting(true);
try {
const res = await connectTestManagedNe(ids);
const msg = t("managedNe.connect.submitted", { n: res.submitted });
showOk(
skipped > 0
? `${msg} · ${t("topology.connectTestSkipped").replace("{{count}}", String(skipped))}`
: msg,
);
startTracking(ids, ids.length === 1);
} catch (err) {
showError(String(err));
} finally {
setSubmitting(false);
}
},
[closeCtxMenu, showError, showOk, startTracking, t],
);
const onRetestSubmitted = useCallback(
(rowId: string) => {
showOk(t("managedNe.connect.submitted", { n: 1 }));
startTracking([rowId], true);
},
[showOk, startTracking, t],
);
return {
connectDetailRow: detailRow,
connectTestSubmitting: submitting,
closeConnectDetail: () => setDetailRow(null),
runConnectTestForNode: runForNode,
runConnectTestForNodes: runForNodes,
onConnectRetestSubmitted: onRetestSubmitted,
};
}

View file

@ -0,0 +1,243 @@
import {
useCallback,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Edge, Node } from "@xyflow/react";
import {
addTopologyViewNodes,
createTopologyPlaceholder,
fetchTopologyGraph,
patchTopologyPositions,
} from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import type { ManagedNeItem } from "../../../types";
import { applyViewGraph } from "../lib/graphFlow";
import type { EdgeDefaults } from "../lib/edgeStyle";
import type { PlaceholderCreateDialogState } from "../components/modals/PlaceholderCreateDialog";
import type { ManagedNeFormState } from "../../managed-ne/lib/formState";
import { isWorldFlatViewName } from "../lib/treeUtils";
import type { NeNodeData } from "../components/TopologyReactFlowView";
type FocusNode = (fabricNodeId: string, fit: boolean) => void;
type Args = {
mapId: string | null | undefined;
activeViewName: string | undefined;
canWrite: boolean;
edgeDefaults: EdgeDefaults;
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
setEdges: Dispatch<SetStateAction<Edge[]>>;
clearDirty: () => void;
historyLockRef: MutableRefObject<boolean>;
focusNode: FocusNode;
closeCtxMenu: () => void;
};
export function useTopologyCreateNe({
mapId,
activeViewName,
canWrite,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
historyLockRef,
focusNode,
closeCtxMenu,
}: Args) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [modeOpen, setModeOpen] = useState(false);
const [placeAt, setPlaceAt] = useState<{ flowX: number; flowY: number } | null>(null);
const [placeholderDialog, setPlaceholderDialog] = useState<PlaceholderCreateDialogState | null>(
null,
);
const [placeholderBusy, setPlaceholderBusy] = useState(false);
const [managedFormOpen, setManagedFormOpen] = useState(false);
const [managedFormInitial, setManagedFormInitial] = useState<Partial<ManagedNeFormState> | undefined>();
const [managedBusy, setManagedBusy] = useState(false);
const placeAtRef = useRef(placeAt);
placeAtRef.current = placeAt;
const openCreateNeAt = useCallback(
(flowX: number, flowY: number) => {
if (!canWrite) {
showError(t("topology.readOnlyHint"));
return;
}
if (isWorldFlatViewName(activeViewName)) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
closeCtxMenu();
setPlaceAt({ flowX, flowY });
setModeOpen(true);
},
[canWrite, activeViewName, closeCtxMenu, showError, t],
);
const closeMode = useCallback(() => setModeOpen(false), []);
const pickManaged = useCallback(() => {
setModeOpen(false);
setManagedFormInitial(undefined);
setManagedFormOpen(true);
}, []);
const pickPlaceholder = useCallback(() => {
const at = placeAtRef.current;
if (!at) return;
setModeOpen(false);
setPlaceholderDialog({ ...at, name: "", ip_address: "" });
}, []);
const closePlaceholder = useCallback(() => setPlaceholderDialog(null), []);
const closeManagedForm = useCallback(() => {
if (managedBusy) return;
setManagedFormOpen(false);
}, [managedBusy]);
const applyGraph = useCallback(
(graph: Awaited<ReturnType<typeof fetchTopologyGraph>>) => {
if (!mapId) return;
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
},
[mapId, queryClient, edgeDefaults, setNodes, setEdges, clearDirty, historyLockRef],
);
const submitPlaceholder = useCallback(async () => {
if (!mapId || !placeholderDialog) return;
const name = placeholderDialog.name.trim();
if (!name) {
showError(t("topology.createNeNameRequired"));
return;
}
setPlaceholderBusy(true);
try {
const graph = await createTopologyPlaceholder(mapId, {
name,
ip_address: placeholderDialog.ip_address.trim(),
x: placeholderDialog.flowX,
y: placeholderDialog.flowY,
});
applyGraph(graph);
const created = graph.nodes.find(
(n) =>
String(n.name || "").trim() === name &&
Math.abs(Number(n.x) - placeholderDialog.flowX) < 0.5 &&
Math.abs(Number(n.y) - placeholderDialog.flowY) < 0.5,
);
if (created?.fabric_node_id) {
focusNode(created.fabric_node_id, false);
}
setPlaceholderDialog(null);
showOk(t("topology.createNeDone").replace("{{name}}", name));
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} catch (err) {
showError(String(err));
} finally {
setPlaceholderBusy(false);
}
}, [
mapId,
placeholderDialog,
applyGraph,
focusNode,
queryClient,
showError,
showOk,
t,
]);
const onManagedFormSaved = useCallback(
async (item: ManagedNeItem) => {
setManagedFormOpen(false);
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
if (!mapId) {
showOk(t("managedNe.form.created"));
return;
}
const at = placeAtRef.current || { flowX: 80, flowY: 80 };
const displayName = item.name || item.ip_address || item.id;
setManagedBusy(true);
try {
let graph = await addTopologyViewNodes(mapId, {
managed_ne_ids: [item.id],
layout: "grid",
});
const added = graph.nodes.find((n) => n.managed_ne_id === item.id);
let placedAtClick = false;
if (added?.fabric_node_id) {
try {
await patchTopologyPositions(mapId, [
{
fabric_node_id: added.fabric_node_id,
x: at.flowX,
y: at.flowY,
label: added.label || added.name || item.name || "",
},
]);
graph = await fetchTopologyGraph(mapId);
placedAtClick = true;
} catch {
// NE is already on the map; keep grid layout coords.
}
}
applyGraph(graph);
if (added?.fabric_node_id) {
focusNode(added.fabric_node_id, false);
}
if (placedAtClick) {
showOk(t("topology.createManagedNeDone").replace("{{name}}", displayName));
} else if (added?.fabric_node_id) {
showError(t("topology.createManagedNePosFailed").replace("{{name}}", displayName));
} else {
showOk(t("topology.createManagedNeDone").replace("{{name}}", displayName));
}
} catch (err) {
// Inventory already has the NE; placement failed → avoid silent orphan confusion.
showError(
t("topology.createManagedNePlaceFailed")
.replace("{{name}}", displayName)
.replace("{{error}}", String(err)),
);
} finally {
setManagedBusy(false);
}
},
[mapId, applyGraph, focusNode, queryClient, showError, showOk, t],
);
return {
modeOpen,
openCreateNeAt,
closeMode,
pickManaged,
pickPlaceholder,
placeholderDialog,
setPlaceholderDialog,
placeholderBusy,
closePlaceholder,
submitPlaceholder,
managedFormOpen,
managedFormInitial,
closeManagedForm,
onManagedFormSaved,
};
}

View file

@ -0,0 +1,395 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { QueryClient } from "@tanstack/react-query";
import type { Edge, Node } from "@xyflow/react";
import { queryKeys } from "../../../constants/queryKeys";
import {
addTopologyViewNodes,
createFabricManualEdge,
deleteFabricEdges,
fetchLldpDiscoverJob,
fetchTopologyGraph,
patchTopologyPositions,
projectTopologyNeighbors,
removeTopologyViewNodes,
startLldpDiscover,
stopLldpCollectJob,
formatErr,
} from "../../../services/api";
import type {
TopologyDiscoverJob,
TopologyDiscoverNeResult,
TopologyDiscoverOut,
TopologyViewGraph,
} from "../../../types";
import type { DiscoverProgress, DiscoverSummary } from "../components/TopologyDiscoverPanel";
import type { EdgeDefaults, EdgeStyleData } from "../lib/edgeStyle";
import { flowToPositions, graphToFlow } from "../lib/graphFlow";
import { discoverResultKind, isLocalPendingEdgeId } from "../lib/idUtils";
import { layoutGraph } from "../lib/layoutGraph";
import type { NeNodeData } from "../components/TopologyReactFlowView";
export type UseTopologyDiscoverOptions = {
mapId: string;
nodes: Node<NeNodeData>[];
edges: Edge[];
graphNodes: TopologyViewGraph["nodes"] | undefined;
edgeDefaults: EdgeDefaults;
autoLayoutAfterDiscover: boolean;
discoverAutoAddUnmatched: boolean;
discoverProjectNeighbors: boolean;
queryClient: QueryClient;
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
dirtyRef: React.MutableRefObject<boolean>;
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
historyLockRef: React.MutableRefObject<boolean>;
appliedMapIdRef: React.MutableRefObject<string>;
markDirty: () => void;
clearDirty: () => void;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
};
export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
const {
mapId,
nodes,
edges,
graphNodes,
edgeDefaults,
autoLayoutAfterDiscover,
discoverAutoAddUnmatched,
discoverProjectNeighbors,
queryClient,
setNodes,
setEdges,
dirtyRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
historyLockRef,
appliedMapIdRef,
markDirty,
clearDirty,
showOk,
showError,
t,
} = opts;
const [discoverOpen, setDiscoverOpen] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
const [discoverLiveResults, setDiscoverLiveResults] = useState<TopologyDiscoverNeResult[]>([]);
const [discoverProgress, setDiscoverProgress] = useState<DiscoverProgress>({
index: 0,
total: 0,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
const [discoverError, setDiscoverError] = useState("");
const [discoverJobId, setDiscoverJobId] = useState("");
const discoverAbortRef = useRef(false);
const flushDirtyCanvas = useCallback(async () => {
if (!mapId || !dirtyRef.current) return;
const createIds = [...pendingEdgeCreatesRef.current];
for (const id of createIds) {
const e = edges.find((x) => x.id === id);
if (!e) continue;
const d = (e.data || {}) as EdgeStyleData;
await createFabricManualEdge({
a_node_id: e.source,
b_node_id: e.target,
a_port: String(d.source_port || ""),
b_port: String(d.target_port || ""),
});
}
pendingEdgeCreatesRef.current.clear();
const pendingEdges = [...pendingEdgeDeletesRef.current].filter((id) => !isLocalPendingEdgeId(id));
if (pendingEdges.length) {
await deleteFabricEdges(pendingEdges);
}
pendingEdgeDeletesRef.current.clear();
const serverIds = (graphNodes || []).map((n) => n.fabric_node_id).filter(Boolean);
const localIds = nodes.map((n) => n.id);
const localSet = new Set(localIds);
const serverSet = new Set(serverIds);
const toAdd = localIds.filter((id) => !serverSet.has(id));
const toRemove = serverIds.filter((id) => !localSet.has(id));
if (toAdd.length) {
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
}
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}, [
mapId,
edges,
graphNodes,
nodes,
dirtyRef,
pendingEdgeCreatesRef,
pendingEdgeDeletesRef,
clearDirty,
]);
const runDiscover = useCallback(
async (neIds?: string[]) => {
if (!mapId || discovering) return;
const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean);
if (!filterIds.length) {
showError(t("topology.discoverOneNeedNe"));
return;
}
setDiscoverOpen(true);
setDiscovering(true);
setDiscoverReport(null);
setDiscoverLiveResults([]);
setDiscoverError("");
setDiscoverJobId("");
discoverAbortRef.current = false;
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
const scoped = scannable.filter(
(n) =>
filterIds.includes(String(n.data.managed_ne_id || "")) ||
filterIds.includes(String(n.data.ume_ne_id || "")),
);
setDiscoverProgress({
index: 0,
total: scoped.length,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
try {
await flushDirtyCanvas();
if (!scoped.length) {
throw new Error(t("topology.discoverOneNeedNe"));
}
const jobStart = await startLldpDiscover({
scope: "ne_ids",
ne_ids: filterIds,
concurrency: 4,
auto_add_unmatched: discoverAutoAddUnmatched,
trigger_mode: "topology",
});
setDiscoverJobId(jobStart.id);
let job: TopologyDiscoverJob = jobStart;
let cancelled = false;
const isTerminalStatus = (status: string) =>
status === "done" ||
status === "failed" ||
status === "cancelled" ||
status === "stopped";
for (let i = 0; i < 600; i++) {
if (discoverAbortRef.current) {
cancelled = true;
break;
}
await new Promise((r) => window.setTimeout(r, 500));
if (discoverAbortRef.current) {
cancelled = true;
break;
}
job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 5 });
setDiscoverProgress((p) => ({
...p,
index: job.done,
total: job.total || p.total,
edgesAdded: job.edges_added,
edgesUpdated: job.edges_updated,
neName: job.items?.[job.items.length - 1]?.ne_name || p.neName,
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
}));
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
if (isTerminalStatus(job.status)) {
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
break;
}
}
if (cancelled || discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
setDiscoverReport({
map_id: mapId,
protocol: "lldp",
job_id: job.id,
scanned: job.done,
edges_added: job.edges_added,
edges_updated: job.edges_updated,
edges_stale: job.edges_missing ?? job.edges_stale,
results: (job.items || []) as TopologyDiscoverNeResult[],
graph: null,
});
return;
}
if (!isTerminalStatus(job.status)) {
throw new Error(t("topology.discoverTimeout"));
}
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
}
try {
job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 100 });
} catch {
/* keep last polled job */
}
const baseGraph = await fetchTopologyGraph(mapId);
const projected = discoverProjectNeighbors
? await projectTopologyNeighbors(mapId, {
seed_fabric_node_ids: scoped.map((n) => n.id),
dry_run: true,
})
: baseGraph;
queryClient.setQueryData(queryKeys.topologyGraph(mapId), baseGraph);
if (discoverProjectNeighbors && projected.truncate_reason === "membership_frozen") {
showError(t("topology.truncatedFrozen"));
}
appliedMapIdRef.current = mapId;
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const serverNodeIds = new Set((baseGraph.nodes || []).map((n) => n.fabric_node_id));
rfNodes = rfNodes.map((n) => {
const p = localPos.get(n.id);
return p ? { ...n, position: { ...p } } : n;
});
let didAutoLayout = false;
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
rfNodes = await layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
didAutoLayout = true;
}
historyLockRef.current = true;
setNodes(rfNodes);
setEdges(rfEdges);
historyLockRef.current = false;
const localOnly = rfNodes.some((n) => !serverNodeIds.has(n.id));
if (localOnly || didAutoLayout) {
markDirty();
} else {
clearDirty();
}
const out: TopologyDiscoverOut = {
map_id: mapId,
protocol: "lldp",
job_id: job.id,
scanned: job.done,
edges_added: job.edges_added,
edges_updated: job.edges_updated,
edges_stale: job.edges_missing ?? job.edges_stale,
results: (job.items || []) as TopologyDiscoverNeResult[],
graph: projected,
};
setDiscoverReport(out);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary });
showOk(
t("topology.discovered")
.replace("{{added}}", String(out.edges_added))
.replace("{{updated}}", String(out.edges_updated))
.replace("{{stale}}", String(out.edges_stale || 0)),
);
} catch (err) {
if (discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
} else {
const detail = formatErr(err);
setDiscoverError(detail);
showError(t("topology.discoverFail").replace("{{detail}}", detail));
}
} finally {
setDiscovering(false);
}
},
[
mapId,
discovering,
nodes,
queryClient,
setNodes,
setEdges,
showOk,
showError,
t,
autoLayoutAfterDiscover,
discoverAutoAddUnmatched,
discoverProjectNeighbors,
edgeDefaults,
clearDirty,
markDirty,
flushDirtyCanvas,
historyLockRef,
appliedMapIdRef,
],
);
const cancelDiscover = useCallback(async () => {
discoverAbortRef.current = true;
const jobId = discoverJobId;
if (jobId) {
try {
await stopLldpCollectJob(jobId);
} catch {
/* best-effort */
}
}
}, [discoverJobId]);
const discoverResults = discoverReport?.results?.length
? discoverReport.results
: discoverLiveResults;
const discoverCounts = useMemo(() => {
let ok = 0;
let warn = 0;
let fail = 0;
for (const r of discoverResults) {
const k = discoverResultKind(r);
if (k === "fail") fail += 1;
else if (k === "warn") warn += 1;
else ok += 1;
}
return { ok, warn, fail, issues: warn + fail };
}, [discoverResults]);
const discoverSummary: DiscoverSummary = discoverReport
? {
scanned: discoverReport.scanned,
added: discoverReport.edges_added,
updated: discoverReport.edges_updated,
missing: discoverReport.edges_stale || 0,
failed: discoverCounts.fail,
}
: {
scanned: discoverLiveResults.length,
added: discoverProgress.edgesAdded,
updated: discoverProgress.edgesUpdated,
missing: 0,
failed: discoverCounts.fail,
};
const discoverPct =
discovering && discoverProgress.total > 0
? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100))
: discoverReport
? 100
: 0;
return {
discoverOpen,
setDiscoverOpen,
discovering,
discoverReport,
discoverProgress,
discoverError,
discoverResults,
discoverSummary,
discoverPct,
runDiscover,
cancelDiscover,
};
}

View file

@ -0,0 +1,108 @@
import { useCallback, useRef, useState } from "react";
import type { Edge, Node } from "@xyflow/react";
import { UNDO_MAX } from "../lib/constants";
import type { HistorySnap } from "../lib/pageTypes";
import type { NeNodeData } from "../components/TopologyReactFlowView";
export function useTopologyHistory(
nodes: Node<NeNodeData>[],
edges: Edge[],
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>,
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>,
t: (key: string) => string,
) {
const [dirty, setDirty] = useState(false);
const [historyTick, setHistoryTick] = useState(0);
const dirtyRef = useRef(false);
const historyRef = useRef<HistorySnap[]>([]);
const redoRef = useRef<HistorySnap[]>([]);
const historyLockRef = useRef(false);
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
const pendingEdgeCreatesRef = useRef<Set<string>>(new Set());
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
const markDirty = useCallback(() => {
dirtyRef.current = true;
setDirty(true);
}, []);
const clearDirty = useCallback(() => {
dirtyRef.current = false;
setDirty(false);
}, []);
const bumpHistory = useCallback(() => {
setHistoryTick((n) => n + 1);
}, []);
const confirmDiscardIfDirty = useCallback(() => {
if (!dirtyRef.current) return true;
return window.confirm(t("topology.unsavedConfirm"));
}, [t]);
const snapshot = useCallback(
(): HistorySnap => ({
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
}),
[nodes, edges],
);
const pushHistory = useCallback(() => {
if (historyLockRef.current) return;
historyRef.current = [...historyRef.current.slice(-(UNDO_MAX - 1)), snapshot()];
redoRef.current = [];
bumpHistory();
}, [snapshot, bumpHistory]);
const undo = useCallback(() => {
const prev = historyRef.current.pop();
if (!prev) return;
redoRef.current.push(snapshot());
historyLockRef.current = true;
setNodes(prev.nodes);
setEdges(prev.edges);
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
markDirty();
bumpHistory();
historyLockRef.current = false;
}, [snapshot, setNodes, setEdges, markDirty, bumpHistory]);
const redo = useCallback(() => {
const next = redoRef.current.pop();
if (!next) return;
historyRef.current.push(snapshot());
historyLockRef.current = true;
setNodes(next.nodes);
setEdges(next.edges);
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
markDirty();
bumpHistory();
historyLockRef.current = false;
}, [snapshot, setNodes, setEdges, markDirty, bumpHistory]);
return {
dirty,
canUndo,
canRedo,
dirtyRef,
historyRef,
redoRef,
historyLockRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
markDirty,
clearDirty,
bumpHistory,
confirmDiscardIfDirty,
pushHistory,
undo,
redo,
};
}

View file

@ -0,0 +1,670 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, type QueryClient } from "@tanstack/react-query";
import type { Edge, Node } from "@xyflow/react";
import type { SetURLSearchParams } from "react-router-dom";
import { queryKeys } from "../../../constants/queryKeys";
import {
createTopologyFolder,
deleteTopologyFolder,
deleteTopologyMap,
fetchTopologyTree,
fetchTopologyWorld,
searchFabricNodes,
updateTopologyFolder,
updateTopologyMap,
} from "../../../services/api";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types";
import { LAST_LEAF_KEY, TREE_EXPAND_KEY, WORLD_MAP_ENABLED } from "../lib/constants";
import type { CtxMenu } from "../lib/pageTypes";
import {
findFolderInTree,
findViewInRegion,
folderPathFolders,
folderPathIds,
isRegionCanvasFolder,
isUmeWorldContainer,
isWorldDrillFolder,
isWorldFlatViewName,
regionDisplayName,
} from "../lib/treeUtils";
import type { NeNodeData } from "../components/TopologyReactFlowView";
export type UseTopologyTreeNavOptions = {
liveSync: boolean;
searchParams: URLSearchParams;
setSearchParams: SetURLSearchParams;
locale: string;
queryClient: QueryClient;
confirmDiscardIfDirty: () => boolean;
clearDirty: () => void;
showOk: (msg: string) => void;
showError: (msg: string) => void;
t: (key: string) => string;
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
setCtxMenu: React.Dispatch<React.SetStateAction<CtxMenu | null>>;
activeViewName?: string;
};
export function useTopologyTreeNav(opts: UseTopologyTreeNavOptions) {
const {
liveSync,
searchParams,
setSearchParams,
locale,
queryClient,
confirmDiscardIfDirty,
clearDirty,
showOk,
showError,
t,
setNodes,
setEdges,
setCtxMenu,
activeViewName,
} = opts;
const [mapId, setMapId] = useState("");
const [worldFocusFolderId, setWorldFocusFolderId] = useState("");
const [worldViewId, setWorldViewId] = useState("");
const [selectedFolderId, setSelectedFolderId] = useState("");
const [hotBrowseKey, setHotBrowseKey] = useState("");
const [pendingHighlightNe, setPendingHighlightNe] = useState("");
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
try {
const raw = localStorage.getItem(TREE_EXPAND_KEY);
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
} catch {
return {};
}
});
const [treeNeQuery, setTreeNeQuery] = useState("");
const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState("");
const [treeSearchOpen, setTreeSearchOpen] = useState(false);
const treeSearchRef = useRef<HTMLDivElement | null>(null);
const [newRootDialog, setNewRootDialog] = useState<{ name: string } | null>(null);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
return () => window.clearTimeout(timer);
}, [treeNeQuery]);
const treeQuery = useQuery({
queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree,
staleTime: liveSync ? 0 : 30_000,
refetchOnWindowFocus: liveSync,
refetchInterval: liveSync ? 5000 : false,
refetchIntervalInBackground: false,
});
const treeFlatMap = useMemo(() => {
if (!mapId) return false;
const kids = treeQuery.data?.root?.children || [];
return isWorldFlatViewName(findViewInRegion(kids, mapId)?.view?.name);
}, [mapId, treeQuery.data?.root?.children]);
const treeNeSearchQuery = useQuery({
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
enabled: debouncedTreeNeQuery.length >= 1,
});
const treeRoot = treeQuery.data?.root || null;
const regions = useMemo(() => treeRoot?.children || [], [treeRoot]);
const treeLoading = treeQuery.isPending && !treeQuery.data;
const treeFailed = treeQuery.isError && !treeQuery.data;
const rootFolderId = String(treeRoot?.id || "").trim();
const canvasModeLocal = Boolean(mapId);
useEffect(() => {
if (!treeRoot || !regions.length) return;
if (mapId) {
const hit = findViewInRegion(regions, mapId);
if (hit && selectedFolderId !== hit.region.id) {
setSelectedFolderId(hit.region.id);
setExpandedIds((prev) => ({ ...prev, [hit.region.id]: true }));
}
return;
}
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
setSelectedFolderId("");
}
}, [mapId, selectedFolderId, treeRoot, regions]);
const activeRegion = useMemo(() => {
if (!selectedFolderId) return null;
return findFolderInTree(regions, selectedFolderId);
}, [regions, selectedFolderId]);
const activeView = useMemo(() => {
if (!mapId) return null;
return findViewInRegion(regions, mapId)?.view || null;
}, [mapId, regions]);
const browseEntries = useMemo((): TopologyTreeViewItem[] => {
if (
!activeRegion ||
isRegionCanvasFolder(activeRegion, rootFolderId) ||
isUmeWorldContainer(activeRegion)
) {
return [];
}
return activeRegion.views || [];
}, [activeRegion, rootFolderId]);
const hexBrowseRegion = useMemo(() => {
if (!activeRegion) return null;
if (isUmeWorldContainer(activeRegion)) return activeRegion;
if (isRegionCanvasFolder(activeRegion, rootFolderId)) return null;
return activeRegion;
}, [activeRegion, rootFolderId]);
const umeWorldHexModules = useMemo(() => {
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
const drill = (activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null;
const flatView = WORLD_MAP_ENABLED
? (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null
: null;
return { drill, flatView };
}, [activeRegion]);
useEffect(() => {
if (!WORLD_MAP_ENABLED) {
setWorldViewId("");
return;
}
let cancelled = false;
fetchTopologyWorld()
.then((w) => {
if (cancelled) return;
setWorldViewId(w.view_id);
})
.catch(() => {
/* world not seeded yet */
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
try {
localStorage.setItem(TREE_EXPAND_KEY, JSON.stringify(expandedIds));
} catch {
/* ignore */
}
}, [expandedIds]);
useEffect(() => {
try {
if (mapId) localStorage.setItem(LAST_LEAF_KEY, mapId);
else localStorage.removeItem(LAST_LEAF_KEY);
} catch {
/* ignore */
}
}, [mapId]);
useEffect(() => {
if (WORLD_MAP_ENABLED || !mapId || !treeFlatMap) return;
const container = regions.find(
(r) => r.external_ref === "ume:world" || r.name === "UME World",
);
setWorldFocusFolderId("");
if (container) {
setSelectedFolderId(container.id);
setExpandedIds((p) => ({ ...p, [container.id]: true }));
}
setMapId("");
clearDirty();
}, [mapId, treeFlatMap, regions, clearDirty]);
useEffect(() => {
const viewId = String(searchParams.get("view") || "").trim();
const neId = String(searchParams.get("ne") || "").trim();
if (!viewId || !regions.length) return;
const hit = findViewInRegion(regions, viewId);
if (!hit) return;
setSelectedFolderId(hit.region.id);
setExpandedIds((p) => ({ ...p, [hit.region.id]: true }));
setMapId(viewId);
if (neId) setPendingHighlightNe(neId);
setSearchParams({}, { replace: true });
}, [regions, searchParams, setSearchParams]);
useEffect(() => {
if (!mapId && !selectedFolderId) return;
const root = document.querySelector(".topo-region-list");
if (!root) return;
const hit =
root.querySelector("li.is-active") ||
root.querySelector(".topo-region-list__block.is-branch-active");
if (hit && "scrollIntoView" in hit) {
(hit as HTMLElement).scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [mapId, selectedFolderId, expandedIds]);
useEffect(() => {
if (!treeSearchOpen) return;
const onDoc = (e: MouseEvent) => {
const el = treeSearchRef.current;
if (el && e.target instanceof Element && !el.contains(e.target)) {
setTreeSearchOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [treeSearchOpen]);
const expandFolderPath = useCallback(
(folderId: string) => {
const path = folderPathIds(regions, folderId);
if (!path.length) return;
setExpandedIds((p) => {
const next = { ...p };
for (const id of path) next[id] = true;
return next;
});
},
[regions],
);
const primaryViewOfFolder = useCallback((folder: TopologyTreeFolderItem | null | undefined) => {
if (!folder) return null;
const views = folder.views || [];
if (isWorldDrillFolder(folder)) {
return views.find((v) => v.name === "World") || views[0] || null;
}
if (folder.external_ref === "ume:world" || folder.name === "UME World") {
const drill = (folder.children || []).find((c) => isWorldDrillFolder(c));
if (drill) {
const dv = drill.views || [];
return dv.find((v) => v.name === "World") || dv[0] || null;
}
return views.find((v) => !isWorldFlatViewName(v.name)) || null;
}
const physical = views.find((v) => String(v.kind) === "physical") || null;
return physical || views[0] || null;
}, []);
const goUmeWorldNav = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
const container = regions.find(
(r) => r.external_ref === "ume:world" || r.name === "UME World",
);
if (!container) return;
setWorldFocusFolderId("");
setSelectedFolderId(container.id);
setExpandedIds((p) => ({ ...p, [container.id]: true }));
setMapId("");
clearDirty();
}, [confirmDiscardIfDirty, clearDirty, regions]);
const goRegion = useCallback(
(folderId: string) => {
if (!confirmDiscardIfDirty()) return;
setSelectedFolderId(folderId);
expandFolderPath(folderId);
const folder = findFolderInTree(regions, folderId);
if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) {
setWorldFocusFolderId("");
const view = primaryViewOfFolder(folder);
if (view) {
setMapId(view.id);
clearDirty();
return;
}
}
setMapId("");
setWorldFocusFolderId("");
clearDirty();
setNodes([]);
setEdges([]);
},
[
confirmDiscardIfDirty,
clearDirty,
setNodes,
setEdges,
regions,
primaryViewOfFolder,
expandFolderPath,
treeRoot?.id,
],
);
const goRoot = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
setSelectedFolderId("");
setWorldFocusFolderId("");
setMapId("");
clearDirty();
setNodes([]);
setEdges([]);
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
const goCanvas = useCallback(
(viewId: string, folderId?: string) => {
if (!confirmDiscardIfDirty()) return;
const hit = findViewInRegion(regions, viewId);
if (!WORLD_MAP_ENABLED && isWorldFlatViewName(hit?.view?.name)) {
showError(t("topology.worldMapOffline"));
return;
}
const regionId = folderId || hit?.region.id || selectedFolderId;
if (regionId) {
setSelectedFolderId(regionId);
expandFolderPath(regionId);
}
setWorldFocusFolderId("");
setMapId(viewId);
},
[confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath, showError, t],
);
const goBackBrowse = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
const folder = findFolderInTree(regions, selectedFolderId);
const parentId = String(folder?.parent_id || "").trim();
const parent = parentId ? findFolderInTree(regions, parentId) : null;
if (parent) {
setSelectedFolderId(parent.id);
expandFolderPath(parent.id);
if (isRegionCanvasFolder(parent, String(treeRoot?.id || ""))) {
setWorldFocusFolderId("");
const view = primaryViewOfFolder(parent);
if (view) {
setMapId(view.id);
clearDirty();
return;
}
}
setWorldFocusFolderId("");
setMapId("");
clearDirty();
setNodes([]);
setEdges([]);
return;
}
setSelectedFolderId("");
setWorldFocusFolderId("");
setMapId("");
clearDirty();
setNodes([]);
setEdges([]);
}, [
confirmDiscardIfDirty,
clearDirty,
setNodes,
setEdges,
regions,
selectedFolderId,
treeRoot?.id,
expandFolderPath,
primaryViewOfFolder,
]);
const renameMapMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyMap(id, { name }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.renamed"));
},
onError: (err) => showError(String(err)),
});
const promptRenameMap = useCallback(
(id: string, currentName: string) => {
const next = window.prompt(t("topology.renamePrompt"), currentName);
if (next == null) return;
const name = next.trim();
if (!name || name === currentName) return;
renameMapMut.mutate({ id, name });
},
[renameMapMut, t],
);
const createRegionMut = useMutation({
mutationFn: (input: { name: string; parent_id?: string }) => {
const parentId = String(input.parent_id || "").trim();
return createTopologyFolder(
parentId
? { name: input.name, kind: "region", parent_id: parentId, locale }
: { name: input.name, kind: "region", locale },
);
},
onSuccess: async (folder, input) => {
setNewRootDialog(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
const tree = await fetchTopologyTree();
const regionsList = tree.root?.children || [];
const hit = findFolderInTree(regionsList, folder.id);
const parent = String(input.parent_id || folder.parent_id || hit?.parent_id || "").trim();
const rootId = String(tree.root?.id || "").trim();
const isNested = Boolean(parent && parent !== rootId);
setWorldFocusFolderId("");
setExpandedIds((prev) => {
const next = { ...prev, [folder.id]: true };
if (parent) next[parent] = true;
return next;
});
if (isNested) {
setSelectedFolderId(parent);
expandFolderPath(parent);
const parentHit = findFolderInTree(regionsList, parent);
const parentView =
primaryViewOfFolder(parentHit) ||
(parentHit?.views || []).find((v) => String(v.kind) === "physical") ||
(parentHit?.views || [])[0];
const stayViewId = mapId || parentView?.id || "";
if (stayViewId) {
setMapId(stayViewId);
await queryClient.invalidateQueries({
queryKey: queryKeys.topologyGraph(stayViewId),
});
}
} else {
setSelectedFolderId(folder.id);
setMapId("");
}
showOk(t("topology.regionCreated"));
},
onError: (err) => showError(String(err)),
});
const renameRegionMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyFolder(id, { name }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
showOk(t("topology.regionRenamed"));
},
onError: (err) => showError(String(err)),
});
const promptRenameRegion = useCallback(
(id: string, currentName: string) => {
const next = window.prompt(t("topology.renameRegionPrompt"), currentName);
if (next == null) return;
const name = next.trim();
if (!name || name === currentName) return;
renameRegionMut.mutate({ id, name });
},
[renameRegionMut, t],
);
const deleteMapMut = useMutation({
mutationFn: (id: string) => deleteTopologyMap(id),
onSuccess: async (_out, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
if (mapId === id) {
clearDirty();
setMapId("");
setNodes([]);
setEdges([]);
}
},
onError: (err) => showError(String(err)),
});
const deleteFolderMut = useMutation({
mutationFn: (id: string) => deleteTopologyFolder(id, false),
onSuccess: async (_out, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
if (selectedFolderId === id) {
setSelectedFolderId("");
setMapId("");
}
showOk(t("topology.regionDeleted"));
},
onError: (err) => showError(String(err)),
});
const promptNewRegion = useCallback(() => {
setNewRootDialog({ name: t("topology.newRegionName") });
}, [t]);
const submitNewRoot = useCallback(() => {
const name = String(newRootDialog?.name || "").trim();
if (!name) {
showError(t("topology.newRegionPrompt"));
return;
}
createRegionMut.mutate({ name });
}, [newRootDialog, createRegionMut, showError, t]);
const promptNewSubRegion = useCallback(() => {
const viewName = activeView?.name || activeViewName;
if (isWorldFlatViewName(viewName)) {
showError(t("topology.subRegionNotOnFlat"));
return;
}
let parentId = selectedFolderId;
if (!parentId && worldViewId && mapId === worldViewId) {
const container = regions.find(
(r) => r.external_ref === "ume:world" || r.name === "UME World",
);
const drill = (container?.children || []).find((c) => isWorldDrillFolder(c));
parentId = drill?.id || container?.id || "";
}
if (!parentId) {
showError(t("topology.subRegionNeedParent"));
return;
}
const name = window.prompt(t("topology.newSubRegionPrompt"), t("topology.newSubRegionName"));
if (!name?.trim()) return;
createRegionMut.mutate({ name: name.trim(), parent_id: parentId });
setCtxMenu(null);
}, [
activeView?.name,
activeViewName,
selectedFolderId,
worldViewId,
mapId,
regions,
createRegionMut,
t,
showError,
setCtxMenu,
]);
const onTreeToggleExpand = useCallback((folderId: string, nextOpen: boolean) => {
setExpandedIds((p) => ({ ...p, [folderId]: nextOpen }));
}, []);
const onTreeHotBrowseKey = useCallback((key: string) => {
setHotBrowseKey(key);
}, []);
const onTreeClearHotBrowseKey = useCallback((key: string) => {
setHotBrowseKey((k) => (k === key ? "" : k));
}, []);
const onTreeDeleteFolder = useCallback(
(folderId: string) => {
deleteFolderMut.mutate(folderId);
},
[deleteFolderMut],
);
const onTreeDeleteMap = useCallback(
(viewId: string) => {
deleteMapMut.mutate(viewId);
},
[deleteMapMut],
);
const breadcrumbFolders = useMemo(
() => folderPathFolders(regions, selectedFolderId),
[regions, selectedFolderId],
);
const titleText = useMemo(() => {
if (activeRegion) return regionDisplayName(activeRegion, t);
return t("topology.rootName");
}, [activeRegion, t]);
return {
mapId,
setMapId,
worldFocusFolderId,
setWorldFocusFolderId,
worldViewId,
selectedFolderId,
setSelectedFolderId,
hotBrowseKey,
setHotBrowseKey,
expandedIds,
setExpandedIds,
treeNeQuery,
setTreeNeQuery,
debouncedTreeNeQuery,
setDebouncedTreeNeQuery,
treeSearchOpen,
setTreeSearchOpen,
treeSearchRef,
newRootDialog,
setNewRootDialog,
treeQuery,
treeFlatMap,
treeNeSearchQuery,
treeRoot,
regions,
treeLoading,
treeFailed,
rootFolderId,
canvasMode: canvasModeLocal,
activeRegion,
activeView,
browseEntries,
hexBrowseRegion,
umeWorldHexModules,
expandFolderPath,
primaryViewOfFolder,
goUmeWorldNav,
goRegion,
goRoot,
goCanvas,
goBackBrowse,
renameMapMut,
promptRenameMap,
createRegionMut,
renameRegionMut,
promptRenameRegion,
deleteMapMut,
deleteFolderMut,
promptNewRegion,
submitNewRoot,
promptNewSubRegion,
onTreeToggleExpand,
onTreeHotBrowseKey,
onTreeClearHotBrowseKey,
onTreeDeleteFolder,
onTreeDeleteMap,
breadcrumbFolders,
titleText,
pendingHighlightNe,
setPendingHighlightNe,
};
}

View file

@ -0,0 +1,73 @@
/** Alarm severity helpers for topology overlay (UME current alarms). */
export type AlarmSeverity =
| "critical"
| "major"
| "minor"
| "warning"
| "indeterminate"
| "cleared"
| "unknown";
export type NodeAlarmSummary = {
severity: AlarmSeverity;
count: number;
};
const SEV_RANK: Record<string, number> = {
critical: 60,
major: 50,
minor: 40,
warning: 30,
indeterminate: 20,
unknown: 10,
cleared: 0,
};
export function normalizeAlarmSeverity(raw: string | undefined | null): AlarmSeverity {
const s = String(raw || "").trim().toLowerCase();
if (!s) return "unknown";
if (s.includes("critical") || s === "5") return "critical";
if (s.includes("major") || s === "4") return "major";
if (s.includes("minor") || s === "3") return "minor";
if (s.includes("warning") || s.includes("warn") || s === "2") return "warning";
if (s.includes("indeterminate") || s === "1") return "indeterminate";
if (s.includes("clear") || s === "0") return "cleared";
return "unknown";
}
export function severityRank(sev: string): number {
return SEV_RANK[normalizeAlarmSeverity(sev)] ?? 0;
}
export function worseSeverity(a: string, b: string): AlarmSeverity {
return severityRank(a) >= severityRank(b) ? normalizeAlarmSeverity(a) : normalizeAlarmSeverity(b);
}
export function normalizeHostKey(value: string | undefined | null): string {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
/** IPv4 / IPv6-ish — safe for exact alarm join. */
export function looksLikeIp(value: string | undefined | null): boolean {
const s = normalizeHostKey(value);
if (!s) return false;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return true;
// Compact check: colon + hex/digits (avoid matching short names).
return s.includes(":") && /^[0-9a-f:%.]+$/i.test(s) && s.length >= 4;
}
/**
* Host/name keys used for alarm overlay join.
* Reject short bare labels (e.g. "r1") that collide across sites.
*/
export function isStrongHostKey(value: string | undefined | null): boolean {
const k = normalizeHostKey(value);
if (!k) return false;
if (looksLikeIp(k)) return true;
if (k.includes(".")) return true; // FQDN / dotted inventory name
return k.length >= 8;
}

View file

@ -0,0 +1,33 @@
/** Flat world-map canvas (LOD / scatter). Temporarily offline in the UI. */
export const WORLD_MAP_ENABLED = false;
export const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
export const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
export const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth";
export const SIDEBAR_WIDTH_DEFAULT = 260;
export const SIDEBAR_WIDTH_MIN = 180;
export const SIDEBAR_WIDTH_MAX = 560;
export const UNDO_MAX = 40;
export const PALETTE_DND = "application/x-netx-topo-palette";
export const SEP = " / ";
export const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
export const WORLD_LOCATE_ZOOM = 0.22;
export const WORLD_LOCATE_HALF = 12000;
export const WORLD_FLAT_ACCUM_CAP = 8000;
export const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
export const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
export const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched.v2";
export const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2";
export const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
export const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge";
export const SHOW_CONNECT_STATUS_KEY = "netx.topology.showConnectStatus";
export const SHOW_ALARM_OVERLAY_KEY = "netx.topology.showAlarmOverlay";
export const SHOW_LEVEL_COLORS_KEY = "netx.topology.showLevelColors";
export const CANVAS_BG_KEY = "netx.topology.canvasBg";
export const DEFAULT_CANVAS_BG = "#0f172a";
export const LEGACY_CANVAS_BG = "#dbeafe";
export const LABEL_COLORS_KEY = "netx.topology.labelColors";
export const VENDOR_COLORS_KEY = "netx.topology.vendorColors";

View file

@ -0,0 +1,146 @@
import type { CSSProperties } from "react";
import {
AUTO_LAYOUT_DISCOVER_KEY,
CANVAS_BG_KEY,
DEFAULT_CANVAS_BG,
LABEL_COLORS_KEY,
LEGACY_CANVAS_BG,
VENDOR_COLORS_KEY,
} from "./constants";
export const VENDOR_TONE_KEYS = [
"cisco",
"huawei",
"zte",
"juniper",
"nokia",
"ericsson",
"h3c",
"ruijie",
"mikrotik",
"gray",
] as const;
export type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number];
export type VendorColors = Record<VendorToneKey, string>;
export type LabelColors = { name: string; edgeLabel: string };
export const DEFAULT_LABEL_COLORS: LabelColors = {
name: "#f1f5f9",
edgeLabel: "#e2e8f0",
};
export const DEFAULT_VENDOR_COLORS: VendorColors = {
cisco: "#049fd9",
huawei: "#cf0a2c",
zte: "#0091da",
juniper: "#84b135",
nokia: "#124191",
ericsson: "#1e3a5f",
h3c: "#7ac143",
ruijie: "#7c3aed",
mikrotik: "#ea580c",
gray: "#94a3b8",
};
export function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value);
}
export function loadCanvasBg(): string {
try {
const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase();
if (raw === LEGACY_CANVAS_BG) {
persistCanvasBg(DEFAULT_CANVAS_BG);
return DEFAULT_CANVAS_BG;
}
if (isHexColor(raw)) return raw;
} catch {
/* ignore */
}
return DEFAULT_CANVAS_BG;
}
export function persistCanvasBg(value: string) {
try {
localStorage.setItem(CANVAS_BG_KEY, value);
} catch {
/* ignore */
}
}
export function loadLabelColors(): LabelColors {
try {
const raw = localStorage.getItem(LABEL_COLORS_KEY);
if (!raw) return { ...DEFAULT_LABEL_COLORS };
const parsed = JSON.parse(raw) as Partial<LabelColors>;
return {
name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name,
edgeLabel: isHexColor(String(parsed.edgeLabel || ""))
? String(parsed.edgeLabel).toLowerCase()
: DEFAULT_LABEL_COLORS.edgeLabel,
};
} catch {
return { ...DEFAULT_LABEL_COLORS };
}
}
export function persistLabelColors(value: LabelColors) {
try {
localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value));
} catch {
/* ignore */
}
}
export function loadVendorColors(): VendorColors {
try {
const raw = localStorage.getItem(VENDOR_COLORS_KEY);
if (!raw) return { ...DEFAULT_VENDOR_COLORS };
const parsed = JSON.parse(raw) as Partial<VendorColors>;
const out = { ...DEFAULT_VENDOR_COLORS };
for (const key of VENDOR_TONE_KEYS) {
const c = String(parsed?.[key] || "").trim().toLowerCase();
if (isHexColor(c)) out[key] = c;
}
return out;
} catch {
return { ...DEFAULT_VENDOR_COLORS };
}
}
export function persistVendorColors(value: VendorColors) {
try {
localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value));
} catch {
/* ignore */
}
}
export function loadBoolFlag(key: string, defaultValue: boolean): boolean {
try {
const raw = localStorage.getItem(key);
if (raw === null) return defaultValue;
return raw === "1" || raw === "true";
} catch {
return defaultValue;
}
}
export function persistBoolFlag(key: string, value: boolean) {
try {
localStorage.setItem(key, value ? "1" : "0");
} catch {
/* ignore */
}
}
export function loadAutoLayoutAfterDiscover(): boolean {
return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false);
}
export function persistAutoLayoutAfterDiscover(value: boolean) {
persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value);
}
export type { CSSProperties };

View file

@ -0,0 +1,118 @@
import type { Edge } from "@xyflow/react";
import type { LinkEdgeData } from "./linkDisplay";
import { EDGE_DEFAULTS_KEY } from "./constants";
export type EdgeLineStyle = "solid" | "dashed" | "dotted";
export type EdgeSourceKind = "manual" | "ume" | "discovered" | "stale";
export type EdgeDefaultStyle = {
stroke_color: string;
stroke_width: number;
line_style: EdgeLineStyle;
};
export type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
export type EdgeStyleData = LinkEdgeData;
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
ume: { stroke_color: "#2563eb", stroke_width: 2, line_style: "solid" },
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" },
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
};
export function sourceKind(source: string): EdgeSourceKind {
const src = (source || "manual").toLowerCase();
if (src === "stale" || src === "missing") return "stale";
if (src === "ume") return "ume";
if (src === "lldp" || src === "cdp") return "discovered";
return "manual";
}
export function loadEdgeDefaults(): EdgeDefaults {
try {
const raw = localStorage.getItem(EDGE_DEFAULTS_KEY);
if (!raw) {
return {
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
};
}
const parsed = JSON.parse(raw) as Partial<EdgeDefaults>;
const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => {
const base = BUILTIN_EDGE_DEFAULTS[kind];
const cur = parsed?.[kind];
const color = String(cur?.stroke_color || base.stroke_color).trim() || base.stroke_color;
const width = Math.max(1, Math.min(12, Number(cur?.stroke_width || base.stroke_width) || base.stroke_width));
const line = String(cur?.line_style || base.line_style).toLowerCase();
const line_style: EdgeLineStyle =
line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style;
return { stroke_color: color, stroke_width: width, line_style };
};
return {
ume: pick("ume"),
discovered: pick("discovered"),
stale: pick("stale"),
manual: pick("manual"),
};
} catch {
return {
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
};
}
}
export function persistEdgeDefaults(next: EdgeDefaults) {
try {
localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function dashForLineStyle(lineStyle: string): string | undefined {
const s = (lineStyle || "").trim().toLowerCase();
if (s === "dashed") return "6 4";
if (s === "dotted") return "2 2";
return undefined;
}
export function edgeStyleBySource(
source: string,
defaults: EdgeDefaults,
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
const d = defaults[sourceKind(source)];
return {
stroke: d.stroke_color,
strokeWidth: d.stroke_width,
strokeDasharray: dashForLineStyle(d.line_style),
};
}
export function resolveEdgeStyle(
data: EdgeStyleData | undefined,
defaults: EdgeDefaults,
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
const base = edgeStyleBySource(data?.source || "manual", defaults);
const color = String(data?.stroke_color || "").trim();
const width = Number(data?.stroke_width || 0);
const line = String(data?.line_style || "").trim().toLowerCase();
return {
stroke: color || base.stroke,
strokeWidth: width > 0 ? width : base.strokeWidth,
strokeDasharray: line ? dashForLineStyle(line) : base.strokeDasharray,
};
}
export function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
const data = (edge.data || {}) as EdgeStyleData;
const style = resolveEdgeStyle(data, defaults);
return { ...edge, style, markerEnd: undefined, labelShowBg: false };
}
export { BUILTIN_EDGE_DEFAULTS };

View file

@ -0,0 +1,459 @@
import { getStraightPath } from "@xyflow/react";
import type { Edge, Node } from "@xyflow/react";
import type { TopologyViewGraph, TopologyViewItem, TopologyViewNodeItem } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { isAggregateEdgeId, type LinkEdgeData } from "./linkDisplay";
import {
TOPO_HANDLE_X,
TOPO_HANDLE_Y,
TOPO_ICON,
TOPO_NODE_W,
TOPO_REGION_HANDLE_X,
TOPO_REGION_HANDLE_Y,
TOPO_REGION_ICON_H,
} from "./topoGeometry";
import type { VendorColors } from "./displayPrefs";
import { vendorColorForNode } from "./vendorTone";
const NE_ICON_URL = "/topo/ne-router.png";
const REGION_ICON_URL = "/topo/region-building.png";
export type TopologyVectorSvgTheme = {
labelColor: string;
edgeLabelColor: string;
vendorColors: VendorColors;
hideIp: boolean;
hideVendor: boolean;
};
type IconAssets = {
neTinted: Map<string, string>;
regionPlain: string;
};
function sanitizeFilename(name: string): string {
const trimmed = String(name || "topology")
.trim()
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
.replace(/\s+/g, "_")
.slice(0, 80);
return trimmed || "topology";
}
function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
}
function downloadText(filename: string, text: string, mime: string): void {
downloadBlob(filename, new Blob([text], { type: mime }));
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`failed to load icon: ${url}`));
img.src = url;
});
}
/** Match canvas: `.topo-node__icon-art` + masked `.topo-node__icon-tint` (mix-blend-mode: color). */
async function tintedIconDataUrl(
img: HTMLImageElement,
color: string,
w: number,
h: number,
): Promise<string> {
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unsupported");
ctx.drawImage(img, 0, 0, w, h);
const tint = document.createElement("canvas");
tint.width = w;
tint.height = h;
const tctx = tint.getContext("2d");
if (!tctx) throw new Error("canvas unsupported");
tctx.fillStyle = color;
tctx.fillRect(0, 0, w, h);
tctx.globalCompositeOperation = "destination-in";
tctx.drawImage(img, 0, 0, w, h);
ctx.globalCompositeOperation = "color";
ctx.drawImage(tint, 0, 0);
return canvas.toDataURL("image/png");
}
async function plainIconDataUrl(img: HTMLImageElement, w: number, h: number): Promise<string> {
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unsupported");
ctx.drawImage(img, 0, 0, w, h);
return canvas.toDataURL("image/png");
}
async function prepareIconAssets(
nodes: Node<NeNodeData>[],
theme: TopologyVectorSvgTheme,
): Promise<IconAssets> {
const [neImg, regionImg] = await Promise.all([loadImage(NE_ICON_URL), loadImage(REGION_ICON_URL)]);
const colors = new Set<string>();
for (const n of nodes) {
if (!isRegionNode(n)) colors.add(vendorColor(theme, n.data));
}
const neTinted = new Map<string, string>();
await Promise.all(
[...colors].map(async (color) => {
neTinted.set(color, await tintedIconDataUrl(neImg, color, TOPO_ICON, TOPO_ICON));
}),
);
const regionPlain = await plainIconDataUrl(regionImg, TOPO_ICON, TOPO_REGION_ICON_H);
return { neTinted, regionPlain };
}
function isRegionNode(node: Node<NeNodeData>): boolean {
return node.data.kind === "region" || String(node.id || "").startsWith("region:");
}
function nodeApiPosition(node: Node<NeNodeData>): { x: number; y: number } {
const isRegion = isRegionNode(node);
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
return { x: node.position.x + hx, y: node.position.y + hy };
}
function nodeGlyphBox(node: Node<NeNodeData>): { x: number; y: number; w: number; h: number } {
const isRegion = isRegionNode(node);
const w = TOPO_ICON;
const h = isRegion ? TOPO_REGION_ICON_H : TOPO_ICON;
return {
x: node.position.x + (TOPO_NODE_W - w) / 2,
y: node.position.y,
w,
h,
};
}
function nodeHandleCenter(node: Node<NeNodeData>): { x: number; y: number } {
const box = nodeGlyphBox(node);
return { x: box.x + box.w / 2, y: box.y + box.h / 2 };
}
function vendorColor(theme: TopologyVectorSvgTheme, data: NeNodeData): string {
return vendorColorForNode(data, theme.vendorColors);
}
function xmlEscape(value: string): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function xmlAttr(name: string, value: string | number | boolean | null | undefined): string {
if (value === null || value === undefined || value === "") return "";
return ` ${name}="${xmlEscape(String(value))}"`;
}
type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
function growBounds(bounds: Bounds, x: number, y: number, pad = 0): void {
bounds.minX = Math.min(bounds.minX, x - pad);
bounds.minY = Math.min(bounds.minY, y - pad);
bounds.maxX = Math.max(bounds.maxX, x + pad);
bounds.maxY = Math.max(bounds.maxY, y + pad);
}
function nodeCaptionLines(node: Node<NeNodeData>, theme: TopologyVectorSvgTheme): string[] {
const data = node.data;
const isRegion = isRegionNode(node);
const name = data.label || (!theme.hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
const lines = [name];
if (!isRegion) {
const meta = [
theme.hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
theme.hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
if (meta.length) lines.push(meta.join(" / "));
}
return lines;
}
function computeGraphBounds(
nodes: Node<NeNodeData>[],
displayEdges: Edge[],
centers: Map<string, { x: number; y: number }>,
theme: TopologyVectorSvgTheme,
): Bounds {
const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
for (const node of nodes) {
const box = nodeGlyphBox(node);
growBounds(bounds, box.x, box.y);
growBounds(bounds, box.x + box.w, box.y + box.h);
const lines = nodeCaptionLines(node, theme);
const captionY = box.y + box.h + 1;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const approxWidth = Math.max(24, line.length * (i === 0 ? 5 : 4.2));
const lineY = captionY + i * (i === 0 ? 10 : 8);
growBounds(bounds, box.x + box.w / 2 - approxWidth / 2, lineY);
growBounds(bounds, box.x + box.w / 2 + approxWidth / 2, lineY + (i === 0 ? 10 : 8));
}
}
for (const edge of displayEdges) {
const a = centers.get(edge.source);
const b = centers.get(edge.target);
if (!a || !b) continue;
growBounds(bounds, a.x, a.y);
growBounds(bounds, b.x, b.y);
const label = String(edge.label || "").trim();
if (label) {
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
growBounds(bounds, mx, my, Math.max(20, label.length * 3));
}
}
if (!Number.isFinite(bounds.minX)) {
return { minX: 0, minY: 0, maxX: 800, maxY: 600 };
}
return bounds;
}
function edgeVisual(edge: Edge): { stroke: string; strokeWidth: number; strokeDasharray?: string } {
const style = (edge.style || {}) as {
stroke?: string;
strokeWidth?: number;
strokeDasharray?: string;
};
return {
stroke: String(style.stroke || "#64748b"),
strokeWidth: Number(style.strokeWidth || 2),
strokeDasharray: style.strokeDasharray ? String(style.strokeDasharray) : undefined,
};
}
function renderNodeIcon(
node: Node<NeNodeData>,
theme: TopologyVectorSvgTheme,
icons: IconAssets,
): string {
const box = nodeGlyphBox(node);
if (isRegionNode(node)) {
return `<image href="${icons.regionPlain}" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" preserveAspectRatio="xMidYMid meet"/>`;
}
const color = vendorColor(theme, node.data);
const href = icons.neTinted.get(color);
if (!href) return "";
return `<image href="${href}" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" preserveAspectRatio="xMidYMid meet"/>`;
}
function renderNode(node: Node<NeNodeData>, theme: TopologyVectorSvgTheme, icons: IconAssets): string {
const box = nodeGlyphBox(node);
const centerX = box.x + box.w / 2;
const icon = renderNodeIcon(node, theme, icons);
const lines = nodeCaptionLines(node, theme);
const captionParts = lines.map((line, idx) => {
const y = box.y + box.h + 1 + (idx === 0 ? 8 : 8 + idx * 8);
const fontSize = idx === 0 ? 8 : 7;
const opacity = idx === 0 ? 1 : 0.72;
return `<text x="${centerX}" y="${y}" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="${fontSize}" font-weight="${idx === 0 ? 600 : 400}" fill="${xmlEscape(theme.labelColor)}" opacity="${opacity}">${xmlEscape(line)}</text>`;
});
return [
`<g id="node-${xmlEscape(node.id)}" class="topo-export-node">`,
icon,
...captionParts,
`</g>`,
].join("");
}
function renderEdge(
edge: Edge,
centers: Map<string, { x: number; y: number }>,
theme: TopologyVectorSvgTheme,
): string {
const a = centers.get(edge.source);
const b = centers.get(edge.target);
if (!a || !b) return "";
const data = (edge.data || {}) as LinkEdgeData;
const index = Number(data.parallelIndex || 0);
const count = Math.max(1, Number(data.parallelCount || 1));
const offset = (index - (count - 1) / 2) * 10;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const ox = (-dy / len) * offset;
const oy = (dx / len) * offset;
const sx = a.x + ox;
const sy = a.y + oy;
const tx = b.x + ox;
const ty = b.y + oy;
const visual = edgeVisual(edge);
const dash = visual.strokeDasharray
? ` stroke-dasharray="${xmlEscape(visual.strokeDasharray)}"`
: "";
const [path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
const label = String(edge.label || "").trim();
const labelMarkup = label
? `<text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="ui-monospace, SFMono-Regular, Consolas, monospace" font-size="7" fill="${xmlEscape(theme.edgeLabelColor)}">${xmlEscape(label)}</text>`
: "";
return [
`<g id="edge-${xmlEscape(edge.id)}" class="topo-export-edge">`,
`<path d="${xmlEscape(path)}" fill="none" stroke="${xmlEscape(visual.stroke)}" stroke-width="${visual.strokeWidth}" stroke-linecap="round"${dash}/>`,
labelMarkup,
`</g>`,
].join("");
}
/** Build a vector SVG using the same PNG icons + vendor tint as the canvas. */
export async function buildTopologyVectorSvg(
nodes: Node<NeNodeData>[],
displayEdges: Edge[],
theme: TopologyVectorSvgTheme,
title?: string,
): Promise<string> {
const icons = await prepareIconAssets(nodes, theme);
const centers = new Map(nodes.map((n) => [n.id, nodeHandleCenter(n)] as const));
const bounds = computeGraphBounds(nodes, displayEdges, centers, theme);
const pad = 48;
const minX = bounds.minX - pad;
const minY = bounds.minY - pad;
const width = bounds.maxX - bounds.minX + pad * 2;
const height = bounds.maxY - bounds.minY + pad * 2;
const edgeMarkup = displayEdges.map((e) => renderEdge(e, centers, theme)).join("");
const nodeMarkup = nodes.map((n) => renderNode(n, theme, icons)).join("");
const titleMarkup = title ? `<title>${xmlEscape(title)}</title>` : "";
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${minX} ${minY} ${width} ${height}" width="${Math.round(width)}" height="${Math.round(height)}">`,
titleMarkup,
`<desc>netx topology vector export</desc>`,
`<g id="edges">${edgeMarkup}</g>`,
`<g id="nodes">${nodeMarkup}</g>`,
`</svg>`,
].join("\n");
}
export function buildExportGraph(
view: TopologyViewItem,
nodes: Node<NeNodeData>[],
edges: Edge[],
serverGraph?: TopologyViewGraph | null,
): TopologyViewGraph {
const serverNodeMap = new Map(
(serverGraph?.nodes || []).map((n) => [n.fabric_node_id, n] as const),
);
const serverEdgeMap = new Map((serverGraph?.edges || []).map((e) => [e.id, e] as const));
const exportNodes: TopologyViewNodeItem[] = nodes.map((n) => {
const pos = nodeApiPosition(n);
const server = serverNodeMap.get(n.id);
return {
fabric_node_id: n.id,
managed_ne_id: n.data.managed_ne_id || server?.managed_ne_id || "",
ume_ne_id: n.data.ume_ne_id || server?.ume_ne_id || "",
label: n.data.label || server?.label || "",
x: pos.x,
y: pos.y,
locked: server?.locked || false,
name: server?.name || n.data.label || "",
ip: n.data.ne_ip || server?.ip || "",
vendor: n.data.vendor || server?.vendor || "",
device_type: server?.device_type || (isRegionNode(n) ? "region" : ""),
connect_status: n.data.connect_status || server?.connect_status || "",
managed_source: n.data.managed_source || server?.managed_source,
kind: n.data.kind || server?.kind,
folder_id: n.data.folder_id || server?.folder_id,
view_id: n.data.view_id || server?.view_id,
node_count: n.data.node_count ?? server?.node_count,
};
});
const physicalEdges = edges.filter((e) => !isAggregateEdgeId(String(e.id)));
const exportEdges = physicalEdges.map((e) => {
const data = (e.data || {}) as LinkEdgeData;
const server = serverEdgeMap.get(e.id);
return {
id: e.id,
a_node_id: e.source,
b_node_id: e.target,
a_port: data.source_port || server?.a_port || "",
b_port: data.target_port || server?.b_port || "",
source: data.source || server?.source || "manual",
status: server?.status || "active",
layer: server?.layer || "physical",
stroke_color: data.stroke_color || server?.stroke_color,
stroke_width: data.stroke_width || server?.stroke_width,
line_style: data.line_style || server?.line_style,
discovered_at: data.discovered_at ?? server?.discovered_at ?? null,
display_label: data.display_label || server?.display_label,
};
});
return {
view,
nodes: exportNodes,
edges: exportEdges,
truncated: serverGraph?.truncated,
truncate_reason: serverGraph?.truncate_reason,
outside_peers: serverGraph?.outside_peers,
world_transform: serverGraph?.world_transform,
scatter: serverGraph?.scatter,
};
}
export function topologyGraphToXml(graph: TopologyViewGraph): string {
const view = graph.view;
const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<topology xmlns="urn:netx:topology:export" version="1"${xmlAttr("exported-at", new Date().toISOString())}>`,
" <view",
`${xmlAttr("id", view.id)}${xmlAttr("name", view.name)}${xmlAttr("kind", view.kind)}${xmlAttr("folder_id", view.folder_id)}${xmlAttr("remark", view.remark || "")} />`,
` <nodes count="${graph.nodes.length}">`,
];
for (const n of graph.nodes) {
lines.push(
` <node${xmlAttr("id", n.fabric_node_id)}${xmlAttr("x", n.x)}${xmlAttr("y", n.y)}${xmlAttr("label", n.label)}${xmlAttr("name", n.name)}${xmlAttr("ip", n.ip)}${xmlAttr("vendor", n.vendor)}${xmlAttr("kind", n.kind)}${xmlAttr("managed_ne_id", n.managed_ne_id)}${xmlAttr("ume_ne_id", n.ume_ne_id)}${xmlAttr("folder_id", n.folder_id)}${xmlAttr("view_id", n.view_id)} />`,
);
}
lines.push(" </nodes>");
lines.push(` <edges count="${graph.edges.length}">`);
for (const e of graph.edges) {
lines.push(
` <edge${xmlAttr("id", e.id)}${xmlAttr("a_node_id", e.a_node_id)}${xmlAttr("b_node_id", e.b_node_id)}${xmlAttr("a_port", e.a_port)}${xmlAttr("b_port", e.b_port)}${xmlAttr("source", e.source)}${xmlAttr("status", e.status)}${xmlAttr("layer", e.layer)}${xmlAttr("display_label", e.display_label || "")} />`,
);
}
lines.push(" </edges>");
lines.push("</topology>");
return lines.join("\n");
}
export async function exportTopologySvg(
nodes: Node<NeNodeData>[],
displayEdges: Edge[],
filenameBase: string,
theme: TopologyVectorSvgTheme,
title?: string,
): Promise<void> {
const svg = await buildTopologyVectorSvg(nodes, displayEdges, theme, title);
downloadText(`${sanitizeFilename(filenameBase)}.svg`, svg, "image/svg+xml;charset=utf-8");
}
export function exportTopologyXml(graph: TopologyViewGraph, filenameBase: string): void {
const xml = topologyGraphToXml(graph);
downloadText(`${sanitizeFilename(filenameBase)}.xml`, xml, "application/xml;charset=utf-8");
}

View file

@ -0,0 +1,189 @@
import type { Edge, Node, Position } from "@xyflow/react";
import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../../../types";
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { formatPortPairLabel } from "./linkDisplay";
import type { EdgeDefaults, EdgeStyleData } from "./edgeStyle";
import { withEdgeVisual } from "./edgeStyle";
import {
TOPO_NODE_H,
TOPO_NODE_W,
TOPO_REGION_HANDLE_X,
TOPO_REGION_HANDLE_Y,
TOPO_REGION_ICON_H,
TOPO_HANDLE_X,
TOPO_HANDLE_Y,
neApiPosition,
neFlowPosition,
regionApiPosition,
regionFlowPosition,
} from "./topoGeometry";
export function graphToFlow(
nodes: TopologyViewNodeItem[],
edges: TopologyViewEdgeItem[],
defaults: EdgeDefaults,
) {
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => {
const isRegion =
n.kind === "region" || n.device_type === "region" || String(n.fabric_node_id || "").startsWith("region:");
const apiX = Number(n.x) || 0;
const apiY = Number(n.y) || 0;
const position = isRegion ? regionFlowPosition(apiX, apiY) : neFlowPosition(apiX, apiY);
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
return {
id: n.fabric_node_id,
type: "neNode",
position,
width: TOPO_NODE_W,
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
handles: [
{ type: "target", position: "left" as Position, x: hx, y: hy },
{ type: "source", position: "right" as Position, x: hx, y: hy },
],
data: {
label: n.label || n.name || n.ip || n.fabric_node_id,
managed_ne_id: n.managed_ne_id || "",
ume_ne_id: n.ume_ne_id || "",
ne_ip: n.ip || "",
vendor: n.vendor || "",
connect_status: n.connect_status || "",
managed_source: n.managed_source || "",
level: n.level ?? null,
fabric_role: n.role || "",
kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"),
folder_id: n.folder_id || "",
view_id: n.view_id || "",
node_count: n.node_count || 0,
},
};
});
const rfEdges: Edge[] = edges.map((e) => {
const src = e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
const label = formatPortPairLabel(e.a_port || "", e.b_port || "", e.display_label || "");
const data: EdgeStyleData = {
source: src,
source_port: e.a_port || "",
target_port: e.b_port || "",
stroke_color: e.stroke_color || "",
stroke_width: Number(e.stroke_width || 0),
line_style: e.line_style || "",
discovered_at: e.discovered_at ?? null,
display_label: e.display_label || "",
};
return withEdgeVisual(
{
id: e.id,
source: e.a_node_id,
target: e.b_node_id,
type: "straight",
label: label || undefined,
animated: false,
data,
},
defaults,
);
});
return { rfNodes, rfEdges };
}
export function flowToPositions(nodes: Node<NeNodeData>[]) {
return nodes.map((n) => {
const isRegion = n.data.kind === "region" || String(n.id || "").startsWith("region:");
const pos = isRegion
? regionApiPosition(n.position.x, n.position.y)
: neApiPosition(n.position.x, n.position.y);
return {
fabric_node_id: n.id,
x: pos.x,
y: pos.y,
label: n.data.label || "",
};
});
}
export function graphFingerprint(graph: TopologyViewGraph): string {
const nodes = [...graph.nodes]
.map(
(n) =>
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}:${n.connect_status || ""}:${n.level ?? ""}:${n.role || ""}`,
)
.sort()
.join("|");
const edges = [...graph.edges]
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
.sort()
.join("|");
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`;
}
/** Overlay / inventory fields safe to refresh while the canvas has unsaved geometry edits. */
export function overlayFingerprint(graph: TopologyViewGraph): string {
return [...graph.nodes]
.map(
(n) =>
`${n.fabric_node_id}:${n.connect_status || ""}:${n.level ?? ""}:${n.role || ""}:${n.ume_ne_id || ""}:${n.managed_ne_id || ""}:${n.ip || ""}:${n.vendor || ""}:${n.managed_source || ""}`,
)
.sort()
.join("|");
}
/**
* Patch live overlay fields onto existing RF nodes without touching positions/selection.
* Used when dirty lock blocks a full graph apply.
*/
export function mergeOverlayIntoNodes(
current: Node<NeNodeData>[],
graph: TopologyViewGraph,
): { nodes: Node<NeNodeData>[]; changed: boolean } {
const byId = new Map(graph.nodes.map((n) => [n.fabric_node_id, n]));
let changed = false;
const nodes = current.map((node) => {
const g = byId.get(node.id);
if (!g || node.data.kind === "region") return node;
const next = {
connect_status: g.connect_status || "",
level: g.level ?? null,
fabric_role: g.role || "",
ume_ne_id: g.ume_ne_id || "",
managed_ne_id: g.managed_ne_id || "",
ne_ip: g.ip || "",
vendor: g.vendor || "",
managed_source: g.managed_source || "",
};
if (
node.data.connect_status === next.connect_status &&
(node.data.level ?? null) === next.level &&
(node.data.fabric_role || "") === next.fabric_role &&
(node.data.ume_ne_id || "") === next.ume_ne_id &&
(node.data.managed_ne_id || "") === next.managed_ne_id &&
(node.data.ne_ip || "") === next.ne_ip &&
(node.data.vendor || "") === next.vendor &&
(node.data.managed_source || "") === next.managed_source
) {
return node;
}
changed = true;
return { ...node, data: { ...node.data, ...next } };
});
return { nodes, changed };
}
export function applyViewGraph(
graph: TopologyViewGraph,
defaults: EdgeDefaults,
setNodes: (ns: Node<NeNodeData>[]) => void,
setEdges: (es: Edge[]) => void,
localPositions?: Map<string, { x: number; y: number }>,
) {
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
const merged = localPositions?.size
? rfNodes.map((n) => {
const p = localPositions.get(n.id);
return p ? { ...n, position: { ...p } } : n;
})
: rfNodes;
setNodes(merged);
setEdges(rfEdges);
return { rfNodes: merged, rfEdges };
}

View file

@ -0,0 +1,23 @@
import type { TopologyDiscoverNeResult } from "../../../types";
export function newId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
}
export function newLocalEdgeId(): string {
return `local:${newId()}`;
}
export function isLocalPendingEdgeId(id: string): boolean {
return String(id || "").startsWith("local:");
}
export function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
if (!r.ok) return "fail";
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
if (r.parser_stub || unmatchedCount > 0) return "warn";
return "ok";
}

View file

@ -0,0 +1,248 @@
import type { Edge, Node } from "@xyflow/react";
export type LayoutKind = "hierarchical-tb" | "hierarchical-lr" | "force" | "grid" | "radial";
const NODE_W = 140;
const NODE_H = 72;
type XY = { x: number; y: number };
type DagreNS = typeof import("dagre");
let dagreMod: DagreNS | null = null;
async function loadDagre(): Promise<DagreNS> {
if (!dagreMod) {
const mod = await import("dagre");
// CJS typings (`export =`) may surface as default or namespace under Vite.
dagreMod = ((mod as { default?: DagreNS }).default ?? mod) as DagreNS;
}
return dagreMod;
}
function applyPositions<T extends Record<string, unknown>>(
nodes: Node<T>[],
positions: Map<string, XY>,
): Node<T>[] {
return nodes.map((n) => {
const p = positions.get(n.id);
if (!p) return n;
return { ...n, position: { x: p.x, y: p.y } };
});
}
async function layoutDagre<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
rankdir: "TB" | "LR",
): Promise<Node<T>[]> {
const dagre = await loadDagre();
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir, nodesep: 48, ranksep: 72, marginx: 24, marginy: 24 });
for (const n of nodes) {
g.setNode(n.id, { width: NODE_W, height: NODE_H });
}
for (const e of edges) {
if (g.hasNode(e.source) && g.hasNode(e.target)) {
g.setEdge(e.source, e.target);
}
}
dagre.layout(g);
const positions = new Map<string, XY>();
for (const n of nodes) {
const dn = g.node(n.id);
if (!dn) continue;
positions.set(n.id, { x: dn.x - NODE_W / 2, y: dn.y - NODE_H / 2 });
}
return applyPositions(nodes, positions);
}
/** Lightweight spring layout (no d3 dependency). */
function layoutForce<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
iterations = 120,
): Node<T>[] {
if (nodes.length === 0) return nodes;
const pos = new Map<string, XY>();
const vel = new Map<string, XY>();
const cx = 400;
const cy = 300;
nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length;
const r = 80 + nodes.length * 4;
pos.set(n.id, {
x: n.position?.x || cx + Math.cos(angle) * r,
y: n.position?.y || cy + Math.sin(angle) * r,
});
vel.set(n.id, { x: 0, y: 0 });
});
const ids = nodes.map((n) => n.id);
const links = edges
.filter((e) => pos.has(e.source) && pos.has(e.target))
.map((e) => ({ s: e.source, t: e.target }));
const repulsion = 2800;
const attraction = 0.04;
const ideal = 160;
const damping = 0.85;
for (let iter = 0; iter < iterations; iter++) {
for (const id of ids) {
const p = pos.get(id)!;
let fx = 0;
let fy = 0;
for (const other of ids) {
if (other === id) continue;
const q = pos.get(other)!;
let dx = p.x - q.x;
let dy = p.y - q.y;
let dist2 = dx * dx + dy * dy;
if (dist2 < 1) {
dx = Math.random() - 0.5;
dy = Math.random() - 0.5;
dist2 = dx * dx + dy * dy;
}
const dist = Math.sqrt(dist2);
const force = repulsion / dist2;
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
for (const link of links) {
if (link.s !== id && link.t !== id) continue;
const other = link.s === id ? link.t : link.s;
const q = pos.get(other)!;
const dx = q.x - p.x;
const dy = q.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - ideal) * attraction;
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
const v = vel.get(id)!;
v.x = (v.x + fx) * damping;
v.y = (v.y + fy) * damping;
p.x += v.x;
p.y += v.y;
}
}
return applyPositions(nodes, pos);
}
function layoutGrid<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
const cols = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
const gapX = NODE_W + 40;
const gapY = NODE_H + 48;
return nodes.map((n, i) => ({
...n,
position: {
x: (i % cols) * gapX + 40,
y: Math.floor(i / cols) * gapY + 40,
},
}));
}
function layoutRadial<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
if (nodes.length === 0) return nodes;
if (nodes.length === 1) {
return [{ ...nodes[0], position: { x: 320, y: 240 } }];
}
const cx = 400;
const cy = 300;
const r = 60 + nodes.length * 18;
return nodes.map((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
return {
...n,
position: {
x: cx + Math.cos(angle) * r - NODE_W / 2,
y: cy + Math.sin(angle) * r - NODE_H / 2,
},
};
});
}
/** dagre is loaded on first hierarchical layout only — not with the topology page chunk. */
export async function layoutGraph<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
kind: LayoutKind,
opts?: { onlyIds?: Set<string> },
): Promise<Node<T>[]> {
const only = opts?.onlyIds;
const targets = only && only.size > 0 ? nodes.filter((n) => only.has(n.id)) : nodes;
const targetIds = new Set(targets.map((n) => n.id));
const subEdges = edges.filter((e) => targetIds.has(e.source) && targetIds.has(e.target));
let laid: Node<T>[];
switch (kind) {
case "hierarchical-lr":
laid = await layoutDagre(targets, subEdges, "LR");
break;
case "force":
laid = layoutForce(targets, subEdges);
break;
case "grid":
laid = layoutGrid(targets);
break;
case "radial":
laid = layoutRadial(targets);
break;
case "hierarchical-tb":
default:
laid = await layoutDagre(targets, subEdges, "TB");
break;
}
if (!only || only.size === 0) return laid;
const byId = new Map(laid.map((n) => [n.id, n]));
return nodes.map((n) => byId.get(n.id) || n);
}
export function alignNodes<T extends Record<string, unknown>>(
nodes: Node<T>[],
ids: string[],
kind: "left" | "right" | "top" | "bottom" | "h-center" | "v-center" | "h-distribute" | "v-distribute",
): Node<T>[] {
const selected = nodes.filter((n) => ids.includes(n.id));
if (selected.length < 2) return nodes;
const xs = selected.map((n) => n.position.x);
const ys = selected.map((n) => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const pos = new Map<string, XY>();
if (kind === "h-distribute") {
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
const span = maxX - minX;
sorted.forEach((n, i) => {
const x = sorted.length === 1 ? minX : minX + (span * i) / (sorted.length - 1);
pos.set(n.id, { x, y: n.position.y });
});
} else if (kind === "v-distribute") {
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
const span = maxY - minY;
sorted.forEach((n, i) => {
const y = sorted.length === 1 ? minY : minY + (span * i) / (sorted.length - 1);
pos.set(n.id, { x: n.position.x, y });
});
} else {
for (const n of selected) {
let x = n.position.x;
let y = n.position.y;
if (kind === "left") x = minX;
if (kind === "right") x = maxX;
if (kind === "top") y = minY;
if (kind === "bottom") y = maxY;
if (kind === "h-center") x = midX;
if (kind === "v-center") y = midY;
pos.set(n.id, { x, y });
}
}
return applyPositions(nodes, pos);
}

View file

@ -0,0 +1,21 @@
/** Map fabric level/role to a short CSS tone key for canvas chrome. */
export type LevelToneKey = "external" | "core" | "aggregation" | "access" | "edge" | "";
export function levelToneKey(level: number | null | undefined, role?: string | null): LevelToneKey {
const r = String(role || "").trim().toLowerCase();
if (r === "external" || r === "core" || r === "access" || r === "edge") return r;
if (r === "aggregation" || r === "aggregate" || r === "agg") return "aggregation";
if (level == null || Number.isNaN(Number(level))) return "";
const maj = Math.floor(Number(level));
if (maj <= 0) return "external";
if (maj === 1) return "core";
if (maj === 2) return "aggregation";
if (maj === 3) return "access";
return "edge";
}
export function levelLabelKey(tone: LevelToneKey): string {
if (!tone) return "topology.levelUnmatched";
return `topology.level.${tone}`;
}

View file

@ -0,0 +1,214 @@
import type { Edge } from "@xyflow/react";
export type LinkMember = {
id: string;
a_port: string;
b_port: string;
source: string;
display_label?: string;
};
export type LinkEdgeData = {
source?: string;
source_port?: string;
target_port?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
discovered_at?: string | null;
/** UME userLabel when ports are unavailable (no EQ+PTP). */
display_label?: string;
/** Logical bundle of parallel physical links between the same NE pair. */
aggregated?: boolean;
member_count?: number;
members?: LinkMember[];
parallelIndex?: number;
parallelCount?: number;
};
export function pairKey(a: string, b: string): string {
return a < b ? `${a}::${b}` : `${b}::${a}`;
}
export function isAggregateEdgeId(id: string): boolean {
return String(id || "").startsWith("agg:");
}
export function aggregateIdForPair(a: string, b: string): string {
return `agg:${pairKey(a, b)}`;
}
function isLabelPlaceholderPort(port: string): boolean {
return String(port || "")
.trim()
.toLowerCase()
.startsWith("label:");
}
/** Clear A↔B port label (not ambiguous "local/remote"). */
export function formatPortPairLabel(aPort: string, bPort: string, displayLabel?: string): string {
const shown = String(displayLabel || "").trim();
if (shown) return shown;
const a = String(aPort || "").trim();
const b = String(bPort || "").trim();
if (isLabelPlaceholderPort(a) || isLabelPlaceholderPort(b)) return "";
if (a && b) return `${a} ↔ ${b}`;
return a || b || "";
}
export function formatBundleLabel(
count: number,
sample?: { a_port?: string; b_port?: string; display_label?: string },
opts?: { hidePorts?: boolean },
): string {
if (count <= 1) {
return opts?.hidePorts
? ""
: formatPortPairLabel(sample?.a_port || "", sample?.b_port || "", sample?.display_label);
}
if (opts?.hidePorts) return `×${count}`;
const sampleLabel = formatPortPairLabel(
sample?.a_port || "",
sample?.b_port || "",
sample?.display_label,
);
return sampleLabel ? `×${count} · ${sampleLabel}` : `×${count}`;
}
function memberFrom(edge: Edge): LinkMember {
const d = (edge.data || {}) as LinkEdgeData;
return {
id: edge.id,
a_port: String(d.source_port || "").trim(),
b_port: String(d.target_port || "").trim(),
source: String(d.source || "manual"),
display_label: String(d.display_label || "").trim(),
};
}
function statusRank(source: string): number {
if (source === "stale") return 2;
if (source === "manual") return 1;
return 0; // discovered / lldp preferred
}
/** Pick representative physical edge for an aggregate (prefer live discovered). */
function pickPrimary(list: Edge[]): Edge {
return [...list].sort((a, b) => {
const da = (a.data || {}) as LinkEdgeData;
const db = (b.data || {}) as LinkEdgeData;
return statusRank(String(da.source || "")) - statusRank(String(db.source || ""));
})[0];
}
/**
* Default: one logical edge per NE pair (bundle parallel links).
* Expanded: all physical edges, offset when count > 1.
*/
export function buildLinkDisplayEdges(
edges: Edge[],
expandPhysical: boolean,
hidePorts = false,
scaleBundleWidth = false,
): Edge[] {
const groups = new Map<string, Edge[]>();
for (const e of edges) {
const k = pairKey(e.source, e.target);
const list = groups.get(k);
if (list) list.push(e);
else groups.set(k, [e]);
}
const out: Edge[] = [];
for (const [, list] of groups) {
if (expandPhysical) {
const count = list.length;
list.forEach((e, i) => {
const prev = (e.data || {}) as LinkEdgeData;
const data: LinkEdgeData = {
...prev,
aggregated: false,
member_count: count,
members: list.map(memberFrom),
parallelIndex: i,
parallelCount: count,
};
const portLabel = formatPortPairLabel(
data.source_port || "",
data.target_port || "",
data.display_label,
);
out.push({
...e,
type: count > 1 ? "topoParallel" : e.type || "straight",
// Never fall back to a stale e.label — it may still contain ports.
label: hidePorts ? undefined : portLabel || undefined,
data,
});
});
continue;
}
if (list.length === 1) {
const e = list[0];
const prev = (e.data || {}) as LinkEdgeData;
const data: LinkEdgeData = {
...prev,
aggregated: false,
member_count: 1,
members: [memberFrom(e)],
};
const portLabel = formatPortPairLabel(
data.source_port || "",
data.target_port || "",
data.display_label,
);
out.push({
...e,
type: "straight",
label: hidePorts ? undefined : portLabel || undefined,
data,
});
continue;
}
const primary = pickPrimary(list);
const prev = (primary.data || {}) as LinkEdgeData;
const members = list.map(memberFrom);
const anyStale = members.some((m) => m.source === "stale");
const data: LinkEdgeData = {
...prev,
source: anyStale ? "stale" : prev.source,
aggregated: true,
member_count: list.length,
members,
};
// Optional: thicken logical bundles by member count (default keeps single-line width).
if (scaleBundleWidth) {
data.stroke_width = Math.max(Number(prev.stroke_width || 0), Math.min(8, 2 + list.length));
}
out.push({
...primary,
id: aggregateIdForPair(primary.source, primary.target),
type: "straight",
label: formatBundleLabel(
list.length,
{ a_port: data.source_port, b_port: data.target_port },
{ hidePorts },
) || undefined,
data,
});
}
return out;
}
export function physicalIdsForDisplayEdge(edge: Edge | null | undefined, allEdges: Edge[]): string[] {
if (!edge) return [];
const d = (edge.data || {}) as LinkEdgeData;
if (d.members?.length) return d.members.map((m) => m.id);
if (isAggregateEdgeId(edge.id)) {
const k = edge.id.slice(4);
return allEdges.filter((e) => pairKey(e.source, e.target) === k).map((e) => e.id);
}
return [edge.id];
}

View file

@ -0,0 +1,29 @@
import type { Edge, Node } from "@xyflow/react";
import type { NeNodeData } from "../components/TopologyReactFlowView";
export type HistorySnap = {
nodes: Node<NeNodeData>[];
edges: Edge[];
pendingEdgeDeletes: string[];
pendingEdgeCreates: string[];
};
export type PaletteSource = "managed" | "ume";
export type PaletteItem = {
key: string;
source: PaletteSource;
managed_ne_id: string;
ume_ne_id: string;
name: string;
ip: string;
vendor: string;
meta: string;
connect_status: string;
};
export type CtxMenu =
| { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number }
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };

View file

@ -0,0 +1,40 @@
import type { Node } from "@xyflow/react";
import type { NeNodeData } from "../components/TopologyReactFlowView";
export function normalizeSearchText(s: string): string {
return String(s || "")
.toLowerCase()
.replace(/[\s_\-./]+/g, "");
}
/** Case-insensitive substring + loose subsequence (e.g. r1 → router-1). */
export function fuzzyIncludes(haystack: string, needle: string): boolean {
const h = normalizeSearchText(haystack);
const n = normalizeSearchText(needle);
if (!n) return true;
if (!h) return false;
if (h.includes(n)) return true;
let i = 0;
for (const ch of h) {
if (ch === n[i]) i += 1;
if (i >= n.length) return true;
}
return false;
}
export function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
const tokens = String(query || "")
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (!tokens.length) return false;
const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id];
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
}
export function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
const src = String(source || "").trim().toLowerCase();
if (src === "lldp" || src === "topology") return true;
return !String(neIp || "").trim() && Boolean(src);
}

View file

@ -0,0 +1,35 @@
import {
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_KEY,
SIDEBAR_WIDTH_MAX,
SIDEBAR_WIDTH_MIN,
} from "./constants";
export function loadSidebarWidth(): number {
try {
const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY));
if (Number.isFinite(n) && n >= SIDEBAR_WIDTH_MIN) {
return Math.min(Math.round(n), SIDEBAR_WIDTH_MAX);
}
} catch {
/* ignore */
}
return SIDEBAR_WIDTH_DEFAULT;
}
export function saveSidebarWidth(width: number): void {
try {
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width)));
} catch {
/* ignore */
}
}
export function clampSidebarWidth(width: number, pageWidth?: number): number {
const maxByPage =
pageWidth && pageWidth > 0
? Math.max(SIDEBAR_WIDTH_MIN, Math.floor(pageWidth * 0.55))
: SIDEBAR_WIDTH_MAX;
const maxW = Math.min(SIDEBAR_WIDTH_MAX, maxByPage);
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(maxW, Math.round(width)));
}

View file

@ -0,0 +1,54 @@
/** Topology editor interaction modes (NMS-style). */
export type ToolMode = "select" | "pan" | "connect";
export type ToolModeBehavior = {
nodesDraggable: boolean;
nodesConnectable: boolean;
elementsSelectable: boolean;
/** false = no pane pan on drag; true = left-drag pans; array = button indices */
panOnDrag: boolean | number[];
selectionOnDrag: boolean;
panOnScroll: boolean;
};
export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
switch (mode) {
case "pan":
return {
nodesDraggable: false,
nodesConnectable: false,
elementsSelectable: false,
panOnDrag: true,
selectionOnDrag: false,
panOnScroll: true,
};
case "connect":
return {
nodesDraggable: false,
nodesConnectable: true,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: false,
panOnScroll: true,
};
case "select":
default:
return {
nodesDraggable: true,
nodesConnectable: false,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: true,
panOnScroll: true,
};
}
}
export function toolModeFromKey(key: string): ToolMode | null {
const k = key.toLowerCase();
if (k === "v") return "select";
if (k === "h") return "pan";
if (k === "c") return "connect";
return null;
}

View file

@ -0,0 +1,49 @@
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
export const TOPO_NODE_W = 88;
export const TOPO_NODE_H = 28;
export const TOPO_ICON = 28;
/** region-building.png is 195×133 — keep aspect so icon center == handle. */
export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195);
export const TOPO_HANDLE_X = TOPO_NODE_W / 2;
export const TOPO_HANDLE_Y = TOPO_ICON / 2;
export const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2;
export const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2;
/** API x/y is icon center; RF position is top-left of the node box. */
export function iconFlowPosition(
apiX: number,
apiY: number,
handleX: number,
handleY: number,
): { x: number; y: number } {
return { x: apiX - handleX, y: apiY - handleY };
}
export function iconApiPosition(
flowX: number,
flowY: number,
handleX: number,
handleY: number,
): { x: number; y: number } {
return { x: flowX + handleX, y: flowY + handleY };
}
export function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
}
export function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } {
return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
}
export function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
}
export function neApiPosition(flowX: number, flowY: number): { x: number; y: number } {
return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
}
export function isRegionFabricId(id: string): boolean {
return String(id || "").startsWith("region:");
}

View file

@ -0,0 +1,227 @@
import type { CSSProperties } from "react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyWorldTransform } from "../../../types";
import { WORLD_MAP_ENABLED } from "./constants";
export { WORLD_MAP_ENABLED };
export function findViewInRegion(
regions: TopologyTreeFolderItem[],
viewId: string,
): { region: TopologyTreeFolderItem; view: TopologyTreeViewItem } | null {
for (const region of regions) {
const view = (region.views || []).find((v) => v.id === viewId);
if (view) return { region, view };
const nested = findViewInRegion(region.children || [], viewId);
if (nested) return nested;
}
return null;
}
export function findFolderInTree(
folders: TopologyTreeFolderItem[],
folderId: string,
): TopologyTreeFolderItem | null {
for (const folder of folders) {
if (folder.id === folderId) return folder;
const nested = findFolderInTree(folder.children || [], folderId);
if (nested) return nested;
}
return null;
}
export function indexFoldersById(
folders: TopologyTreeFolderItem[],
acc: Map<string, TopologyTreeFolderItem> = new Map(),
): Map<string, TopologyTreeFolderItem> {
for (const folder of folders) {
acc.set(folder.id, folder);
indexFoldersById(folder.children || [], acc);
}
return acc;
}
export function folderPathIds(folders: TopologyTreeFolderItem[], folderId: string): string[] {
const byId = indexFoldersById(folders);
const path: string[] = [];
let cur: string | undefined = folderId;
const seen = new Set<string>();
while (cur && !seen.has(cur)) {
seen.add(cur);
path.push(cur);
const folder = byId.get(cur);
const parent = String(folder?.parent_id || "").trim();
if (!folder || !parent || !byId.has(parent)) break;
cur = parent;
}
return path;
}
export function folderPathFolders(
folders: TopologyTreeFolderItem[],
folderId: string,
): TopologyTreeFolderItem[] {
if (!folderId) return [];
const byId = indexFoldersById(folders);
return folderPathIds(folders, folderId)
.map((id) => byId.get(id))
.filter((f): f is TopologyTreeFolderItem => Boolean(f))
.reverse();
}
export function isUmeWorldContainer(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder) return false;
const ext = String(folder.external_ref || "").trim();
return ext === "ume:world" || folder.name === "UME World";
}
export function isUmeWorldNavFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder) return false;
const ext = String(folder.external_ref || "").trim();
if (isUmeWorldContainer(folder) || ext === "ume:world:drill") return true;
return Boolean(ext);
}
export function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder) return false;
const ext = String(folder.external_ref || "").trim();
return ext === "ume:world:drill" || (folder.name === "World" && Boolean(folder.is_system));
}
export function isWorldFlatViewName(name: string | undefined | null): boolean {
const n = String(name || "").trim();
return n === "世界地图" || n === "完整世界地图" || n === "World map";
}
export function isManualRootMapName(name: string | undefined | null): boolean {
const n = String(name || "").trim();
return n === "根图" || n === "Root map";
}
export function displayViewName(name: string | undefined | null, t: (key: string) => string): string {
if (isWorldFlatViewName(name)) return t("topology.worldMapName");
if (isManualRootMapName(name)) return t("topology.rootMapName");
return String(name || "").trim();
}
export function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder) return false;
if (isUmeWorldContainer(folder) || isWorldDrillFolder(folder)) return true;
const ext = String(folder.external_ref || "").trim();
return ext === "ume:world" || ext === "ume:world:drill";
}
export function isManualRootMapFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder || isUmeStructuralFolder(folder)) return false;
return Boolean(folder.is_system) && !String(folder.external_ref || "").trim();
}
export function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean {
if (!folder || isUmeStructuralFolder(folder)) return false;
return Boolean(String(folder.external_ref || "").trim());
}
export function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" {
if (zoom < 0.04) return "dot";
if (zoom < 0.12) return "pin";
return "full";
}
export function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
x: number;
y: number;
width: number;
height: number;
} | null {
if (!wt) return null;
const scale = Number(wt.scale) || 1;
const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale);
const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return { x: 0, y: 0, width, height };
}
export function isRegionCanvasFolder(
folder: TopologyTreeFolderItem | null | undefined,
rootId?: string,
): boolean {
if (!folder) return false;
if (isUmeWorldContainer(folder)) return false;
const rid = String(rootId || "").trim();
const parent = String(folder.parent_id || "").trim();
if (rid && parent === rid) return false;
return true;
}
export function regionDisplayName(
region: TopologyTreeFolderItem | null | undefined,
t?: (key: string) => string,
): string {
if (!region) return "";
const raw = String(region.name || "").trim();
if (t && isManualRootMapName(raw)) return t("topology.rootMapName");
return raw;
}
export function folderNeCount(folder: TopologyTreeFolderItem | null | undefined): number {
if (!folder) return 0;
return Math.max(0, Math.floor(Number(folder.ne_count) || 0));
}
export function formatNeCount(count: number): string {
const n = Math.max(0, Math.floor(Number(count) || 0));
return `${n}N`;
}
export function canvasBgLuminance(bg: string): number {
const hex = String(bg || "").replace("#", "").trim();
if (hex.length !== 6) return 0.1;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (![r, g, b].every((n) => Number.isFinite(n))) return 0.1;
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
export function canvasFloatChromeVars(bg: string): CSSProperties {
const light = canvasBgLuminance(bg) > 0.55;
if (light) {
return {
"--topo-float-bg": "rgba(255, 255, 255, 0.92)",
"--topo-float-border": "rgba(15, 23, 42, 0.08)",
"--topo-float-shadow": "0 8px 24px rgba(15, 23, 42, 0.12)",
"--topo-float-fg": "#0f172a",
"--topo-float-muted": "#64748b",
"--topo-float-btn-bg": "transparent",
"--topo-float-btn-border": "transparent",
"--topo-float-btn-hover": "rgba(15, 23, 42, 0.06)",
"--topo-float-active-bg": "rgba(37, 99, 235, 0.16)",
"--topo-float-active-fg": "#1d4ed8",
"--topo-float-active-border": "#3b82f6",
"--topo-float-control-bg": "transparent",
"--topo-float-control-hover": "rgba(15, 23, 42, 0.06)",
"--topo-float-control-fill": "#334155",
"--topo-float-input-bg": "#ffffff",
"--topo-float-input-fg": "#0f172a",
"--topo-float-input-border": "rgba(15, 23, 42, 0.16)",
} as CSSProperties;
}
return {
"--topo-float-bg": "rgba(15, 23, 42, 0.92)",
"--topo-float-border": "rgba(148, 163, 184, 0.18)",
"--topo-float-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)",
"--topo-float-fg": "#e2e8f0",
"--topo-float-muted": "#94a3b8",
"--topo-float-btn-bg": "transparent",
"--topo-float-btn-border": "transparent",
"--topo-float-btn-hover": "rgba(30, 41, 59, 0.9)",
"--topo-float-active-bg": "rgba(37, 99, 235, 0.28)",
"--topo-float-active-fg": "#93c5fd",
"--topo-float-active-border": "#3b82f6",
"--topo-float-control-bg": "transparent",
"--topo-float-control-hover": "rgba(30, 41, 59, 0.9)",
"--topo-float-control-fill": "#cbd5e1",
"--topo-float-input-bg": "#0b1220",
"--topo-float-input-fg": "#e2e8f0",
"--topo-float-input-border": "rgba(148, 163, 184, 0.28)",
} as CSSProperties;
}

View file

@ -0,0 +1,35 @@
import type { NeNodeData } from "../components/TopologyReactFlowView";
import { VENDOR_TONE_KEYS, type VendorColors, type VendorToneKey } from "./displayPrefs";
export type { VendorToneKey, VendorColors };
export function nodeIconTone(
vendor: string,
managedNeId: string,
umeNeId = "",
): VendorToneKey {
const hasManaged = Boolean(String(managedNeId || "").trim());
const hasUme = Boolean(String(umeNeId || "").trim());
if (!hasManaged && !hasUme) return "gray";
const v = String(vendor || "").trim().toLowerCase();
if (!v || v === "other" || v === "unknown" || v === "generic") {
return hasUme ? "zte" : "gray";
}
if (v.includes("cisco")) return "cisco";
if (v.includes("huawei")) return "huawei";
if (v.includes("zte")) return "zte";
if (v.includes("juniper")) return "juniper";
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
if (v.includes("ericsson")) return "ericsson";
if (v.includes("h3c") || v.includes("comware")) return "h3c";
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
if (v.includes("mikrotik")) return "mikrotik";
return hasUme ? "zte" : "gray";
}
export function vendorColorForNode(data: NeNodeData, colors: VendorColors): string {
if (data.kind === "region") return "#38bdf8";
return colors[nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id)] || colors.gray;
}
export { VENDOR_TONE_KEYS };

View file

@ -0,0 +1,73 @@
import type {
TopologyViewEdgeItem,
TopologyViewGraph,
TopologyViewNodeItem,
} from "../../../types";
import { WORLD_FLAT_ACCUM_CAP } from "./constants";
/** Merge newly fetched flat LOD into the existing cache so visited areas stay. */
export function mergeFlatWorldGraph(
prev: TopologyViewGraph | undefined,
next: TopologyViewGraph,
opts?: { centerX?: number; centerY?: number },
): TopologyViewGraph {
const scatter = next.scatter?.length ? next.scatter : prev?.scatter;
if (!prev?.nodes?.length) {
return scatter?.length && !next.scatter?.length ? { ...next, scatter } : next;
}
if (!next?.nodes?.length) {
return {
...prev,
view: next.view || prev.view,
nodes: [],
edges: [],
scatter: scatter || [],
world_transform: next.world_transform ?? prev.world_transform,
truncated: Boolean(next.truncated || prev.truncated),
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
};
}
const nodeMap = new Map<string, TopologyViewNodeItem>();
for (const n of prev.nodes) nodeMap.set(String(n.fabric_node_id), n);
for (const n of next.nodes) nodeMap.set(String(n.fabric_node_id), n);
const edgeMap = new Map<string, TopologyViewEdgeItem>();
for (const e of prev.edges || []) edgeMap.set(String(e.id), e);
for (const e of next.edges || []) edgeMap.set(String(e.id), e);
let nodes = [...nodeMap.values()];
const cx = opts?.centerX;
const cy = opts?.centerY;
if (nodes.length > WORLD_FLAT_ACCUM_CAP && cx != null && cy != null) {
const prefer = new Set(next.nodes.map((n) => String(n.fabric_node_id)));
const kept = next.nodes.slice();
const extras = nodes
.filter((n) => !prefer.has(String(n.fabric_node_id)))
.sort((a, b) => {
const da = (Number(a.x) - cx) ** 2 + (Number(a.y) - cy) ** 2;
const db = (Number(b.x) - cx) ** 2 + (Number(b.y) - cy) ** 2;
return da - db;
});
const room = Math.max(0, WORLD_FLAT_ACCUM_CAP - kept.length);
nodes = kept.concat(extras.slice(0, room));
}
const idSet = new Set(nodes.map((n) => String(n.fabric_node_id)));
const edges = [...edgeMap.values()].filter(
(e) => idSet.has(String(e.a_node_id)) && idSet.has(String(e.b_node_id)),
);
return {
...next,
view: next.view || prev.view,
nodes,
edges,
world_transform: next.world_transform ?? prev.world_transform,
scatter: scatter || [],
truncated: Boolean(prev.truncated || next.truncated),
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
};
}

View file

@ -0,0 +1,182 @@
import { Button, Input } from "@heroui/react";
import { useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { apiGet, apiPatch, apiPost } from "../../services/api";
type UserRow = {
id: string;
username: string;
role: string;
is_active: boolean;
created_at?: string | null;
};
export function UsersPage() {
const { t } = useI18n();
const { isAdmin, ready } = useAuth();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [resetPwd, setResetPwd] = useState<Record<string, string>>({});
const usersQuery = useQuery({
queryKey: ["appUsers"],
queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"),
enabled: ready && isAdmin,
});
const createMut = useMutation({
mutationFn: () => apiPost("/v1/users", { username, password, role }),
onSuccess: async () => {
setUsername("");
setPassword("");
setRole("user");
showOk(t("auth.userCreated"));
await qc.invalidateQueries({ queryKey: ["appUsers"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const patchMut = useMutation({
mutationFn: (payload: { id: string; body: Record<string, unknown> }) =>
apiPatch(`/v1/users/${encodeURIComponent(payload.id)}`, payload.body),
onSuccess: async () => {
showOk(t("auth.userUpdated"));
await qc.invalidateQueries({ queryKey: ["appUsers"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const items = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
if (ready && !isAdmin) return <Navigate to="/" replace />;
const onCreate = (e: FormEvent) => {
e.preventDefault();
createMut.mutate();
};
return (
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2>{t("auth.usersTitle")}</h2>
</div>
<div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}>
<Input
placeholder={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<Input
type="password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
<FieldSelect value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option>
</FieldSelect>
<Button type="submit" size="sm" variant="primary" isDisabled={createMut.isPending}>
{t("auth.addUser")}
</Button>
</form>
{usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !usersQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.username")}</th>
<th>{t("auth.role")}</th>
<th>{t("auth.status")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((u) => (
<tr key={u.id}>
<td className="pt-list-task-name">{u.username}</td>
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
<td>
<span
className={`pt-list-status ${
u.is_active ? "pt-list-status--ok" : "pt-list-status--other"
}`}
>
{u.is_active ? t("auth.active") : t("auth.disabled")}
</span>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button
size="sm"
variant="secondary"
onPress={() =>
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
}
>
{u.is_active ? t("auth.disable") : t("auth.enable")}
</Button>
<FieldSelect
value={u.role}
onChange={(e) =>
patchMut.mutate({ id: u.id, body: { role: e.target.value } })
}
>
<option value="user">{t("auth.roleUser")}</option>
<option value="admin">{t("auth.roleAdmin")}</option>
</FieldSelect>
<Input
type="password"
placeholder={t("auth.newPassword")}
value={resetPwd[u.id] || ""}
onChange={(e) =>
setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))
}
style={{ width: 140, minWidth: 140, flex: "0 0 auto" }}
/>
<Button
size="sm"
variant="primary"
isDisabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
onPress={() => {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });
setResetPwd((m) => ({ ...m, [u.id]: "" }));
}}
>
{t("auth.resetPassword")}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,189 @@
/** SecureCRT-like keyword highlighting for WebCRT stdout (ANSI inject). */
export type KeywordRule = {
id: string;
pattern: string;
regex: boolean;
};
export type KeywordHighlightConfig = {
enabled: boolean;
caseSensitive: boolean;
/** Foreground (character) highlight color (hex). */
color: string;
keywords: KeywordRule[];
};
export const KEYWORD_HL_STORAGE_KEY = "netx.webcrt.keywordHighlight";
const DEFAULT_COLOR = "#ffff00";
export function defaultKeywordHighlightConfig(): KeywordHighlightConfig {
return {
enabled: false,
caseSensitive: false,
color: DEFAULT_COLOR,
keywords: [],
};
}
export function newKeywordId(): string {
return `kw_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeHexColor(hex: string): string | null {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim());
return m ? `#${m[1].toLowerCase()}` : null;
}
export function loadKeywordHighlightConfig(): KeywordHighlightConfig {
const base = defaultKeywordHighlightConfig();
try {
const raw = localStorage.getItem(KEYWORD_HL_STORAGE_KEY);
if (!raw) return base;
const j = JSON.parse(raw) as Partial<KeywordHighlightConfig>;
const keywords = Array.isArray(j.keywords)
? j.keywords
.map((k) => ({
id: String((k as KeywordRule).id || newKeywordId()),
pattern: String((k as KeywordRule).pattern || ""),
regex: Boolean((k as KeywordRule).regex),
}))
.filter((k) => k.pattern.trim())
: [];
return {
// Keywords imply active highlighting (avoids stale enabled:false in localStorage).
enabled: keywords.length > 0,
caseSensitive: Boolean(j.caseSensitive),
color: normalizeHexColor(String(j.color || base.color)) || DEFAULT_COLOR,
keywords,
};
} catch {
return base;
}
}
export function saveKeywordHighlightConfig(cfg: KeywordHighlightConfig): void {
const keywords = (cfg.keywords || []).filter((k) => String(k.pattern || "").trim());
localStorage.setItem(
KEYWORD_HL_STORAGE_KEY,
JSON.stringify({
...cfg,
enabled: keywords.length > 0 && cfg.enabled !== false,
keywords,
}),
);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const n = normalizeHexColor(hex) || DEFAULT_COLOR;
const v = parseInt(n.slice(1), 16);
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
/** Map highlight color to bold foreground (character color), not background. */
function sgrForColor(hex: string): { open: string; close: string } {
const { r, g, b } = hexToRgb(hex);
// 16-color bright fg fallback + truecolor fg for xterm.
let brightFg = 93; // bright yellow
if (g >= r && g >= b && g > 80) brightFg = 92; // bright green
else if (r >= g && r >= b && r > 80 && g < 100) brightFg = 91; // bright red
else if (b >= r && b >= g && b > 80) brightFg = 94; // bright blue
else if (r > 180 && g > 180 && b < 120) brightFg = 93; // yellow
const open = `\x1b[1;${brightFg}m\x1b[38;2;${r};${g};${b}m`;
const close = "\x1b[22;39m";
return { open, close };
}
type CompiledRule = {
re: RegExp;
open: string;
close: string;
};
export function compileKeywordRules(config: KeywordHighlightConfig | null | undefined): CompiledRule[] {
if (!config?.keywords?.length) return [];
if (!config.enabled) return [];
const { open, close } = sgrForColor(config.color || DEFAULT_COLOR);
const flags = config.caseSensitive ? "g" : "gi";
const out: CompiledRule[] = [];
for (const kw of config.keywords) {
const pattern = String(kw.pattern || "").trim();
if (!pattern) continue;
try {
const source = kw.regex ? pattern : escapeRegExp(pattern);
out.push({ re: new RegExp(source, flags), open, close });
} catch {
/* skip invalid regex */
}
}
return out;
}
/** CSI / OSC / simple charset sequences — leave untouched. */
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2AB]|[>=])/g;
function highlightPlain(text: string, rules: CompiledRule[]): string {
if (!text || !rules.length) return text;
type Match = { start: number; end: number; open: string; close: string };
const matches: Match[] = [];
for (const rule of rules) {
rule.re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = rule.re.exec(text)) !== null) {
if (!m[0]) {
rule.re.lastIndex += 1;
continue;
}
matches.push({
start: m.index,
end: m.index + m[0].length,
open: rule.open,
close: rule.close,
});
if (!rule.re.global) break;
}
}
if (!matches.length) return text;
matches.sort((a, b) => a.start - b.start || b.end - a.end);
const picked: Match[] = [];
let cursor = 0;
for (const m of matches) {
if (m.start < cursor) continue;
picked.push(m);
cursor = m.end;
}
let out = "";
let i = 0;
for (const m of picked) {
out += text.slice(i, m.start) + m.open + text.slice(m.start, m.end) + m.close;
i = m.end;
}
out += text.slice(i);
return out;
}
export function applyKeywordHighlight(
text: string,
config: KeywordHighlightConfig | null | undefined,
): string {
if (!text) return text;
const rules = compileKeywordRules(config);
if (!rules.length) return text;
let out = "";
let last = 0;
ANSI_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ANSI_RE.exec(text)) !== null) {
if (m.index > last) out += highlightPlain(text.slice(last, m.index), rules);
out += m[0];
last = m.index + m[0].length;
}
if (last < text.length) out += highlightPlain(text.slice(last), rules);
return out;
}

View file

@ -0,0 +1,952 @@
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 { 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 { 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 "—";
if (Math.abs(n) >= 10000) {
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
return String(n);
}
function formatAgeSec(sec: number | null | undefined): string {
if (sec == null || !Number.isFinite(sec)) return "—";
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`;
}
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 FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = {
ok: "#22c55e",
mid: "#38bdf8",
warn: "#f59e0b",
bad: "#ef4444",
muted: "#64748b",
};
const REGION_PALETTE = [
"#38bdf8",
"#22d3ee",
"#818cf8",
"#34d399",
"#fbbf24",
"#fb7185",
"#a78bfa",
"#2dd4bf",
];
type FleetSlice = {
key: string;
n: number;
label?: string;
tone?: "ok" | "mid" | "warn" | "bad" | "muted";
color?: string;
};
function sliceColor(p: FleetSlice): string {
if (p.color) return p.color;
if (p.tone) return FLEET_TONE_COLOR[p.tone];
return FLEET_TONE_COLOR.muted;
}
function buildDonutGradient(parts: FleetSlice[]): string {
const raw = parts.map((p) => Math.max(0, p.n));
const total = raw.reduce((s, n) => s + n, 0);
if (total <= 0) {
return `conic-gradient(${FLEET_TONE_COLOR.muted} 0deg 360deg)`;
}
// Tiny non-zero slices get a minimum arc so they stay glanceable on skewed fleets.
const MIN_SHARE = 0.022;
const shares = raw.map((n) => {
if (n <= 0) return 0;
return Math.max(n / total, MIN_SHARE);
});
const shareSum = shares.reduce((s, n) => s + n, 0);
let cursor = 0;
const stops: string[] = [];
parts.forEach((p, i) => {
const share = shares[i] / shareSum;
if (share <= 0) return;
const start = cursor * 360;
cursor += share;
const end = cursor * 360;
stops.push(`${sliceColor(p)} ${start.toFixed(2)}deg ${end.toFixed(2)}deg`);
});
return `conic-gradient(${stops.join(", ")})`;
}
function FleetDonut({
parts,
center,
unit,
}: {
parts: FleetSlice[];
center: ReactNode;
unit?: ReactNode;
}) {
return (
<div
className="wb-fleet__donut"
style={{ background: buildDonutGradient(parts) }}
aria-hidden="true"
>
<div className="wb-fleet__donut-hole">
<div className="wb-fleet__donut-value">{center}</div>
{unit ? <div className="wb-fleet__donut-unit">{unit}</div> : null}
</div>
</div>
);
}
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;
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) => {
if (String(c.kind) !== "region") return false;
if (isUmeWorldContainer(c) || isWorldDrillFolder(c)) return false;
return true;
});
}
function clampPct(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, Math.round(n)));
}
function ratioPct(used: number, limit: number): number {
if (limit <= 0) return 0;
return clampPct((used / limit) * 100);
}
function toneClass(pct: number, muted?: boolean): string {
if (muted) return "is-muted";
if (pct >= 75) return "is-high";
if (pct >= 40) return "is-mid";
return "is-ok";
}
function formatBytes(n: number): string {
const v = Math.max(0, Number(n) || 0);
const gib = 1024 ** 3;
const mib = 1024 ** 2;
if (v >= gib) {
const g = v / gib;
return `${g.toFixed(g >= 10 ? 0 : 1)} GiB`;
}
return `${(v / mib).toFixed(0)} MiB`;
}
function formatBytesCompact(n: number): string {
const v = Math.max(0, Number(n) || 0);
const gib = 1024 ** 3;
const mib = 1024 ** 2;
if (v >= gib) return `${(v / gib).toFixed(v >= 10 * gib ? 0 : 1)}G`;
if (v >= mib) return `${(v / mib).toFixed(0)}M`;
return `${Math.max(1, Math.round(v / 1024))}K`;
}
function isActiveStatus(status: string): boolean {
const s = status.toLowerCase();
return (
s === "collecting" ||
s === "running" ||
s === "connecting" ||
s === "testing" ||
s === "paused" ||
s === "pending" ||
s === "detached"
);
}
function kindLabel(kind: string, t: (k: string) => string): string {
const key = `audit.tasks.kind.${kind}`;
const tr = t(key);
return tr === key ? kind : tr;
}
function statusLabel(status: string, t: (k: string) => string): string {
const key = `audit.tasks.status.${status}`;
const tr = t(key);
return tr === key ? status : tr;
}
function statusTone(status: string): "running" | "ok" | "paused" | "stopped" | "other" {
const s = status.toLowerCase();
if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running";
if (s === "ready" || s === "idle" || s === "succeeded" || s === "success") return "ok";
if (s === "paused" || s === "pending" || s === "detached") return "paused";
if (s === "failed" || s === "error" || s === "stopped") return "stopped";
return "other";
}
function taskTitle(row: OpsTaskItem, t: (k: string) => string): string {
const kind = kindLabel(row.kind, t);
const subject = String(row.title || row.id || "").trim();
if (!subject) return kind;
if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) {
return subject;
}
return `${kind} · ${subject}`;
}
function openTaskHref(href: string) {
const path = String(href || "").trim() || "/audit/tasks";
if (path.startsWith("/ume")) {
void openOrFocusModule({ moduleId: "ume", path });
return;
}
if (
path.startsWith("/network") ||
path.startsWith("/lldp") ||
path.startsWith("/config-sync") ||
path.startsWith("/port-traffic")
) {
void openOrFocusModule({ moduleId: "network", path });
return;
}
if (path.startsWith("/ne")) {
void openOrFocusModule({ moduleId: "ne", path });
return;
}
if (path.startsWith("/webcrt")) {
void openOrFocusModule({ moduleId: "webcrt", path });
return;
}
void openOrFocusModule({
moduleId: "audit",
path: path.startsWith("/audit") ? path : "/audit/tasks",
});
}
function StatLine({
label,
value,
pct,
muted,
}: {
label: string;
value: string;
pct?: number;
muted?: boolean;
}) {
return (
<div className={`wb-stat ${toneClass(pct ?? 0, muted)}`}>
<span className="wb-stat__label">{label}</span>
<span className="wb-stat__value">{value}</span>
{pct == null ? null : (
<span className="wb-stat__track" aria-hidden="true">
<span className="wb-stat__fill" style={{ width: `${clampPct(pct)}%` }} />
</span>
)}
</div>
);
}
export function WorkbenchPage() {
const { t } = useI18n();
const { isAdmin, hasScope } = useAuth();
const metricsQuery = useQuery({
queryKey: ["runtimeMetrics"],
queryFn: fetchRuntimeMetrics,
staleTime: 10_000,
refetchInterval: 15_000,
refetchIntervalInBackground: false,
});
const opsQuery = useQuery({
queryKey: ["opsTasks"],
queryFn: fetchOpsTasks,
staleTime: 10_000,
refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000),
refetchIntervalInBackground: false,
});
const neStatsQuery = useQuery({
queryKey: ["managedNeStats"],
queryFn: fetchManagedNeStats,
staleTime: 30_000,
refetchInterval: 45_000,
refetchIntervalInBackground: false,
enabled: isAdmin || hasScope("ne:read"),
});
const fabricQuery = useQuery({
queryKey: ["fabricSummary"],
queryFn: fetchFabricSummary,
staleTime: 30_000,
refetchInterval: 60_000,
refetchIntervalInBackground: false,
enabled: isAdmin || hasScope("ne:read"),
});
const topoTreeQuery = useQuery({
queryKey: ["topologyTree"],
queryFn: fetchTopologyTree,
staleTime: 45_000,
refetchInterval: 60_000,
refetchIntervalInBackground: false,
enabled: isAdmin || hasScope("ne:read"),
});
const visibleBySection = useMemo(() => {
const canSee = (mod: ModuleDefinition) =>
!mod.workbenchHidden &&
(!mod.adminOnly || isAdmin) &&
(!mod.requiredScope || hasScope(mod.requiredScope) || isAdmin);
return Object.fromEntries(
SECTIONS.map((section) => [section, modulesInSection(section).filter(canSee)]),
) as Record<WorkbenchSection, ModuleDefinition[]>;
}, [isAdmin, hasScope]);
const displaySections = useMemo((): DisplaySection[] => {
const ops = [...visibleBySection.monitoring, ...visibleBySection.operations];
const system = visibleBySection.system;
const sections: DisplaySection[] = [
{ key: "operations", mods: ops },
{ key: "system", mods: system },
];
return sections.filter((s) => s.mods.length > 0);
}, [visibleBySection]);
const railTasks = useMemo(() => {
const items = opsQuery.data?.items ?? [];
const active = items.filter((row) => isActiveStatus(row.status));
return (active.length ? active : items).slice(0, 8);
}, [opsQuery.data]);
const metrics = metricsQuery.data;
const host = metrics?.host;
const activeTasks = opsQuery.data?.active ?? 0;
const cpuPct = clampPct(Number(host?.cpu_percent ?? 0));
const memPct = clampPct(Number(host?.mem_percent ?? 0));
const memUsed = Number(host?.mem_used_bytes || 0);
const memTotal = Number(host?.mem_total_bytes || 0);
const storage = metrics?.db_storage;
const storageUsed = Number(storage?.used_bytes || 0);
const storageOk =
storageUsed > 0 || (storage?.source === "pg_database_size" && !storage?.error);
const cliUsed = Number(metrics?.cli_budget?.in_use || 0);
const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0));
const cliPct = ratioPct(cliUsed, cliLimit);
const dbOut = Number(metrics?.db_pool?.checked_out || 0);
const dbSize = Math.max(1, Number(metrics?.db_pool?.size || 0));
const dbPct = ratioPct(dbOut, dbSize);
const webUsed = Number(metrics?.webcrt?.active_sessions || 0);
const webMax = Math.max(1, Number(metrics?.webcrt?.max_sessions || 20));
const webPct = ratioPct(webUsed, webMax);
const hostBusy = cpuPct >= 75 || memPct >= 75;
const platformBusy = cliPct >= 75 || dbPct >= 75 || webPct >= 75;
const schedulers = metrics?.device_schedulers;
const schedConfigOn = Boolean(schedulers?.config_sync?.running ?? metrics?.config_sync?.running);
const schedLldpOn = Boolean(schedulers?.lldp_collect?.running ?? metrics?.lldp_collect?.running);
const schedPortOn = Boolean(schedulers?.port_traffic?.running ?? metrics?.port_traffic?.running);
const schedNeOn = Boolean(schedulers?.ne_collect?.running);
const schedStale = Boolean(schedulers?.stale);
const schedMode = String(schedulers?.mode || "");
const schedAge = typeof schedulers?.age_sec === "number" ? schedulers.age_sec : null;
const healthBusy = hostBusy || platformBusy || schedStale;
const canReadFleet = isAdmin || hasScope("ne:read");
const neTotal = neStatsQuery.data?.total ?? 0;
const neByStatus = neStatsQuery.data?.by_status ?? {};
const nePass = Number(neByStatus.pass || 0);
const neFail = Number(neByStatus.fail || 0);
const neTesting = Number(neByStatus.testing || 0);
const neUnknown = Number(neByStatus.unknown || 0);
const neNoTag = Number(neStatsQuery.data?.no_tag_count || 0);
const neKnownPct = neTotal > 0 ? clampPct(((neTotal - neUnknown) / neTotal) * 100) : 0;
const neTopTags = Object.entries(neStatsQuery.data?.tag_counts ?? {})
.filter(([tag, n]) => tag && tag !== "__no_tag__" && Number(n) > 0)
.sort((a, b) => Number(b[1]) - Number(a[1]))
.slice(0, 2)
.map(([tag, n]) => ({ tag, n: Number(n) }));
const neValue = !canReadFleet
? "—"
: neStatsQuery.isLoading
? "…"
: neStatsQuery.isError
? "—"
: neTotal;
const fabricNodes = Number(fabricQuery.data?.node_count || 0);
const fabricEdges = Number(fabricQuery.data?.edge_count || 0);
const fabricDiscover = formatCompactTime(fabricQuery.data?.last_discover_at ?? null);
const fabricUpdated = formatCompactTime(fabricQuery.data?.updated_at ?? null);
const regionSlices = useMemo((): FleetSlice[] => {
const regions = topLevelRegions(topoTreeQuery.data?.root)
.map((r) => ({
id: r.id,
name: String(r.name || "").trim() || r.id,
n: Number(r.ne_count || 0),
}))
.filter((r) => r.n > 0)
.sort((a, b) => b.n - a.n);
// Cap at 4 legend rows to match 网元 / 调度 denseness.
const TOP_N = 3;
const head = regions.slice(0, TOP_N);
const rest = regions.slice(TOP_N);
const restSum = rest.reduce((s, r) => s + r.n, 0);
const assigned = regions.reduce((s, r) => s + r.n, 0);
const unassigned = Math.max(0, fabricNodes - assigned);
const other = restSum + unassigned;
const slices: FleetSlice[] = head.map((r, i) => ({
key: r.id,
n: r.n,
label: r.name,
color: REGION_PALETTE[i % REGION_PALETTE.length],
}));
if (other > 0) {
slices.push({
key: unassigned > 0 && restSum === 0 ? "__none__" : "__other__",
n: other,
label: "",
color: unassigned > 0 && restSum === 0 ? FLEET_TONE_COLOR.muted : "#94a3b8",
});
}
return slices;
}, [topoTreeQuery.data, fabricNodes]);
const memValue =
memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`;
const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—";
const dash = (v: number | string) => (canReadFleet ? v : "—");
const tickAge = (sec?: number | null) => formatAgeSec(sec ?? null);
const schedRows: SchedDiscItem[] = [
{
key: "config",
on: schedConfigOn,
label: t("workbench.fleetSchedConfig"),
age: tickAge(schedulers?.config_sync?.last_tick_age_sec),
moduleId: "network",
path: "/network/tasks/config-sync",
},
{
key: "lldp",
on: schedLldpOn,
label: t("workbench.fleetSchedLldp"),
age: tickAge(schedulers?.lldp_collect?.last_tick_age_sec),
moduleId: "network",
path: "/network/topology/lldp",
},
{
key: "port",
on: schedPortOn,
label: t("workbench.fleetSchedPort"),
age: tickAge(schedulers?.port_traffic?.last_tick_age_sec),
moduleId: "network",
path: "/network/tasks/port-traffic",
},
{
key: "ne",
on: schedNeOn,
label: t("workbench.fleetSchedNeCollect"),
age: tickAge(schedulers?.ne_collect?.last_tick_age_sec),
moduleId: "network",
path: "/network/tasks/collect",
},
];
const schedOnCount = schedRows.filter((r) => r.on).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>
</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>
<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>
</div>
<div className="wb-fleet__grid">
<button
type="button"
className="wb-fleet__card"
disabled={!canReadFleet}
onClick={() => openOrFocusModule({ moduleId: "ne", path: "/ne" })}
>
<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}%` : "—"}
</span>
</div>
<div className="wb-fleet__viz">
<FleetDonut
parts={[
{ key: "pass", n: canReadFleet ? nePass : 0, tone: "ok" },
{ key: "fail", n: canReadFleet ? neFail : 0, tone: "bad" },
{ key: "testing", n: canReadFleet ? neTesting : 0, tone: "mid" },
{ key: "unknown", n: canReadFleet ? neUnknown : 0, tone: "muted" },
]}
center={typeof neValue === "number" ? formatCount(neValue) : neValue}
unit={
canReadFleet && typeof neValue === "number"
? t("workbench.fleetNeUnit")
: undefined
}
/>
<div className="wb-fleet__rows">
<div className="wb-fleet__row">
<span className="wb-fleet__legend">
<i className="wb-fleet__swatch is-ok" aria-hidden="true" />
{t("workbench.fleetNePass")}
</span>
<strong className="is-ok">{dash(nePass)}</strong>
</div>
<div className="wb-fleet__row">
<span className="wb-fleet__legend">
<i className="wb-fleet__swatch is-bad" aria-hidden="true" />
{t("workbench.fleetNeFail")}
</span>
<strong className="is-bad">{dash(neFail)}</strong>
</div>
<div className="wb-fleet__row">
<span className="wb-fleet__legend">
<i className="wb-fleet__swatch is-mid" aria-hidden="true" />
{t("workbench.fleetNeTesting")}
</span>
<strong className="is-mid">{dash(neTesting)}</strong>
</div>
<div className="wb-fleet__row">
<span className="wb-fleet__legend">
<i className="wb-fleet__swatch is-muted" aria-hidden="true" />
{t("workbench.fleetNeUnknown")}
</span>
<strong className="is-muted">{dash(neUnknown)}</strong>
</div>
</div>
</div>
<div className="wb-fleet__foot">
<span>
{t("workbench.fleetNeTags")}{" "}
{canReadFleet && neTopTags.length > 0 ? (
neTopTags.map((x, i) => (
<em key={x.tag}>
{i > 0 ? " · " : ""}
{x.tag} {formatCount(x.n)}
</em>
))
) : (
<em>
{t("workbench.fleetNeNoTag")} {canReadFleet ? neNoTag : "—"}
</em>
)}
</span>
</div>
</button>
<button
type="button"
className="wb-fleet__card"
disabled={!canReadFleet}
onClick={() => openOrFocusModule({ moduleId: "topology", path: "/topology" })}
>
<div className="wb-fleet__card-top">
<div className="wb-fleet__card-label">{t("workbench.fleetTopo")}</div>
<span className="wb-fleet__card-badge">
{canReadFleet
? `${formatCount(fabricEdges)} ${t("workbench.fleetTopoEdges")}`
: "—"}
</span>
</div>
<div className="wb-fleet__viz">
<FleetDonut
parts={canReadFleet ? regionSlices : []}
center={
!canReadFleet
? "—"
: fabricQuery.isLoading || topoTreeQuery.isLoading
? "…"
: 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>
</div>
) : (
(canReadFleet ? regionSlices : []).map((s) => (
<div key={s.key} className="wb-fleet__row">
<span className="wb-fleet__legend">
<i
className="wb-fleet__swatch"
style={{ background: sliceColor(s), boxShadow: "none" }}
aria-hidden="true"
/>
<span className="wb-fleet__legend-text">
{s.key === "__other__"
? t("workbench.fleetTopoOther")
: s.key === "__none__"
? t("workbench.fleetTopoUnassigned")
: s.label || s.key}
</span>
</span>
<strong>{formatCount(s.n)}</strong>
</div>
))
)}
</div>
</div>
<div className="wb-fleet__foot">
<span>
{t("workbench.fleetTopoDiscover")}{" "}
<em>{canReadFleet ? fabricDiscover : "—"}</em>
</span>
<span>
{t("workbench.fleetTopoUpdated")}{" "}
<em>{canReadFleet ? fabricUpdated : "—"}</em>
</span>
</div>
</button>
<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>
</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) => (
<button
key={row.key}
type="button"
className={`wb-fleet__row wb-fleet__row--sched${row.on ? " is-on" : ""}`}
onClick={(e) => {
e.stopPropagation();
openOrFocusModule({ moduleId: row.moduleId, path: row.path });
}}
>
<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",
}}
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"}
</span>
<span className="wb-fleet__sched-age">{row.age}</span>
</button>
))}
</div>
</div>
<div className="wb-fleet__foot">
<span>
{t("workbench.fleetSchedMode")} <em>{schedMode || "—"}</em>
</span>
<span>
{t("workbench.fleetSchedHeartbeat")}{" "}
<em className={schedStale ? "is-warn" : "is-ok"}>{formatAgeSec(schedAge)}</em>
</span>
</div>
</div>
</div>
</section>
</div>
<aside className="wb-side" aria-label={t("workbench.sidePanel")}>
<section className="wb-rail wb-panel wb-rail--tasks">
<div className="wb-rail__head">
<h2 className="wb-rail__title">
{t("workbench.taskRail")}
<span className="wb-rail__count">{activeTasks}</span>
</h2>
<Button
type="button"
size="sm"
variant="ghost"
className="wb-rail__all"
onPress={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
>
{t("workbench.taskRailAll")}
</Button>
</div>
{railTasks.length === 0 ? (
<p className="wb-rail__empty">{t("workbench.taskRailEmpty")}</p>
) : (
<ul className="wb-rail__list">
{railTasks.map((row) => (
<li key={`${row.kind}:${row.id}`}>
<button
type="button"
className="wb-rail__item"
onClick={() => openTaskHref(row.href)}
>
<span className="wb-rail__item-title">{taskTitle(row, t)}</span>
<span className="wb-rail__item-meta">
<span className={`wb-rail__status wb-rail__status--${statusTone(row.status)}`}>
{statusLabel(row.status, t)}
</span>
{row.progress ? (
<span className="wb-rail__progress"> · {row.progress}</span>
) : null}
</span>
</button>
</li>
))}
</ul>
)}
</section>
</aside>
</div>
</div>
);
}

View file

@ -0,0 +1,61 @@
import type { ModuleIconKind, ModuleIconTone } from "../../../config/modules";
type Props = {
tone: ModuleIconTone;
kind: ModuleIconKind;
};
function IconPath({ kind }: { kind: ModuleIconKind }) {
switch (kind) {
case "sync":
return (
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" />
);
case "server":
return (
<path d="M4 4h16a1 1 0 0 1 1 1v5H3V5a1 1 0 0 1 1-1zm-1 8h18v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5zm3 1.25a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm0-8a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM8 6.5h6v1.25H8V6.5zm0 8h6v1.25H8V14.5z" />
);
case "network":
return (
<path d="M12 2a3 3 0 0 1 1 5.83V10h3a3 3 0 1 1 0 2h-3v2.17A3.001 3.001 0 1 1 11 17.83V14H8a3 3 0 1 1 0-2h3v-2.17A3.001 3.001 0 0 1 12 2zm0 2a1 1 0 1 0 .001 2.001A1 1 0 0 0 12 4zM6 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
);
case "topology":
return (
<path d="M7 3a3 3 0 0 1 2.83 4H11v2h2V7h1.17A3.001 3.001 0 1 1 17 10.83V12h2v2h-2v1.17A3.001 3.001 0 1 1 13.17 18H12v-2h-1.17A3.001 3.001 0 1 1 7 13.17V12H5v-2h2V8.83A3.001 3.001 0 0 1 7 3zm10 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM7 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm0 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
);
case "terminal":
return (
<path d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5zm2 0v14h12V5H6zm2.3 3.2 1.4-1.4L13.1 10l-3.4 3.2-1.4-1.4L10.3 10 8.3 8.2zM12 15h4v1.8h-4V15z" />
);
case "wall":
return (
<path d="M3 5h8v6H3V5zm10 0h8v6h-8V5zM3 13h8v6H3v-6zm10 0h8v6h-8v-6zm2-6v2h4V7h-4zm0 8v2h4v-2h-4zM5 7v2h4V7H5zm0 8v2h4v-2H5z" />
);
case "users":
return (
<path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0 2c-3.33 0-6 1.34-6 3v1h12v-1c0-1.66-2.67-3-6-3zm7.5-5.5a2.5 2.5 0 1 0-2.45-3H17a2.5 2.5 0 0 0 2.5 3.5zM19 12c1.66 0 3 .67 3 1.5V14h-2.1c-.3-.7-1-1.2-1.9-1.5v-.5z" />
);
case "audit":
return (
<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7 1.5V9h4.5L14 4.5zM9 12h6v1.5H9V12zm0 3.5h6V17H9v-1.5zm0-7h3V10H9V8.5z" />
);
case "key":
return (
<path d="M12.65 10A5.99 5.99 0 0 0 7 4a6 6 0 0 0-1 11.92V20l2 2 2-2 1-1v-2.08c.33.05.66.08 1 .08a6 6 0 0 0 5.65-8H12.65zM7 8a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" />
);
default:
return (
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
);
}
}
export function WorkbenchCardIcon({ tone, kind }: Props) {
return (
<span className={`wb-card__icon wb-card__icon--${tone}`} aria-hidden>
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<IconPath kind={kind} />
</svg>
</span>
);
}