mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
39aad258e6
commit
775989cad4
6 changed files with 391 additions and 193 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
|
|
|
|||
|
|
@ -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: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue