Improve UME and managed NE list UI layout and styling.

Reorganize the UME page with collapsible CLI, sync status, and AI alert panels; embed CLI connect config in the main view; move task cards under token status; add tags column to managed NE list; and fix hop/proxy form alignment plus consistent section spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-02 10:22:21 +08:00
parent 39aad258e6
commit 775989cad4
6 changed files with 391 additions and 193 deletions

View file

@ -601,6 +601,7 @@ export function NePage() {
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.deviceType")}</th>
<th>{t("managedNe.col.tags")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.user")}</th>
<th>{t("managedNe.col.connect")}</th>
@ -621,6 +622,15 @@ export function NePage() {
<td>{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.device_type}</td>
<td>
{row.tags
? row.tags.split(/\s+/).map((tag) => (
<span key={tag} className="table-tag">
{tag}
</span>
))
: t("common.empty")}
</td>
<td>
{row.ip_address}:{row.port}/{row.protocol}
{row.hop_enabled ? (
@ -808,7 +818,7 @@ export function NePage() {
}));
}}
/>
{t("managedNe.hop.enable")}
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
{form.hop_enabled ? (
<HopProxyFields

View file

@ -46,6 +46,8 @@ export function UmePage() {
const [nePageSize, setNePageSize] = useState(50);
const [expandedNeId, setExpandedNeId] = useState("");
const [nePanelOpen, setNePanelOpen] = useState(false);
const [syncStatusPanelOpen, setSyncStatusPanelOpen] = useState(false);
const [keyAlertPanelOpen, setKeyAlertPanelOpen] = useState(false);
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
@ -69,17 +71,7 @@ export function UmePage() {
const [keyAlertNeTypes, setKeyAlertNeTypes] = useState<string[]>([]);
const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null);
const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]);
const [umeViewTab, setUmeViewTab] = useState<"main" | "cli">("main");
const syncMutation = useMutation({
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.umeCurrentAlarmsAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.umeInventoryNeTypes });
},
});
const [cliPanelOpen, setCliPanelOpen] = useState(false);
const syncStatusQuery = useQuery({
queryKey: queryKeys.umeSyncStatus(syncPage, syncPageSize),
@ -138,6 +130,7 @@ export function UmePage() {
}),
staleTime: 3000,
refetchInterval: 5000,
enabled: keyAlertPanelOpen,
});
const keyAlertNeTypesQuery = useQuery({
queryKey: queryKeys.umeInventoryNeTypes,
@ -261,7 +254,7 @@ export function UmePage() {
queryKey: queryKeys.cliTargets(neKeyword, nePage, nePageSize),
queryFn: () =>
fetchCliTargets({ source: "ume", keyword: neKeyword, page: nePage, pageSize: nePageSize }),
enabled: nePanelOpen && umeViewTab === "main",
enabled: nePanelOpen,
staleTime: 5000,
});
const cliStatusByNeId = useMemo(() => {
@ -459,30 +452,7 @@ export function UmePage() {
return (
<>
<section className="panel" style={{ marginBottom: 12 }}>
<div className="filter-inline">
<button
type="button"
className={umeViewTab === "main" ? "link-btn" : "link-btn"}
style={{ fontWeight: umeViewTab === "main" ? 700 : 400 }}
onClick={() => setUmeViewTab("main")}
>
{t("ume.tabs.main")}
</button>
<button
type="button"
className="link-btn"
style={{ fontWeight: umeViewTab === "cli" ? 700 : 400 }}
onClick={() => setUmeViewTab("cli")}
>
{t("ume.tabs.cli")}
</button>
</div>
</section>
{umeViewTab === "cli" ? (
<UmeCliConnectPanel />
) : (
<>
<div className="page-stack">
<section className="cards">
<article className="card card--full">
<h3>{t("ume.token.title")}</h3>
@ -562,6 +532,105 @@ export function UmePage() {
)}
</article>
<article className="card card--full">
<h3>{t("ume.tasks.currentTitle")}</h3>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
{t("ume.tasks.running")}: {runningTasks.length}
</span>
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
{t("common.refresh")}
</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>domain</th>
<th>trigger_mode</th>
<th>status</th>
<th>started_at</th>
<th>error</th>
</tr>
</thead>
<tbody>
{runningTasks.map((x) => (
<tr key={`running-${x.id}`}>
<td>{x.id}</td>
<td>{x.domain}</td>
<td>{x.trigger_mode}</td>
<td>{x.status}</td>
<td>{formatSystemTime(x.started_at)}</td>
<td>{x.error_message || t("common.empty")}</td>
</tr>
))}
{!syncStatusQuery.isLoading && runningTasks.length === 0 && (
<tr>
<td colSpan={6}>{t("ume.tasks.noRunning")}</td>
</tr>
)}
</tbody>
</table>
<h3 className="card__section-title">{t("ume.tasks.runtimeTitle")}</h3>
{runtimeTaskError ? (
<div className="pill pill--high" style={{ marginBottom: 8 }}>
{t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError}
</div>
) : null}
<table>
<thead>
<tr>
<th>task</th>
<th title={t("ume.tasks.intervalTitle")}>interval</th>
<th>status</th>
<th title={t("ume.tasks.lastRunTitle")}>last_run_at</th>
<th>{t("ume.tasks.lastErrorCol")}</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{runtimeTasks.map((x) => (
<tr key={`runtime-${x.task}`}>
<td title={x.task}>{runtimeTaskLabel(x.task)}</td>
<td title={typeof x.interval_s === "number" ? `${x.interval_s}s` : undefined}>
{runtimeIntervalLabel(x.interval_label)}
</td>
<td>{x.status}</td>
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}</td>
<td>{runtimeLastError(x.last_error, t) || t("common.empty")}</td>
<td>
{Boolean(x.paused) ? (
<button
type="button"
className="link-btn"
disabled={runtimeTaskMutation.isPending}
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
>
resume
</button>
) : (
<button
type="button"
className="link-btn"
disabled={runtimeTaskMutation.isPending}
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
>
pause
</button>
)}
</td>
</tr>
))}
{!syncStatusQuery.isLoading && runtimeTasks.length === 0 && (
<tr>
<td colSpan={6}>{t("ume.tasks.noRuntime")}</td>
</tr>
)}
</tbody>
</table>
</article>
<article className="card card--full">
<h3 className="card-title-with-hint">
{t("ume.subscription.title")}
@ -711,10 +780,30 @@ export function UmePage() {
</article>
<article className="card card--full">
<h3 className="card-title-with-hint">
{t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h3>
<div className="panel__toolbar" style={{ marginBottom: cliPanelOpen ? 12 : 0 }}>
<h3 className="card-title-with-hint">
{t("ume.cli.title")}
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
</h3>
<button type="button" className="link-btn" onClick={() => setCliPanelOpen((x) => !x)}>
{cliPanelOpen ? t("ume.cli.hidePanel") : t("ume.cli.showPanel")}
</button>
</div>
{cliPanelOpen ? <UmeCliConnectPanel embedded enabled /> : null}
</article>
<article className="card card--full">
<div className="panel__toolbar" style={{ marginBottom: keyAlertPanelOpen ? 12 : 0 }}>
<h3 className="card-title-with-hint">
{t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h3>
<button type="button" className="link-btn" onClick={() => setKeyAlertPanelOpen((x) => !x)}>
{keyAlertPanelOpen ? t("ume.keyAlert.hidePanel") : t("ume.keyAlert.showPanel")}
</button>
</div>
{keyAlertPanelOpen ? (
<>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${oclawWsPill}`}>
{t("ume.keyAlert.ws")}:{" "}
@ -1059,130 +1148,20 @@ export function UmePage() {
</select>
</div>
</div>
</article>
<article className="card card--full">
<h3>{t("ume.sync.title")}</h3>
<div className="actions-row actions-row--inline">
<button onClick={() => syncMutation.mutate(["inventory"])} disabled={syncMutation.isPending}>
{t("ume.sync.inventory")}
</button>
<button onClick={() => syncMutation.mutate(["alarms_current"])} disabled={syncMutation.isPending}>
{t("ume.sync.alarmsCurrent")}
</button>
<button onClick={() => syncMutation.mutate(["inventory", "alarms_current"])} disabled={syncMutation.isPending}>
{t("ume.sync.full")}
</button>
</div>
{syncMutation.error && (
<div className="pill pill--high">
{t("ume.sync.failed")}: {String(syncMutation.error)}
</div>
)}
</>
) : null}
</article>
</section>
<section className="panel">
<h2>{t("ume.tasks.currentTitle")}</h2>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}>
{t("ume.tasks.running")}: {runningTasks.length}
</span>
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
{t("common.refresh")}
<div className="panel__toolbar">
<h2>{t("ume.syncStatus.title")}</h2>
<button type="button" className="link-btn" onClick={() => setSyncStatusPanelOpen((x) => !x)}>
{syncStatusPanelOpen ? t("ume.syncStatus.hidePanel") : t("ume.syncStatus.showPanel")}
</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>domain</th>
<th>trigger_mode</th>
<th>status</th>
<th>started_at</th>
<th>error</th>
</tr>
</thead>
<tbody>
{runningTasks.map((x) => (
<tr key={`running-${x.id}`}>
<td>{x.id}</td>
<td>{x.domain}</td>
<td>{x.trigger_mode}</td>
<td>{x.status}</td>
<td>{formatSystemTime(x.started_at)}</td>
<td>{x.error_message || t("common.empty")}</td>
</tr>
))}
{!syncStatusQuery.isLoading && runningTasks.length === 0 && (
<tr>
<td colSpan={6}>{t("ume.tasks.noRunning")}</td>
</tr>
)}
</tbody>
</table>
<h3 style={{ marginTop: 12 }}>{t("ume.tasks.runtimeTitle")}</h3>
{runtimeTaskError ? (
<div className="pill pill--high" style={{ marginBottom: 8 }}>
{t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError}
</div>
) : null}
<table>
<thead>
<tr>
<th>task</th>
<th title={t("ume.tasks.intervalTitle")}>interval</th>
<th>status</th>
<th title={t("ume.tasks.lastRunTitle")}>last_run_at</th>
<th>{t("ume.tasks.lastErrorCol")}</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{runtimeTasks.map((x) => (
<tr key={`runtime-${x.task}`}>
<td title={x.task}>{runtimeTaskLabel(x.task)}</td>
<td title={typeof x.interval_s === "number" ? `${x.interval_s}s` : undefined}>
{runtimeIntervalLabel(x.interval_label)}
</td>
<td>{x.status}</td>
<td>{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}</td>
<td>{runtimeLastError(x.last_error, t) || t("common.empty")}</td>
<td>
{Boolean(x.paused) ? (
<button
type="button"
className="link-btn"
disabled={runtimeTaskMutation.isPending}
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
>
resume
</button>
) : (
<button
type="button"
className="link-btn"
disabled={runtimeTaskMutation.isPending}
onClick={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
>
pause
</button>
)}
</td>
</tr>
))}
{!syncStatusQuery.isLoading && runtimeTasks.length === 0 && (
<tr>
<td colSpan={6}>{t("ume.tasks.noRuntime")}</td>
</tr>
)}
</tbody>
</table>
</section>
<section className="panel">
<h2>{t("ume.syncStatus.title")}</h2>
{syncStatusPanelOpen ? (
<>
<div className="actions-row actions-row--inline">
<button onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} disabled={syncStatusQuery.isFetching}>
{t("common.refresh")}
@ -1249,6 +1228,8 @@ export function UmePage() {
</select>
</div>
</div>
</>
) : null}
</section>
<section className="panel">
@ -1476,6 +1457,7 @@ export function UmePage() {
</>
) : null}
</section>
</div>
{keyAlertEditRule ? (
<div className="modal-backdrop" role="presentation" onClick={() => setKeyAlertEditRule(null)}>
@ -1554,7 +1536,5 @@ export function UmePage() {
</div>
) : null}
</>
)}
</>
);
}