mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 15:53:20 +08:00
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:
parent
3b45d6abe3
commit
0d894efe5e
196 changed files with 26947 additions and 28091 deletions
445
web/src/features/api-tokens/ApiTokensPage.tsx
Normal file
445
web/src/features/api-tokens/ApiTokensPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
web/src/features/audit/AuditLayout.tsx
Normal file
32
web/src/features/audit/AuditLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
332
web/src/features/audit/AuditPage.tsx
Normal file
332
web/src/features/audit/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
web/src/features/audit/TaskOverviewPage.tsx
Normal file
194
web/src/features/audit/TaskOverviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
web/src/features/auth/AuthContext.tsx
Normal file
111
web/src/features/auth/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
123
web/src/features/auth/ForceChangePasswordPage.tsx
Normal file
123
web/src/features/auth/ForceChangePasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
web/src/features/auth/LoginPage.tsx
Normal file
126
web/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
web/src/features/auth/LoginShell.tsx
Normal file
35
web/src/features/auth/LoginShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1427
web/src/features/collect/CollectPage.tsx
Normal file
1427
web/src/features/collect/CollectPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
857
web/src/features/config-sync/ConfigSyncPage.tsx
Normal file
857
web/src/features/config-sync/ConfigSyncPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4298
web/src/features/cutover/BizComparePage.tsx
Normal file
4298
web/src/features/cutover/BizComparePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
3265
web/src/features/cutover/BizMigrationPage.tsx
Normal file
3265
web/src/features/cutover/BizMigrationPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
1484
web/src/features/cutover/BizMonitorTemplatesPage.tsx
Normal file
1484
web/src/features/cutover/BizMonitorTemplatesPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
3335
web/src/features/cutover/BizStatePage.tsx
Normal file
3335
web/src/features/cutover/BizStatePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
233
web/src/features/cutover/lib/compareTemplateModel.ts
Normal file
233
web/src/features/cutover/lib/compareTemplateModel.ts
Normal 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 })),
|
||||
},
|
||||
];
|
||||
}
|
||||
70
web/src/features/cutover/lib/cutoverDataCache.ts
Normal file
70
web/src/features/cutover/lib/cutoverDataCache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
web/src/features/cutover/lib/cutoverPrefetch.ts
Normal file
13
web/src/features/cutover/lib/cutoverPrefetch.ts
Normal 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");
|
||||
}
|
||||
1084
web/src/features/managed-ne/NePage.tsx
Normal file
1084
web/src/features/managed-ne/NePage.tsx
Normal file
File diff suppressed because it is too large
Load diff
250
web/src/features/managed-ne/components/HopProxyFields.tsx
Normal file
250
web/src/features/managed-ne/components/HopProxyFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
301
web/src/features/managed-ne/components/ManagedNeFormDialog.tsx
Normal file
301
web/src/features/managed-ne/components/ManagedNeFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
web/src/features/managed-ne/lib/connectStatus.ts
Normal file
14
web/src/features/managed-ne/lib/connectStatus.ts
Normal 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";
|
||||
}
|
||||
206
web/src/features/managed-ne/lib/formState.ts
Normal file
206
web/src/features/managed-ne/lib/formState.ts
Normal 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;
|
||||
}
|
||||
165
web/src/features/managed-ne/lib/hopProxy.ts
Normal file
165
web/src/features/managed-ne/lib/hopProxy.ts
Normal 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 };
|
||||
52
web/src/features/managed-ne/lib/zteHop.ts
Normal file
52
web/src/features/managed-ne/lib/zteHop.ts
Normal 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);
|
||||
}
|
||||
1351
web/src/features/network/LldpLinksPage.tsx
Normal file
1351
web/src/features/network/LldpLinksPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
278
web/src/features/network/NetworkAlarmsPage.tsx
Normal file
278
web/src/features/network/NetworkAlarmsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
395
web/src/features/network/NetworkConfigsPage.tsx
Normal file
395
web/src/features/network/NetworkConfigsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
web/src/features/network/NetworkDevicesPage.tsx
Normal file
211
web/src/features/network/NetworkDevicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
web/src/features/network/NetworkLayout.tsx
Normal file
128
web/src/features/network/NetworkLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
web/src/features/network/NetworkPlaceholderPage.tsx
Normal file
13
web/src/features/network/NetworkPlaceholderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
592
web/src/features/network/TopologyClassifyPage.tsx
Normal file
592
web/src/features/network/TopologyClassifyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
web/src/features/port-traffic/PortTrafficBoardListPage.tsx
Normal file
144
web/src/features/port-traffic/PortTrafficBoardListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1548
web/src/features/port-traffic/PortTrafficPage.tsx
Normal file
1548
web/src/features/port-traffic/PortTrafficPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
720
web/src/features/port-traffic/PortTrafficWallPage.tsx
Normal file
720
web/src/features/port-traffic/PortTrafficWallPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
536
web/src/features/port-traffic/components/PortTrafficWall.tsx
Normal file
536
web/src/features/port-traffic/components/PortTrafficWall.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
web/src/features/port-traffic/lib/portTrafficBoardWindow.ts
Normal file
17
web/src/features/port-traffic/lib/portTrafficBoardWindow.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
169
web/src/features/sessions/SessionsPage.tsx
Normal file
169
web/src/features/sessions/SessionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2509
web/src/features/topology/TopologyPage.tsx
Normal file
2509
web/src/features/topology/TopologyPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
51
web/src/features/topology/components/ParallelEdge.tsx
Normal file
51
web/src/features/topology/components/ParallelEdge.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
web/src/features/topology/components/TopoModalShell.tsx
Normal file
1
web/src/features/topology/components/TopoModalShell.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { AppModalShell, TopoModalShell, type AppModalShellProps } from "../../../components/ui/AppModalShell";
|
||||
522
web/src/features/topology/components/TopologyCtxMenu.tsx
Normal file
522
web/src/features/topology/components/TopologyCtxMenu.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
119
web/src/features/topology/components/TopologyDiscoverPanel.tsx
Normal file
119
web/src/features/topology/components/TopologyDiscoverPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
315
web/src/features/topology/components/TopologyHexBrowser.tsx
Normal file
315
web/src/features/topology/components/TopologyHexBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
web/src/features/topology/components/TopologyModals.tsx
Normal file
192
web/src/features/topology/components/TopologyModals.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
576
web/src/features/topology/components/TopologyReactFlowView.tsx
Normal file
576
web/src/features/topology/components/TopologyReactFlowView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
web/src/features/topology/components/TopologySidebar.tsx
Normal file
246
web/src/features/topology/components/TopologySidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
376
web/src/features/topology/components/TopologyToolbar.tsx
Normal file
376
web/src/features/topology/components/TopologyToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
393
web/src/features/topology/components/TopologyTreeNavFolder.tsx
Normal file
393
web/src/features/topology/components/TopologyTreeNavFolder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
615
web/src/features/topology/components/TopologyViewTools.tsx
Normal file
615
web/src/features/topology/components/TopologyViewTools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
web/src/features/topology/components/WorldScatterLayer.tsx
Normal file
110
web/src/features/topology/components/WorldScatterLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
120
web/src/features/topology/components/topologyIcons.tsx
Normal file
120
web/src/features/topology/components/topologyIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
web/src/features/topology/hooks/useTopologyAlarmOverlay.ts
Normal file
131
web/src/features/topology/hooks/useTopologyAlarmOverlay.ts
Normal 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) : "",
|
||||
};
|
||||
}
|
||||
599
web/src/features/topology/hooks/useTopologyCanvas.ts
Normal file
599
web/src/features/topology/hooks/useTopologyCanvas.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
181
web/src/features/topology/hooks/useTopologyConnectTest.ts
Normal file
181
web/src/features/topology/hooks/useTopologyConnectTest.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
243
web/src/features/topology/hooks/useTopologyCreateNe.ts
Normal file
243
web/src/features/topology/hooks/useTopologyCreateNe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
395
web/src/features/topology/hooks/useTopologyDiscover.ts
Normal file
395
web/src/features/topology/hooks/useTopologyDiscover.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
108
web/src/features/topology/hooks/useTopologyHistory.ts
Normal file
108
web/src/features/topology/hooks/useTopologyHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
670
web/src/features/topology/hooks/useTopologyTreeNav.ts
Normal file
670
web/src/features/topology/hooks/useTopologyTreeNav.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
73
web/src/features/topology/lib/alarmOverlay.ts
Normal file
73
web/src/features/topology/lib/alarmOverlay.ts
Normal 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;
|
||||
}
|
||||
33
web/src/features/topology/lib/constants.ts
Normal file
33
web/src/features/topology/lib/constants.ts
Normal 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";
|
||||
146
web/src/features/topology/lib/displayPrefs.ts
Normal file
146
web/src/features/topology/lib/displayPrefs.ts
Normal 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 };
|
||||
118
web/src/features/topology/lib/edgeStyle.ts
Normal file
118
web/src/features/topology/lib/edgeStyle.ts
Normal 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 };
|
||||
459
web/src/features/topology/lib/exportTopology.ts
Normal file
459
web/src/features/topology/lib/exportTopology.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
189
web/src/features/topology/lib/graphFlow.ts
Normal file
189
web/src/features/topology/lib/graphFlow.ts
Normal 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 };
|
||||
}
|
||||
23
web/src/features/topology/lib/idUtils.ts
Normal file
23
web/src/features/topology/lib/idUtils.ts
Normal 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";
|
||||
}
|
||||
248
web/src/features/topology/lib/layoutGraph.ts
Normal file
248
web/src/features/topology/lib/layoutGraph.ts
Normal 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);
|
||||
}
|
||||
21
web/src/features/topology/lib/levelTone.ts
Normal file
21
web/src/features/topology/lib/levelTone.ts
Normal 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}`;
|
||||
}
|
||||
214
web/src/features/topology/lib/linkDisplay.ts
Normal file
214
web/src/features/topology/lib/linkDisplay.ts
Normal 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];
|
||||
}
|
||||
29
web/src/features/topology/lib/pageTypes.ts
Normal file
29
web/src/features/topology/lib/pageTypes.ts
Normal 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 };
|
||||
40
web/src/features/topology/lib/searchUtils.ts
Normal file
40
web/src/features/topology/lib/searchUtils.ts
Normal 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);
|
||||
}
|
||||
35
web/src/features/topology/lib/sidebarLayout.ts
Normal file
35
web/src/features/topology/lib/sidebarLayout.ts
Normal 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)));
|
||||
}
|
||||
54
web/src/features/topology/lib/toolMode.ts
Normal file
54
web/src/features/topology/lib/toolMode.ts
Normal 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;
|
||||
}
|
||||
49
web/src/features/topology/lib/topoGeometry.ts
Normal file
49
web/src/features/topology/lib/topoGeometry.ts
Normal 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:");
|
||||
}
|
||||
227
web/src/features/topology/lib/treeUtils.ts
Normal file
227
web/src/features/topology/lib/treeUtils.ts
Normal 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;
|
||||
}
|
||||
35
web/src/features/topology/lib/vendorTone.ts
Normal file
35
web/src/features/topology/lib/vendorTone.ts
Normal 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 };
|
||||
73
web/src/features/topology/lib/worldGraph.ts
Normal file
73
web/src/features/topology/lib/worldGraph.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
182
web/src/features/users/UsersPage.tsx
Normal file
182
web/src/features/users/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3496
web/src/features/webcrt/WebcrtPage.tsx
Normal file
3496
web/src/features/webcrt/WebcrtPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
1144
web/src/features/webcrt/components/WebTerminal.tsx
Normal file
1144
web/src/features/webcrt/components/WebTerminal.tsx
Normal file
File diff suppressed because it is too large
Load diff
189
web/src/features/webcrt/lib/webcrtKeywordHighlight.ts
Normal file
189
web/src/features/webcrt/lib/webcrtKeywordHighlight.ts
Normal 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;
|
||||
}
|
||||
952
web/src/features/workbench/WorkbenchPage.tsx
Normal file
952
web/src/features/workbench/WorkbenchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
web/src/features/workbench/components/WorkbenchCardIcon.tsx
Normal file
61
web/src/features/workbench/components/WorkbenchCardIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue