mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 08:05:46 +08:00
Add standalone topology MCP with skill, scopes UI, and opt-in live sync.
Splits canvas/Fabric tools into netx-topology-mcp, documents install and a companion Cursor skill, lets API keys grant ne:write explicitly, and adds optional topology live sync so operators can watch agent drawing without constant polling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a95b96f648
commit
bf0dfd66d8
30 changed files with 2057 additions and 410 deletions
|
|
@ -477,7 +477,7 @@ const en = {
|
|||
colIp: "IP",
|
||||
apiKeysTitle: "API Key management",
|
||||
apiKeysHint:
|
||||
"Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users.",
|
||||
"Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users. Pick scopes; topology drawing needs ne:write.",
|
||||
tokenName: "Name",
|
||||
expiresIn: "Expiry",
|
||||
expire7d: "7 days",
|
||||
|
|
@ -501,6 +501,24 @@ const en = {
|
|||
tokenStatusRevoked: "Revoked",
|
||||
revokeToken: "Revoke",
|
||||
revokeConfirm: "Revoke this API key?",
|
||||
scopesTitle: "Scopes",
|
||||
scopesHint:
|
||||
"Scopes are stored on the token. Without Inherit, pick at least one. MCP default omits ne:write so write tools stay hidden from the agent.",
|
||||
scopesInherit: "Inherit all owner scopes",
|
||||
scopesInheritShort: "Inherit owner",
|
||||
scopesCol: "Scopes",
|
||||
scopesRequired: "Select at least one scope, or inherit all owner scopes",
|
||||
scopesNoneAvailable: "This owner has no grantable scopes",
|
||||
scopePresetMcp: "MCP default (read + CLI)",
|
||||
scopePresetTopoWrite: "MCP + topology write",
|
||||
scopeAlarmsRead: "alarms:read Alarms read",
|
||||
scopeNeRead: "ne:read NE / topology read",
|
||||
scopeNeWrite: "ne:write NE / topology write (draw)",
|
||||
scopeNeExec: "ne:exec Managed NE exec",
|
||||
scopeWebcrt: "webcrt:session WebCRT",
|
||||
scopeSql: "sql:query SQL",
|
||||
scopeAdminUsers: "admin:users User admin",
|
||||
scopeOpsWrite: "ops:write Ops write",
|
||||
forceChangeTitle: "Change initial password",
|
||||
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
|
||||
oldPassword: "Current password",
|
||||
|
|
@ -1375,6 +1393,9 @@ const en = {
|
|||
edgeDiscovered: "Discovered",
|
||||
edgeStale: "Missing",
|
||||
fit: "Fit view",
|
||||
liveSync: "Live sync",
|
||||
liveSyncOn: "Syncing…",
|
||||
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default. No need to open a map first",
|
||||
fullscreen: "Fullscreen",
|
||||
exitFullscreen: "Exit fullscreen",
|
||||
display: "Display",
|
||||
|
|
|
|||
|
|
@ -473,7 +473,7 @@ const zh = {
|
|||
colStatus: "状态码",
|
||||
colIp: "IP",
|
||||
apiKeysTitle: "API Key 管理",
|
||||
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。",
|
||||
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。可勾选权限;拓扑画图需 ne:write。",
|
||||
tokenName: "名称",
|
||||
expiresIn: "有效期",
|
||||
expire7d: "7 天",
|
||||
|
|
@ -497,6 +497,23 @@ const zh = {
|
|||
tokenStatusRevoked: "已吊销",
|
||||
revokeToken: "吊销",
|
||||
revokeConfirm: "确定吊销该 API Key?",
|
||||
scopesTitle: "权限范围",
|
||||
scopesHint: "勾选后写入 Token;不勾选「继承」时至少选一项。默认 MCP 不含 ne:write,写工具对 Agent 不可见。",
|
||||
scopesInherit: "继承所属用户全部权限",
|
||||
scopesInheritShort: "继承用户",
|
||||
scopesCol: "权限",
|
||||
scopesRequired: "请至少勾选一项权限,或选择继承用户全部权限",
|
||||
scopesNoneAvailable: "当前所属用户没有可授予的权限",
|
||||
scopePresetMcp: "MCP 默认(只读+CLI)",
|
||||
scopePresetTopoWrite: "MCP + 拓扑写",
|
||||
scopeAlarmsRead: "alarms:read 告警只读",
|
||||
scopeNeRead: "ne:read 网元/拓扑只读",
|
||||
scopeNeWrite: "ne:write 网元/拓扑写入(含画图)",
|
||||
scopeNeExec: "ne:exec 托管网元执行命令",
|
||||
scopeWebcrt: "webcrt:session WebCRT",
|
||||
scopeSql: "sql:query SQL 查询",
|
||||
scopeAdminUsers: "admin:users 用户管理",
|
||||
scopeOpsWrite: "ops:write 运维写入",
|
||||
forceChangeTitle: "请修改初始密码",
|
||||
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
|
||||
oldPassword: "当前密码",
|
||||
|
|
@ -1369,6 +1386,9 @@ const zh = {
|
|||
edgeDiscovered: "发现",
|
||||
edgeStale: "未发现",
|
||||
fit: "适应画布",
|
||||
liveSync: "实时同步",
|
||||
liveSyncOn: "同步中…",
|
||||
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新已打开的图(观看 MCP 建图/画拓扑);默认关闭。不需要先打开画布",
|
||||
fullscreen: "全屏显示",
|
||||
exitFullscreen: "退出全屏",
|
||||
display: "显示",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -11,6 +11,7 @@ type TokenRow = {
|
|||
name: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
scopes?: string[];
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
|
|
@ -23,9 +24,35 @@ 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",
|
||||
"webcrt:session",
|
||||
"sql:query",
|
||||
"admin:users",
|
||||
"ops:write",
|
||||
] as const;
|
||||
|
||||
const MCP_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec"] as const;
|
||||
const MCP_TOPO_WRITE_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] 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",
|
||||
"webcrt:session": "auth.scopeWebcrt",
|
||||
"sql:query": "auth.scopeSql",
|
||||
"admin:users": "auth.scopeAdminUsers",
|
||||
"ops:write": "auth.scopeOpsWrite",
|
||||
};
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: 7, labelKey: "auth.expire7d" },
|
||||
{ value: 30, labelKey: "auth.expire30d" },
|
||||
|
|
@ -40,14 +67,21 @@ function tokenStatusClass(row: TokenRow): string {
|
|||
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 } = useAuth();
|
||||
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 [inheritScopes, setInheritScopes] = useState(false);
|
||||
const [selectedScopes, setSelectedScopes] = useState<string[]>([...MCP_DEFAULT_SCOPES]);
|
||||
const [createdPlain, setCreatedPlain] = useState("");
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
|
|
@ -62,12 +96,32 @@ export function ApiTokensPage() {
|
|||
enabled: ready && isAdmin,
|
||||
});
|
||||
|
||||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
const availableScopes = useMemo(() => {
|
||||
if (ownerUserId) {
|
||||
const owner = users.find((u) => u.id === ownerUserId);
|
||||
return [...(owner?.scopes || [])].sort();
|
||||
}
|
||||
return [...(myScopes || [])].sort();
|
||||
}, [ownerUserId, users, myScopes]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedScopes((prev) => {
|
||||
const next = prev.filter((s) => availableScopes.includes(s));
|
||||
if (next.length) return next;
|
||||
return intersectScopes(availableScopes, MCP_DEFAULT_SCOPES);
|
||||
});
|
||||
}, [availableScopes]);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
|
||||
name: name.trim() || "mcp",
|
||||
expires_in_days: expiresInDays,
|
||||
user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
|
||||
scopes: inheritScopes ? [] : selectedScopes,
|
||||
}),
|
||||
onSuccess: async (data) => {
|
||||
setCreatedPlain(data.token.token);
|
||||
|
|
@ -86,11 +140,12 @@ export function ApiTokensPage() {
|
|||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
const onCreate = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inheritScopes && selectedScopes.length === 0) {
|
||||
showError(t("auth.scopesRequired"));
|
||||
return;
|
||||
}
|
||||
setCreatedPlain("");
|
||||
createMut.mutate();
|
||||
};
|
||||
|
|
@ -104,6 +159,22 @@ export function ApiTokensPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setSelectedScopes((prev) =>
|
||||
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(),
|
||||
);
|
||||
};
|
||||
|
||||
const applyPreset = (desired: readonly string[]) => {
|
||||
setInheritScopes(false);
|
||||
setSelectedScopes(intersectScopes(availableScopes, desired));
|
||||
};
|
||||
|
||||
const formatScopes = (scopes: string[] | undefined) => {
|
||||
if (!scopes || scopes.length === 0) return t("auth.scopesInheritShort");
|
||||
return scopes.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
|
|
@ -113,43 +184,91 @@ export function ApiTokensPage() {
|
|||
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<form className="filter-inline" onSubmit={onCreate}>
|
||||
<input
|
||||
placeholder={t("auth.tokenName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
aria-label={t("auth.expiresIn")}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isAdmin ? (
|
||||
<form className="token-create" onSubmit={onCreate}>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
placeholder={t("auth.tokenName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={ownerUserId}
|
||||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
aria-label={t("auth.expiresIn")}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
{t("auth.createToken")}
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
value={ownerUserId}
|
||||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
{t("auth.createToken")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="token-scopes">
|
||||
<div className="token-scopes__head">
|
||||
<strong>{t("auth.scopesTitle")}</strong>
|
||||
<span className="panel__hint" style={{ margin: 0 }}>
|
||||
{t("auth.scopesHint")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="token-scopes__presets">
|
||||
<button type="button" className="btn--ghost btn--sm" onClick={() => applyPreset(MCP_DEFAULT_SCOPES)}>
|
||||
{t("auth.scopePresetMcp")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--ghost btn--sm"
|
||||
onClick={() => applyPreset(MCP_TOPO_WRITE_SCOPES)}
|
||||
disabled={!availableScopes.includes("ne:write")}
|
||||
>
|
||||
{t("auth.scopePresetTopoWrite")}
|
||||
</button>
|
||||
</div>
|
||||
<label className="token-scopes__inherit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inheritScopes}
|
||||
onChange={(e) => setInheritScopes(e.target.checked)}
|
||||
/>
|
||||
{t("auth.scopesInherit")}
|
||||
</label>
|
||||
<div className={`token-scopes__grid${inheritScopes ? " is-disabled" : ""}`}>
|
||||
{ALL_SCOPE_KEYS.filter((s) => availableScopes.includes(s)).map((scope) => (
|
||||
<label key={scope}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={inheritScopes}
|
||||
checked={selectedScopes.includes(scope)}
|
||||
onChange={() => toggleScope(scope)}
|
||||
/>
|
||||
{t(SCOPE_LABEL_KEYS[scope])}
|
||||
</label>
|
||||
))}
|
||||
{!availableScopes.length ? (
|
||||
<span className="muted">{t("auth.scopesNoneAvailable")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{createdPlain ? (
|
||||
|
|
@ -177,6 +296,7 @@ export function ApiTokensPage() {
|
|||
<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>
|
||||
|
|
@ -189,6 +309,9 @@ export function ApiTokensPage() {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { apiPost } from "../services/api";
|
||||
import { LoginShell } from "./LoginShell";
|
||||
|
||||
export function ForceChangePasswordPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -42,26 +43,24 @@ export function ForceChangePasswordPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__atmosphere" aria-hidden="true">
|
||||
<span className="login-page__orb login-page__orb--a" />
|
||||
<span className="login-page__orb login-page__orb--b" />
|
||||
<span className="login-page__grid" />
|
||||
</div>
|
||||
<LoginShell>
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
<div className="login-card__head">
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<p className="login-card__hint">
|
||||
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
|
||||
</p>
|
||||
<label className="login-card__label">
|
||||
{t("auth.oldPassword")}
|
||||
<span className="login-card__sr">{t("auth.oldPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
placeholder={t("auth.oldPassword")}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
|
|
@ -69,10 +68,11 @@ export function ForceChangePasswordPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.newPassword")}
|
||||
<span className="login-card__sr">{t("auth.newPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.newPassword")}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
|
|
@ -81,10 +81,11 @@ export function ForceChangePasswordPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.confirmPassword")}
|
||||
<span className="login-card__sr">{t("auth.confirmPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.confirmPassword")}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
disabled={busy}
|
||||
|
|
@ -92,20 +93,23 @@ export function ForceChangePasswordPage() {
|
|||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="login-card__error">{error}</div> : null}
|
||||
{error ? (
|
||||
<div className="login-card__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="login-card__submit" disabled={busy}>
|
||||
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__submit"
|
||||
style={{ background: "#64748b" }}
|
||||
className="login-card__submit login-card__submit--ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { LoginShell } from "./LoginShell";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -9,6 +10,7 @@ export function LoginPage() {
|
|||
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);
|
||||
|
||||
|
|
@ -31,36 +33,66 @@ export function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__atmosphere" aria-hidden="true">
|
||||
<span className="login-page__orb login-page__orb--a" />
|
||||
<span className="login-page__orb login-page__orb--b" />
|
||||
<span className="login-page__grid" />
|
||||
</div>
|
||||
<LoginShell>
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
<div className="login-card__head">
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<label className="login-card__label">
|
||||
{t("auth.username")}
|
||||
<span className="login-card__sr">{t("auth.username")}</span>
|
||||
<input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.password")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
<span className="login-card__sr">{t("auth.password")}</span>
|
||||
<span className="login-card__password">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__eye"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
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>
|
||||
</span>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="login-card__error" role="alert">
|
||||
|
|
@ -71,6 +103,6 @@ export function LoginPage() {
|
|||
{busy ? t("auth.loggingIn") : t("auth.login")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
web/src/pages/LoginShell.tsx
Normal file
18
web/src/pages/LoginShell.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/** Auth shell modeled after CuteCloud login: space BG + rotating night-earth + centered form. */
|
||||
export function LoginShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__space" aria-hidden="true">
|
||||
<div className="login-planet">
|
||||
<div className="login-planet__railway" />
|
||||
<div className="login-planet__earth">
|
||||
<div className="login-planet__textures" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-page__content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -597,6 +597,22 @@ function flowToPositions(nodes: Node<NeNodeData>[]) {
|
|||
}));
|
||||
}
|
||||
|
||||
/** Stable signature so live poll only re-applies when the server graph actually changed. */
|
||||
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 || ""}`,
|
||||
)
|
||||
.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}`;
|
||||
}
|
||||
|
||||
function applyViewGraph(
|
||||
graph: TopologyViewGraph,
|
||||
defaults: EdgeDefaults,
|
||||
|
|
@ -678,6 +694,8 @@ export function TopologyPage() {
|
|||
});
|
||||
const [discoverError, setDiscoverError] = useState("");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
|
||||
const [liveSync, setLiveSync] = useState(false);
|
||||
const [canvasQuery, setCanvasQuery] = useState("");
|
||||
const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
|
|
@ -692,6 +710,8 @@ export function TopologyPage() {
|
|||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const appliedMapIdRef = useRef("");
|
||||
const appliedGraphFpRef = useRef("");
|
||||
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
||||
const historyRef = useRef<HistorySnap[]>([]);
|
||||
const redoRef = useRef<HistorySnap[]>([]);
|
||||
const historyLockRef = useRef(false);
|
||||
|
|
@ -699,6 +719,8 @@ export function TopologyPage() {
|
|||
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||
|
||||
nodesRef.current = nodes;
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
dirtyRef.current = true;
|
||||
setDirty(true);
|
||||
|
|
@ -760,6 +782,8 @@ export function TopologyPage() {
|
|||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
refetchInterval: liveSync ? 5000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -777,6 +801,8 @@ export function TopologyPage() {
|
|||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () => fetchTopologyGraph(mapId),
|
||||
enabled: Boolean(mapId),
|
||||
refetchInterval: liveSync && mapId ? 3000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -915,24 +941,41 @@ export function TopologyPage() {
|
|||
|
||||
useEffect(() => {
|
||||
appliedMapIdRef.current = "";
|
||||
appliedGraphFpRef.current = "";
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMode) return;
|
||||
if (!mapId || !graphQuery.data) return;
|
||||
// Only hydrate React Flow from server when entering a map — never clobber unsaved local positions.
|
||||
if (appliedMapIdRef.current === mapId) return;
|
||||
appliedMapIdRef.current = mapId;
|
||||
const entering = appliedMapIdRef.current !== mapId;
|
||||
// Live poll while on the same map: apply server graph only if no unsaved local edits.
|
||||
if (!entering && dirtyRef.current) 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(rfNodes);
|
||||
setNodes(nextNodes);
|
||||
setEdges(rfEdges);
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
clearDirty();
|
||||
bumpHistory();
|
||||
appliedGraphFpRef.current = fp;
|
||||
if (entering) {
|
||||
appliedMapIdRef.current = mapId;
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
clearDirty();
|
||||
bumpHistory();
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}
|
||||
historyLockRef.current = false;
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2119,17 +2162,29 @@ export function TopologyPage() {
|
|||
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
||||
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="topo-sidebar__rail"
|
||||
title={t("topology.expandSidebar")}
|
||||
aria-label={t("topology.expandSidebar")}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||
<SidebarFoldIcon expand />
|
||||
</span>
|
||||
</button>
|
||||
<div className="topo-sidebar__rail-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-sidebar__rail"
|
||||
title={t("topology.expandSidebar")}
|
||||
aria-label={t("topology.expandSidebar")}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||
<SidebarFoldIcon expand />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`topo-sidebar__rail topo-sidebar__rail--live${liveSync ? " is-on" : ""}`}
|
||||
aria-pressed={liveSync}
|
||||
title={t("topology.liveSyncHint")}
|
||||
aria-label={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||
onClick={() => setLiveSync((v) => !v)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-live-dot" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="topo-sidebar__section">
|
||||
|
|
@ -2167,6 +2222,17 @@ export function TopologyPage() {
|
|||
<SidebarFoldIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="topo-sidebar__live">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
||||
aria-pressed={liveSync}
|
||||
title={t("topology.liveSyncHint")}
|
||||
onClick={() => setLiveSync((v) => !v)}
|
||||
>
|
||||
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||
</button>
|
||||
</div>
|
||||
{treeSearchOpen && treeNeQuery.trim() ? (
|
||||
<div className="topo-tree-search__panel" role="listbox">
|
||||
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
|
||||
|
|
@ -2905,6 +2971,15 @@ export function TopologyPage() {
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
||||
aria-pressed={liveSync}
|
||||
title={t("topology.liveSyncHint")}
|
||||
onClick={() => setLiveSync((v) => !v)}
|
||||
>
|
||||
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||
</button>
|
||||
</div>
|
||||
{!activeRegion ? (
|
||||
regions.length === 0 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue