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

@ -16,6 +16,7 @@ import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CliConnectProfileItem, UmeCliOverrideItem } from "../types";
import { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange } from "../utils/hopProxy";
import { formatSystemTime } from "../utils/time";
function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
@ -36,9 +37,17 @@ type ProfileForm = {
device_type_default: string;
vendor_default: string;
is_default: boolean;
hop_enabled: boolean;
hop: HopProxyFieldsState;
};
function applyHopTemplate(hop: HopProxyFieldsState, protocol: string, vrf: string, force = false): Partial<HopProxyFieldsState> {
if (!force && !isAutoHopTemplate(hop.hop_command_template, hop.hop_vendor, hop.hop_protocol, hop.hop_vrf)) {
return {};
}
return { hop_command_template: defaultHopTemplate(hop.hop_vendor, protocol, vrf) };
}
const emptyForm = (): ProfileForm => ({
id: "",
name: "default",
@ -49,7 +58,8 @@ const emptyForm = (): ProfileForm => ({
device_type_default: "zte_zxros",
vendor_default: "ZTE",
is_default: true,
hop: { ...emptyHopProxyFields(), hop_vendor: "bastion", hop_port: 22 },
hop_enabled: false,
hop: emptyHopProxyFields(),
});
function profileToForm(row: CliConnectProfileItem): ProfileForm {
@ -63,6 +73,7 @@ function profileToForm(row: CliConnectProfileItem): ProfileForm {
device_type_default: row.device_type_default,
vendor_default: row.vendor_default,
is_default: row.is_default,
hop_enabled: row.hop_enabled,
hop: {
hop_vendor: (row.hop_vendor || "bastion") as HopProxyFieldsState["hop_vendor"],
hop_host: row.hop_host ?? "",
@ -77,7 +88,7 @@ function profileToForm(row: CliConnectProfileItem): ProfileForm {
};
}
export function UmeCliConnectPanel() {
export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabled?: boolean; embedded?: boolean }) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
@ -91,9 +102,9 @@ export function UmeCliConnectPanel() {
setTestPending(false);
}, [testNeId]);
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta });
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta });
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles });
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta, enabled });
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, enabled });
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles, enabled });
const overrideQuery = useQuery({
queryKey: queryKeys.umeCliOverride(testNeId),
queryFn: () => fetchUmeCliOverride(testNeId),
@ -118,6 +129,11 @@ export function UmeCliConnectPanel() {
const saveMutation = useMutation({
mutationFn: async () => {
if (form.hop_enabled) {
if (!form.hop.hop_host.trim()) throw new Error(t("managedNe.hop.hostRequired"));
if (!form.hop.hop_username.trim()) throw new Error(t("managedNe.hop.userRequired"));
if (!form.id && !form.hop.hop_password) throw new Error(t("managedNe.hop.passwordRequired"));
}
const body = {
name: form.name.trim(),
username: form.username.trim(),
@ -127,14 +143,13 @@ export function UmeCliConnectPanel() {
device_type_default: form.device_type_default,
vendor_default: form.vendor_default,
is_default: form.is_default,
hop_enabled:
form.hop.hop_vendor !== "linux" ? Boolean((form.hop.hop_host ?? "").trim()) : true,
hop_enabled: form.hop_enabled,
hop_vendor: form.hop.hop_vendor,
hop_host: form.hop.hop_host,
hop_port: form.hop.hop_port,
hop_protocol: form.hop.hop_protocol,
hop_username: form.hop.hop_username,
hop_password: form.hop.hop_password || undefined,
hop_password: form.hop_enabled ? form.hop.hop_password || undefined : undefined,
hop_command_template: form.hop.hop_command_template,
hop_vrf: form.hop.hop_vrf,
hop_target_auth_mode: form.hop.hop_target_auth_mode,
@ -185,16 +200,18 @@ export function UmeCliConnectPanel() {
const vendors = neMetaQuery.data?.vendors ?? [];
const cliReady = Boolean(metaQuery.data?.cli_profile_ready);
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.cli.title")}</h2>
<HelpHint text={t("ume.cli.hint")} />
</div>
const body = (
<div className={embedded ? "cli-connect-panel" : undefined}>
{!embedded ? (
<div className="panel__toolbar">
<h2>{t("ume.cli.title")}</h2>
<HelpHint text={t("ume.cli.hint")} />
</div>
) : null}
<p className="form-field-hint" style={{ marginBottom: 12 }}>
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
</p>
<div className="form-grid" style={{ maxWidth: 960 }}>
<div className="form-grid">
<label>
<span className="form-label">{t("ume.cli.profileName")}</span>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
@ -241,13 +258,43 @@ export function UmeCliConnectPanel() {
</select>
</label>
</div>
<HopProxyFields
value={form.hop}
onChange={(patch) => setForm((prev) => ({ ...prev, hop: { ...prev.hop, ...patch } }))}
hopPasswordRequired={!form.id}
hopPasswordOptional={Boolean(form.id)}
/>
<div className="filter-inline" style={{ marginTop: 16 }}>
<div className="form-fieldset" style={{ marginTop: 16 }}>
<div className="form-fieldset__title">{t("managedNe.hop.sectionTitle")}</div>
<label className="form-check">
<input
type="checkbox"
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
hop: {
...prev.hop,
...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
},
}
: {}),
}));
}}
/>
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
{form.hop_enabled ? (
<div className="hop-proxy-fields">
<HopProxyFields
value={form.hop}
onChange={(patch) => setForm((prev) => ({ ...prev, hop: { ...prev.hop, ...patch } }))}
hopPasswordRequired={!form.id}
hopPasswordOptional={Boolean(form.id)}
/>
</div>
) : null}
</div>
<div className="actions-row actions-row--inline" style={{ marginTop: 16 }}>
<button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
@ -279,14 +326,13 @@ export function UmeCliConnectPanel() {
</button>
) : null}
</div>
<div className="panel" style={{ marginTop: 20 }}>
<h3>{t("ume.cli.connectTestTitle")}</h3>
<div className="filter-inline">
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
<div className="cli-connect-test-row">
<input
value={sampleUmeNeId}
onChange={(e) => setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")}
style={{ minWidth: 320 }}
/>
<button type="button" onClick={() => connectTestMutation.mutate()} disabled={connectTestMutation.isPending}>
{connectTestMutation.isPending || overrideTesting
@ -335,7 +381,7 @@ export function UmeCliConnectPanel() {
{(profilesQuery.data?.items || []).length > 1 ? (
<div style={{ marginTop: 16 }}>
<span className="form-label">{t("ume.cli.existingProfiles")}</span>
<div className="filter-inline">
<div className="actions-row actions-row--inline">
{(profilesQuery.data?.items || []).map((p) => (
<button key={p.id} type="button" className="link-btn" onClick={() => setForm(profileToForm(p))}>
{p.name}
@ -345,6 +391,9 @@ export function UmeCliConnectPanel() {
</div>
</div>
) : null}
</section>
</div>
);
if (embedded) return body;
return <section className="panel">{body}</section>;
}

View file

@ -337,6 +337,8 @@ const en = {
},
keyAlert: {
title: "AI alarm monitor (OClaw WhatsApp)",
showPanel: "Expand",
hidePanel: "Collapse",
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.). Optionally restrict to UME inventory ne_type values (multi-select; empty = all devices). Pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.",
ws: "OClaw WSS",
wsConnected: "connected",
@ -455,6 +457,8 @@ const en = {
},
syncStatus: {
title: "Sync history",
showPanel: "Expand",
hidePanel: "Collapse",
empty: "No sync records",
},
ne: {
@ -473,6 +477,8 @@ const en = {
},
cli: {
title: "UME CLI connection profile",
showPanel: "Expand",
hidePanel: "Collapse",
hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.",
statusReady: "CLI profile is ready for UME NE command execution.",
statusNotReady: "Set target username and hop/bastion settings (if enabled).",

View file

@ -335,6 +335,8 @@ const zh = {
},
keyAlert: {
title: "AI 告警监控(OClaw WhatsApp)",
showPanel: "展开",
hidePanel: "收起",
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段);可选限定 UME Inventory 中的网元类型 (ne_type),不选则匹配全部设备。经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。",
ws: "OClaw WSS",
wsConnected: "已连接",
@ -453,6 +455,8 @@ const zh = {
},
syncStatus: {
title: "同步状态",
showPanel: "展开",
hidePanel: "收起",
empty: "暂无同步记录",
},
ne: {
@ -471,6 +475,8 @@ const zh = {
},
cli: {
title: "UME CLI 连接配置",
showPanel: "展开",
hidePanel: "收起",
hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。",
statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。",
statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",

View file

@ -355,13 +355,26 @@ button {
font-size: 16px;
}
.card input,
.card__section-title {
margin: 14px 0 8px;
font-size: 14px;
font-weight: 600;
}
.card input:not([type="checkbox"]):not([type="radio"]),
.card select,
.card textarea {
width: 100%;
margin-bottom: 8px;
}
.card input[type="checkbox"],
.card input[type="radio"] {
width: auto;
margin-bottom: 0;
flex: 0 0 auto;
}
.pager input,
.pager select {
width: auto;
@ -902,7 +915,11 @@ pre {
.page-stack {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.page-stack > .cards {
margin-bottom: 0;
}
.panel--warn {
@ -920,7 +937,8 @@ pre {
margin-bottom: 12px;
}
.panel__toolbar h2 {
.panel__toolbar h2,
.panel__toolbar h3 {
margin: 0;
}
@ -1008,6 +1026,104 @@ pre {
margin-top: 16px;
}
.cli-connect-panel {
width: 100%;
text-align: left;
}
.cli-connect-panel .form-grid,
.cli-connect-panel .form-fieldset {
width: 100%;
max-width: none;
}
.cli-connect-panel .form-grid label:not(.form-check) {
align-items: stretch;
text-align: left;
}
.cli-connect-panel .form-label {
display: block;
margin: 0;
text-align: left;
}
.cli-connect-panel .form-grid input,
.cli-connect-panel .form-grid select,
.cli-connect-panel .form-fieldset input,
.cli-connect-panel .form-fieldset select {
width: 100%;
margin: 0;
text-align: left;
}
.cli-connect-panel .form-fieldset {
text-align: left;
width: 100%;
min-width: 0;
min-inline-size: 0;
}
.cli-connect-panel .hop-proxy-fields,
.cli-connect-panel .hop-proxy-fields .form-grid {
width: 100%;
}
.cli-connect-panel .hop-proxy-fields .form-grid label {
align-items: flex-start;
justify-content: flex-start;
text-align: left;
width: 100%;
}
.cli-connect-panel .hop-proxy-fields .form-grid select,
.cli-connect-panel .hop-proxy-fields .form-grid input {
display: block;
width: 100%;
max-width: 100%;
margin: 0;
box-sizing: border-box;
text-align: left;
text-align-last: left;
}
.cli-connect-panel .hop-proxy-fields .form-field-hint {
text-align: left;
}
.cli-connect-panel .form-fieldset legend {
margin: 0 0 8px;
padding: 0 6px;
text-align: left;
}
.cli-connect-panel .form-check {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
max-width: 100%;
}
.card .cli-connect-panel input,
.card .cli-connect-panel select,
.card .cli-connect-panel textarea {
margin-bottom: 0;
}
.cli-connect-test-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.cli-connect-test-row input {
flex: 1 1 320px;
min-width: 240px;
margin-bottom: 0;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -1036,6 +1152,14 @@ pre {
line-height: 1.35;
}
.form-label {
display: block;
margin: 0;
font-size: 12px;
color: #64748b;
text-align: left;
}
.form-grid label {
display: flex;
flex-direction: column;
@ -1060,13 +1184,21 @@ pre {
border-radius: 8px;
}
.form-fieldset legend {
.form-fieldset legend,
.form-fieldset__title {
padding: 0 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.form-fieldset__title {
display: block;
margin: 0 0 10px;
padding: 0;
text-align: left;
}
.form-check {
display: flex;
flex-direction: row;
@ -1075,6 +1207,21 @@ pre {
margin-bottom: 12px;
font-size: 13px;
color: #334155;
width: fit-content;
max-width: 100%;
}
.form-check__text {
white-space: nowrap;
line-height: 1.4;
}
/* form-grid labels are column layout; checkbox rows must stay horizontal */
.form-grid label.form-check {
flex-direction: row;
align-items: center;
width: fit-content;
max-width: 100%;
}
.table-tag {

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}
</>
)}
</>
);
}