Polish network shell and UME with HeroUI chips and denser layouts.

Refresh left-nav/main chrome, unify list status chips across network pages, and merge UME token with alarm subscription into one top card.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 00:13:24 +08:00
parent 0a4561f385
commit 41fd5b62a4
13 changed files with 927 additions and 589 deletions

View file

@ -66,16 +66,17 @@ body {
--nm-accent: #3b82f6; --nm-accent: #3b82f6;
--nm-accent-deep: #2563eb; --nm-accent-deep: #2563eb;
--nm-header: #060a12; --nm-header: #060a12;
--nm-chrome: #070b14; --nm-chrome: #080d18;
--nm-nav: #0c1220; --nm-main: #0a101c;
--nm-nav-border: #1e293b; --nm-nav: #101826;
--nm-nav-hover: #152033; --nm-nav-border: rgba(148, 163, 184, 0.14);
--nm-nav-active: #1a2740; --nm-nav-hover: rgba(59, 130, 246, 0.1);
--nm-nav-active: rgba(59, 130, 246, 0.16);
--nm-nav-ink: #e2e8f0; --nm-nav-ink: #e2e8f0;
--nm-nav-muted: #94a3b8; --nm-nav-muted: #94a3b8;
--nm-panel: rgba(15, 23, 42, 0.72); --nm-panel: rgba(15, 23, 42, 0.78);
--nm-panel-solid: #0f172a; --nm-panel-solid: #111a2b;
--nm-panel-border: #1e293b; --nm-panel-border: rgba(148, 163, 184, 0.16);
--nm-brand: #f1f5f9; --nm-brand: #f1f5f9;
--nm-text: #e2e8f0; --nm-text: #e2e8f0;
--nm-muted: #94a3b8; --nm-muted: #94a3b8;
@ -2145,10 +2146,12 @@ pre {
.ume-page, .ume-page,
.system-page { .system-page {
margin: -20px -28px -32px; margin: -20px -28px -32px;
padding: 16px 18px 24px; padding: 20px 22px 28px;
min-height: calc(100vh - 48px); min-height: calc(100vh - 48px);
background: var(--nm-chrome); background:
gap: 14px; radial-gradient(1200px 480px at 0% 0%, rgba(59, 130, 246, 0.07), transparent 55%),
var(--nm-main, var(--nm-chrome));
gap: 16px;
} }
/* Already inside network/audit shell — don't re-bleed or force full-viewport height /* Already inside network/audit shell — don't re-bleed or force full-viewport height
@ -2167,15 +2170,19 @@ pre {
.ume-page > .cards > .panel, .ume-page > .cards > .panel,
.ume-page > .cards > .card, .ume-page > .cards > .card,
.system-page > .panel { .system-page > .panel {
padding: 16px 18px 18px; padding: 18px 20px 20px;
border-color: var(--nm-panel-border); border: 1px solid var(--nm-panel-border);
box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); border-radius: 14px;
background: linear-gradient(180deg, rgba(22, 32, 50, 0.96), rgba(15, 23, 42, 0.92));
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 10px 28px rgba(0, 0, 0, 0.18);
} }
.ume-page > .cards { .ume-page > .cards {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 16px;
margin: 0; margin: 0;
grid-template-columns: none; grid-template-columns: none;
} }
@ -2185,7 +2192,7 @@ pre {
.system-page .panel__toolbar { .system-page .panel__toolbar {
margin-bottom: 14px; margin-bottom: 14px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid var(--nm-panel-border, #c8d2de); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
} }
.ne-page .panel__toolbar h2, .ne-page .panel__toolbar h2,
@ -2198,7 +2205,7 @@ pre {
font-size: 17px; font-size: 17px;
font-weight: 650; font-weight: 650;
color: var(--nm-brand); color: var(--nm-brand);
letter-spacing: -0.01em; letter-spacing: -0.02em;
margin: 0; margin: 0;
} }
@ -2219,12 +2226,111 @@ pre {
.ume-page .card__section-title { .ume-page .card__section-title {
margin: 16px 0 10px; margin: 16px 0 10px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--nm-panel-border, #c8d2de); border-top: 1px solid rgba(148, 163, 184, 0.14);
font-size: 14px; font-size: 14px;
font-weight: 650; font-weight: 650;
color: var(--nm-brand); color: var(--nm-brand);
} }
.ume-page .ume-status-row {
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.ume-conn-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 28px;
align-items: start;
}
.ume-conn-col {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.ume-conn-col .panel__toolbar {
margin-bottom: 0;
padding-bottom: 10px;
}
.ume-conn-col + .ume-conn-col {
padding-left: 28px;
border-left: 1px solid rgba(148, 163, 184, 0.14);
}
.ume-conn-panel .ume-ws-log {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid rgba(148, 163, 184, 0.14);
}
@media (max-width: 960px) {
.ume-conn-grid {
grid-template-columns: 1fr;
gap: 18px;
}
.ume-conn-col + .ume-conn-col {
padding-left: 0;
padding-top: 14px;
border-left: 0;
border-top: 1px solid rgba(148, 163, 184, 0.14);
}
}
.ume-page .pt-list-table-wrap {
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 12px;
overflow: hidden;
background: rgba(8, 13, 24, 0.28);
}
.ume-page .pt-list-table thead th {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ume-page .ume-ws-log {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ume-page .ume-ws-log__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px 12px;
}
.ume-page .ume-ws-log__head strong {
font-size: 13px;
color: var(--nm-brand);
}
.ume-page .ume-ws-log__head .muted {
font-size: 12px;
}
.ume-page .ws-log-panel {
margin-top: 0;
max-height: 180px;
border-radius: 10px;
background: rgba(8, 13, 24, 0.55);
}
.ume-page .error-text {
color: #fca5a5;
}
.ne-page .filter-inline, .ne-page .filter-inline,
.ume-page .filter-inline, .ume-page .filter-inline,
.system-page .filter-inline { .system-page .filter-inline {
@ -9236,62 +9342,82 @@ html.login-page--paused .login-page__flare {
} }
.network-nav { .network-nav {
width: 220px; width: 248px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--nm-nav); background:
linear-gradient(180deg, rgba(59, 130, 246, 0.06), transparent 120px),
var(--nm-nav);
border-right: 1px solid var(--nm-nav-border); border-right: 1px solid var(--nm-nav-border);
min-height: 0; min-height: 0;
color: var(--nm-text); color: var(--nm-text);
box-shadow: inset -1px 0 0 rgba(15, 27, 45, 0.04); box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
} }
.network-nav__scroll { .network-nav__scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 10px 0 16px; padding: 10px 10px 20px;
}
.network-nav__accordion.accordion {
width: 100%;
background: transparent;
border: 0;
border-radius: 0;
contain: none;
} }
.network-nav__group { .network-nav__group {
margin: 0 0 4px; margin: 0 0 6px;
border-radius: 10px;
}
.network-nav__group.is-active-group {
background: rgba(59, 130, 246, 0.05);
} }
.app-main .network-nav__group-toggle, .app-main .network-nav__group-toggle,
.network-nav__group-toggle { .network-nav__group-toggle,
.network-nav .accordion__trigger.network-nav__group-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
width: 100%; width: 100%;
padding: 9px 14px; padding: 10px 12px;
border: 0; border: 0;
border-radius: 0; border-radius: 10px;
background: transparent; background: transparent;
color: var(--nm-text); color: var(--nm-muted);
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 650;
letter-spacing: 0.02em; letter-spacing: 0.04em;
text-transform: uppercase;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.app-main .network-nav__group-toggle:hover, .app-main .network-nav__group-toggle:hover,
.network-nav__group-toggle:hover { .network-nav__group-toggle:hover,
.network-nav .accordion__trigger.network-nav__group-toggle:hover {
background: var(--nm-nav-hover); background: var(--nm-nav-hover);
color: var(--nm-brand); color: var(--nm-brand);
} }
.network-nav__group.is-active-group .network-nav__group-toggle { .network-nav__group.is-active-group .network-nav__group-toggle,
color: var(--nm-accent); .network-nav__group.is-active-group .accordion__trigger.network-nav__group-toggle {
color: #93c5fd;
} }
.network-nav__chevron { .network-nav__chevron,
display: inline-block; .network-nav .accordion__indicator.network-nav__chevron {
width: 1em; display: inline-flex;
color: #7b8b9c; width: 14px;
font-size: 11px; height: 14px;
line-height: 1; color: #64748b;
flex-shrink: 0;
} }
.network-nav__group-label { .network-nav__group-label {
@ -9299,11 +9425,19 @@ html.login-page--paused .login-page__flare {
min-width: 0; min-width: 0;
} }
.network-nav .accordion__body-inner.network-nav__panel-body,
.network-nav .network-nav__panel-body {
padding: 0 4px 8px !important;
color: inherit !important;
}
.network-nav__list { .network-nav__list {
list-style: none; list-style: none;
margin: 0; margin: 0;
/* Indent under group label (toggle pad + chevron + gap), not under the chevron */ padding: 0 4px;
padding: 0 8px 6px 34px; display: flex;
flex-direction: column;
gap: 2px;
} }
.network-nav__list > li { .network-nav__list > li {
@ -9314,13 +9448,17 @@ html.login-page--paused .login-page__flare {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
padding: 7px 12px; padding: 9px 12px;
border-radius: 6px; border-radius: 8px;
color: var(--nm-muted); color: var(--nm-muted);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
line-height: 1.35; line-height: 1.35;
border-left: 2px solid transparent; border-left: 0;
transition:
background 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
} }
.network-nav__link:hover { .network-nav__link:hover {
@ -9330,22 +9468,25 @@ html.login-page--paused .login-page__flare {
.network-nav__link.is-active { .network-nav__link.is-active {
background: var(--nm-nav-active); background: var(--nm-nav-active);
color: var(--nm-accent); color: #dbeafe;
font-weight: 600; font-weight: 600;
border-left-color: var(--nm-accent); box-shadow: inset 3px 0 0 var(--nm-accent);
} }
.network-nav__brand { .network-nav__brand {
padding: 14px 16px 10px; padding: 16px 18px 14px;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--nm-brand); color: var(--nm-brand);
border-bottom: 1px solid var(--nm-nav-border); border-bottom: 1px solid var(--nm-nav-border);
background: rgba(8, 13, 24, 0.35);
} }
.network-nav__list--flat { .network-nav__list--flat {
padding: 8px 10px 12px; padding: 4px;
gap: 2px;
} }
.audit-shell .network-main { .audit-shell .network-main {
@ -9357,40 +9498,52 @@ html.login-page--paused .login-page__flare {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 16px 18px 24px; padding: 20px 22px 28px;
background: var(--nm-chrome); background:
radial-gradient(1200px 480px at 0% 0%, rgba(59, 130, 246, 0.07), transparent 55%),
var(--nm-main, var(--nm-chrome));
} }
.network-main > .panel, .network-main > .panel,
.network-main .page-stack > .panel { .network-main .page-stack > .panel,
padding: 16px 18px 18px; .network-main > .nm-page-panel {
border-color: var(--nm-panel-border); padding: 18px 20px 20px;
box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); border: 1px solid var(--nm-panel-border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(22, 32, 50, 0.96), rgba(15, 23, 42, 0.92));
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 10px 28px rgba(0, 0, 0, 0.18);
} }
.network-main .panel__toolbar { .network-main .panel__toolbar {
margin-bottom: 14px; margin-bottom: 16px;
padding-bottom: 12px; padding-bottom: 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18); border-bottom: 1px solid rgba(148, 163, 184, 0.14);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
} }
.network-main .panel__toolbar h2 { .network-main .panel__toolbar h2 {
font-size: 17px; margin: 0;
font-size: 18px;
font-weight: 650; font-weight: 650;
color: var(--nm-brand); color: var(--nm-brand);
letter-spacing: -0.01em; letter-spacing: -0.02em;
} }
.network-main .filter-inline { .network-main .filter-inline {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 10px;
align-items: center; align-items: center;
margin-bottom: 0; margin-bottom: 0;
padding: 10px 12px; padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 8px; border-radius: 12px;
background: rgba(15, 23, 42, 0.55); background: rgba(8, 13, 24, 0.45);
grid-template-columns: none; grid-template-columns: none;
} }
@ -9400,13 +9553,13 @@ html.login-page--paused .login-page__flare {
min-width: 140px; min-width: 140px;
flex: 1 1 160px; flex: 1 1 160px;
max-width: 280px; max-width: 280px;
height: 32px; height: 34px;
margin: 0; margin: 0;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 6px; border-radius: 8px;
background: #0b1220; background: #0b1220;
color: #e2e8f0; color: #e2e8f0;
padding: 0 10px; padding: 0 12px;
font-size: 13px; font-size: 13px;
} }
@ -9421,35 +9574,63 @@ html.login-page--paused .login-page__flare {
.network-main .filter-inline > .ui-field > .ui-field__select { .network-main .filter-inline > .ui-field > .ui-field__select {
width: 100%; width: 100%;
height: 32px; height: 34px;
min-height: 32px; min-height: 34px;
max-height: 32px; max-height: 34px;
flex: none; flex: none;
margin: 0; margin: 0;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 6px; border-radius: 8px;
background: #0b1220; background: #0b1220;
color: #e2e8f0; color: #e2e8f0;
padding: 0 10px; padding: 0 12px;
font-size: 13px; font-size: 13px;
} }
.network-main .filter-inline button { .network-main .filter-inline button {
height: 32px; height: 34px;
flex: 0 0 auto; flex: 0 0 auto;
} }
.network-main .pt-list { .network-main .pt-list {
gap: 12px; gap: 14px;
} }
.network-main .pt-list-table-wrap { .network-main .pt-list-table-wrap {
border-color: rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 12px;
overflow: hidden;
background: rgba(8, 13, 24, 0.28);
}
.network-main .pt-list-table {
margin: 0;
} }
.network-main .pt-list-table thead th { .network-main .pt-list-table thead th {
background: rgba(15, 23, 42, 0.9); background: rgba(15, 23, 42, 0.92);
color: #94a3b8; color: #94a3b8;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
padding-top: 12px;
padding-bottom: 12px;
}
.network-main .pt-list-table tbody td {
padding-top: 11px;
padding-bottom: 11px;
border-bottom-color: rgba(148, 163, 184, 0.08);
}
.network-main .pt-list-table tbody tr:last-child td {
border-bottom: 0;
}
.network-main .pt-list-actions .button,
.network-main .table-actions .button {
min-height: 28px;
} }
.network-main .pager.pt-list-pager, .network-main .pager.pt-list-pager,
@ -9459,7 +9640,8 @@ html.login-page--paused .login-page__flare {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
margin-top: 2px; margin-top: 4px;
padding-top: 4px;
} }
.pt-port-updown { .pt-port-updown {
@ -9618,13 +9800,17 @@ html.login-page--paused .login-page__flare {
.ume-entry-grid { .ume-entry-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px; gap: 16px;
} }
.ume-page > .ume-entry-grid > .panel { .ume-page > .ume-entry-grid > .panel {
padding: 16px 18px 18px; padding: 16px 18px 18px;
border-color: var(--nm-panel-border); border: 1px solid var(--nm-panel-border);
box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); border-radius: 14px;
background: linear-gradient(180deg, rgba(22, 32, 50, 0.96), rgba(15, 23, 42, 0.92));
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 10px 28px rgba(0, 0, 0, 0.18);
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -9647,6 +9833,18 @@ html.login-page--paused .login-page__flare {
.ume-entry__pills { .ume-entry__pills {
margin: 0; margin: 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px;
}
.ume-entry__link {
color: #93c5fd;
text-decoration: none;
font-weight: 600;
}
.ume-entry__link:hover {
color: #bfdbfe;
text-decoration: underline;
} }
.ume-modal-form { .ume-modal-form {
@ -11037,8 +11235,8 @@ option {
.ne-page .filter-inline, .ne-page .filter-inline,
.ume-page .filter-inline, .ume-page .filter-inline,
.system-page .filter-inline { .system-page .filter-inline {
background: rgba(15, 23, 42, 0.55) !important; background: rgba(8, 13, 24, 0.45) !important;
border-color: rgba(148, 163, 184, 0.2) !important; border-color: rgba(148, 163, 184, 0.16) !important;
} }
.app-main .filter-inline input, .app-main .filter-inline input,
@ -11052,7 +11250,7 @@ option {
.system-page .filter-inline input, .system-page .filter-inline input,
.system-page .filter-inline select { .system-page .filter-inline select {
background: #0b1220 !important; background: #0b1220 !important;
border-color: rgba(148, 163, 184, 0.28) !important; border-color: rgba(148, 163, 184, 0.24) !important;
color: #e2e8f0 !important; color: #e2e8f0 !important;
} }
@ -11062,8 +11260,8 @@ option {
.ume-page .pt-list-table-wrap, .ume-page .pt-list-table-wrap,
.system-page .pt-list-table-wrap, .system-page .pt-list-table-wrap,
.pt-list-table-wrap { .pt-list-table-wrap {
border-color: rgba(148, 163, 184, 0.2) !important; border-color: rgba(148, 163, 184, 0.14) !important;
background: transparent; background: rgba(8, 13, 24, 0.28);
} }
.app-main .pt-list-table thead th, .app-main .pt-list-table thead th,
@ -11074,9 +11272,9 @@ option {
.pt-list-table thead th, .pt-list-table thead th,
.app-main .data-table thead th, .app-main .data-table thead th,
.data-table thead th { .data-table thead th {
background: rgba(15, 23, 42, 0.95) !important; background: rgba(15, 23, 42, 0.92) !important;
color: #94a3b8 !important; color: #94a3b8 !important;
border-bottom-color: rgba(148, 163, 184, 0.18); border-bottom-color: rgba(148, 163, 184, 0.14);
} }
.app-main .pt-list-table tbody tr:hover td, .app-main .pt-list-table tbody tr:hover td,

View file

@ -28,6 +28,7 @@ import { useToast } from "../hooks/useToast";
import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types"; import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types";
import { downloadCsv, fetchAllPages } from "../utils/csvExport"; import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import { jobChipColor, NmStatusChip, sourceChipColor } from "./network/nmChips";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000; const POLL_MS = 2000;
@ -458,7 +459,7 @@ export function CollectPage() {
return ( return (
<div className="page-stack"> <div className="page-stack">
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<div> <div>
<h2>{t("collect.jobs.title")}</h2> <h2>{t("collect.jobs.title")}</h2>
@ -542,7 +543,7 @@ export function CollectPage() {
</div> </div>
</section> </section>
<section className="panel" style={{ marginBottom: 16 }}> <section className="panel nm-page-panel" style={{ marginBottom: 16 }}>
<h3>{t("collect.policyTitle")}</h3> <h3>{t("collect.policyTitle")}</h3>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
{t("collect.policyHint")} {t("collect.policyHint")}
@ -703,7 +704,7 @@ export function CollectPage() {
) : null} ) : null}
</section> </section>
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<div> <div>
<h2>{t("collect.jobs.listTitle")}</h2> <h2>{t("collect.jobs.listTitle")}</h2>
@ -1146,7 +1147,9 @@ function JobRow({
return ( return (
<tr> <tr>
<td>{job.title}</td> <td>{job.title}</td>
<td>{job.status}</td> <td>
<NmStatusChip color={jobChipColor(job.status)}>{job.status}</NmStatusChip>
</td>
<td> <td>
{job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")} {job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")}
</td> </td>
@ -1368,11 +1371,15 @@ function JobRunsPanel({
{runs.map((run) => ( {runs.map((run) => (
<tr key={run.id}> <tr key={run.id}>
<td> <td>
<span className="table-tag">{run.ne_source || "managed"}</span> <NmStatusChip color={sourceChipColor(run.ne_source || "managed")}>
{run.ne_source || "managed"}
</NmStatusChip>
</td> </td>
<td>{run.ne_name}</td> <td>{run.ne_name}</td>
<td>{run.ne_ip}</td> <td>{run.ne_ip}</td>
<td>{run.status}</td> <td>
<NmStatusChip color={jobChipColor(run.status)}>{run.status}</NmStatusChip>
</td>
<td> <td>
{run.message ? ( {run.message ? (
<div className="collect-run-message" title={run.message}> <div className="collect-run-message" title={run.message}>

View file

@ -25,6 +25,7 @@ import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTas
import { downloadCsv, fetchAllPages } from "../utils/csvExport"; import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
import { jobChipColor, NmStatusChip, sourceChipColor } from "./network/nmChips";
const POLL_MS = 2500; const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 10; const TARGET_PAGE_SIZE = 10;
@ -353,7 +354,7 @@ export function ConfigSyncPage() {
}; };
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("configSync.title")}</h2> <h2>{t("configSync.title")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -549,7 +550,9 @@ export function ConfigSyncPage() {
onChange={() => toggleTarget(row)} onChange={() => toggleTarget(row)}
/> />
</td> </td>
<td>{source}</td> <td>
<NmStatusChip color={sourceChipColor(source)}>{source}</NmStatusChip>
</td>
<td>{row.name}</td> <td>{row.name}</td>
<td>{row.ip_address}</td> <td>{row.ip_address}</td>
<td>{row.vendor || "-"}</td> <td>{row.vendor || "-"}</td>
@ -664,21 +667,7 @@ export function ConfigSyncPage() {
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td> <td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td> <td>{c.trigger_mode}</td>
<td> <td>
<span <NmStatusChip color={jobChipColor(c.status)}>{c.status}</NmStatusChip>
className={`pt-list-status ${
c.status === "running" || c.status === "pending"
? "pt-list-status--running"
: c.status === "paused"
? "pt-list-status--paused"
: c.status === "failed"
? "pt-list-status--failed"
: c.status === "success" || c.status === "completed"
? "pt-list-status--ok"
: "pt-list-status--other"
}`}
>
{c.status}
</span>
</td> </td>
<td> <td>
{c.success_count}/{c.planned_count} · fail {c.fail_count} {c.success_count}/{c.planned_count} · fail {c.fail_count}
@ -690,7 +679,7 @@ export function ConfigSyncPage() {
{c.status === "running" || c.status === "pending" ? ( {c.status === "running" || c.status === "pending" ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={pauseMut.isPending} isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(c.id)} onPress={() => pauseMut.mutate(c.id)}
> >
@ -700,7 +689,7 @@ export function ConfigSyncPage() {
{c.status === "paused" ? ( {c.status === "paused" ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={resumeMut.isPending} isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(c.id)} onPress={() => resumeMut.mutate(c.id)}
> >
@ -823,8 +812,12 @@ export function ConfigSyncPage() {
<td>{task.ne_name || task.target_id}</td> <td>{task.ne_name || task.target_id}</td>
<td>{task.ne_ip}</td> <td>{task.ne_ip}</td>
<td>{task.vendor || "-"}</td> <td>{task.vendor || "-"}</td>
<td>{task.source}</td> <td>
<td>{task.status}</td> <NmStatusChip color={sourceChipColor(task.source)}>{task.source}</NmStatusChip>
</td>
<td>
<NmStatusChip color={jobChipColor(task.status)}>{task.status}</NmStatusChip>
</td>
<td title={task.message}>{task.message || "-"}</td> <td title={task.message}>{task.message || "-"}</td>
</tr> </tr>
))} ))}

View file

@ -34,6 +34,24 @@ import { pageCount, runtimeIntervalLabel } from "../utils/display";
import { runtimeLastError, wsConnectionLabel } from "../utils/runtimeMessages"; import { runtimeLastError, wsConnectionLabel } from "../utils/runtimeMessages";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
import { openOrFocusModule } from "../utils/moduleWindows"; import { openOrFocusModule } from "../utils/moduleWindows";
import { jobChipColor, NmStatusChip, type NmChipColor } from "./network/nmChips";
function tokenChipColor(opts: {
hasToken: boolean;
needsRenewal: boolean;
}): NmChipColor {
if (!opts.hasToken) return "danger";
if (opts.needsRenewal) return "warning";
return "success";
}
function pillLevelToChip(level: string | undefined): NmChipColor {
const s = String(level || "").toLowerCase();
if (s === "up" || s === "ok" || s === "pass") return "success";
if (s === "down" || s === "fail" || s === "error") return "danger";
if (s === "warn" || s === "warning" || s === "medium") return "warning";
return "default";
}
export function UmePage() { export function UmePage() {
const { t } = useI18n(); const { t } = useI18n();
@ -455,100 +473,255 @@ export function UmePage() {
<> <>
<div className="page-stack ume-page"> <div className="page-stack ume-page">
<section className="cards ume-cards"> <section className="cards ume-cards">
<article className="panel"> <article className="panel nm-page-panel ume-conn-panel">
<div className="panel__toolbar"> <div className="ume-conn-grid">
<h2>{t("ume.token.title")}</h2> <div className="ume-conn-col">
</div> <div className="panel__toolbar">
<div className="actions-row actions-row--inline"> <h2>{t("ume.token.title")}</h2>
<span
className={`conn-pill conn-pill--${!hasToken ? "down" : tokenNeedsRenewal ? "warn" : "up"}`}
title={tokenNeedsRenewal ? t("ume.token.renewHint") : undefined}
>
token:{" "}
{!hasToken
? t("ume.token.disconnected")
: tokenNeedsRenewal
? t("ume.token.connectedRenew")
: t("ume.token.connected")}
</span>
<span className={`conn-pill conn-pill--${tokenLevel}`}>
{t("ume.token.expiresIn")}:{" "}
{typeof tokenStatusQuery.data?.expires_in_s === "number"
? tokenNeedsRenewal
? t("ume.token.needsRenew")
: `${tokenStatusQuery.data.expires_in_s}s`
: t("common.empty")}
</span>
{tokenStatusQuery.data?.token_preview ? (
<span className="conn-pill">
{t("ume.token.preview")}: {tokenStatusQuery.data.token_preview}
</span>
) : null}
</div>
<div className="actions-row actions-row--inline">
<Button
size="sm"
variant="secondary"
isDisabled={tokenStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
>
{tokenStatusQuery.isFetching ? (
<>
<span className="inline-spinner" aria-hidden />
{t("common.refreshing")}
</>
) : (
t("ume.token.refreshStatus")
)}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenRefreshMutation.mutate()}
>
{tokenRefreshMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.token.renewing")}
</>
) : (
t("ume.token.renewLogin")
)}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenDisconnectMutation.mutate()}
>
{tokenDisconnectMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.token.disconnecting")}
</>
) : (
t("ume.token.disconnect")
)}
</Button>
</div>
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
<div className="pill pill--high">
{t("common.opFailed")}: {tokenOpError || String(tokenRefreshMutation.error || tokenDisconnectMutation.error)}
</div> </div>
)} <div className="actions-row actions-row--inline ume-status-row">
</article> <NmStatusChip color={tokenChipColor({ hasToken, needsRenewal: tokenNeedsRenewal })}>
<span title={tokenNeedsRenewal ? t("ume.token.renewHint") : undefined}>
<article className="panel"> token:{" "}
<div className="panel__toolbar"> {!hasToken
<h2>{t("ume.tasks.currentTitle")}</h2> ? t("ume.token.disconnected")
<div className="panel__toolbar-end"> : tokenNeedsRenewal
<span className={`conn-pill conn-pill--${runningTasks.length > 0 ? "unknown" : "up"}`}> ? t("ume.token.connectedRenew")
{t("ume.tasks.running")}: {runningTasks.length} : t("ume.token.connected")}
</span> </span>
</NmStatusChip>
<NmStatusChip color={pillLevelToChip(tokenLevel)}>
{t("ume.token.expiresIn")}:{" "}
{typeof tokenStatusQuery.data?.expires_in_s === "number"
? tokenNeedsRenewal
? t("ume.token.needsRenew")
: `${tokenStatusQuery.data.expires_in_s}s`
: t("common.empty")}
</NmStatusChip>
{tokenStatusQuery.data?.token_preview ? (
<NmStatusChip color="default">
{t("ume.token.preview")}: {tokenStatusQuery.data.token_preview}
</NmStatusChip>
) : null}
</div>
<div className="actions-row actions-row--inline">
<Button
size="sm"
variant="ghost"
isDisabled={tokenStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeTokenStatus })}
>
{tokenStatusQuery.isFetching ? (
<>
<span className="inline-spinner" aria-hidden />
{t("common.refreshing")}
</>
) : (
t("ume.token.refreshStatus")
)}
</Button>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenRefreshMutation.mutate()}
>
{tokenRefreshMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.token.renewing")}
</>
) : (
t("ume.token.renewLogin")
)}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={tokenRefreshMutation.isPending || tokenDisconnectMutation.isPending}
onPress={() => tokenDisconnectMutation.mutate()}
>
{tokenDisconnectMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.token.disconnecting")}
</>
) : (
t("ume.token.disconnect")
)}
</Button>
</div>
{(tokenOpError || tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
<div className="pill pill--high">
{t("common.opFailed")}: {tokenOpError || String(tokenRefreshMutation.error || tokenDisconnectMutation.error)}
</div>
)}
</div>
<div className="ume-conn-col">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.subscription.title")}
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
</h2>
</div>
<div className="actions-row actions-row--inline ume-status-row">
<NmStatusChip color={subscriptionActive ? "success" : "danger"}>
{t("ume.subscription.sub")}:{" "}
{subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")}
</NmStatusChip>
{subscriptionActive && alarmSub.subscription_id ? (
<NmStatusChip color="default" className="ume-chip-mono">
<span title={alarmSub.wss_uri || ""}>
id: {String(alarmSub.subscription_id).slice(0, 12)}…
</span>
</NmStatusChip>
) : null}
{subscriptionActive || wsConsumer ? (
<NmStatusChip color={pillLevelToChip(wsPillLevel)}>
<span
title={wsConn?.detail || runtimeLastError(wsConsumer?.last_error, t) || alarmSub.wss_uri || ""}
>
WSS: {wsLabel}
</span>
</NmStatusChip>
) : null}
</div>
{scheduledSyncSkipped && !serverSubLost ? (
<div className="pill pill--low">
{t("ume.subscription.wssModeBanner", { mode: currentAlarmsMode })}
</div>
) : null}
{serverSubLost ? (
<div className="pill pill--medium">
{t("ume.subscription.serverLostBanner")}
{serverSubLostReason ? (
<div className="muted" style={{ marginTop: 6, fontSize: 12, wordBreak: "break-word" }}>
{serverSubLostReason.slice(0, 280)}
</div>
) : null}
</div>
) : null}
<div className="actions-row actions-row--inline">
<Button
size="sm"
variant="secondary"
isDisabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken}
onPress={() => {
if (serverSubLost && subscriptionActive) {
if (window.confirm(t("ume.subscription.confirmReestablish"))) {
subscriptionEstablishMutation.mutate({ forceReestablish: true });
}
return;
}
subscriptionEstablishMutation.mutate(undefined);
}}
>
{subscriptionEstablishMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{serverSubLost ? t("ume.subscription.reestablishing") : t("ume.subscription.establishing")}
</>
) : serverSubLost ? (
t("ume.subscription.reestablish")
) : (
t("ume.subscription.establish")
)}
</Button>
{serverSubLost && subscriptionActive ? (
<Button
size="sm"
variant="ghost"
isDisabled={subPending}
onPress={() => {
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate();
}}
>
{subscriptionClearLocalMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.subscription.clearing")}
</>
) : (
t("ume.subscription.clearLocalOnly")
)}
</Button>
) : null}
<Button
size="sm"
variant="danger"
isDisabled={subPending || !subscriptionActive}
onPress={() => subscriptionCancelMutation.mutate(undefined)}
>
{subscriptionCancelMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.subscription.cancelling")}
</>
) : (
t("ume.subscription.cancel")
)}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={subscriptionStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
>
{t("ume.subscription.refreshStatus")}
</Button>
</div>
{subscriptionOpError ? (
<div className="pill pill--high">
{t("ume.subscription.opFailed")}: {subscriptionOpError}
</div>
) : null}
</div>
</div>
<div className="ume-ws-log">
<div className="ume-ws-log__head">
<strong>{t("ume.subscription.wsLogs")}</strong>
<span className="muted">
{wsConsumer?.last_run_at
? t("ume.subscription.wsActivity", { time: formatSystemTime(String(wsConsumer.last_run_at)) })
: t("ume.subscription.wsAutoRefresh")}
</span>
</div>
<div className="ws-log-panel" role="log" aria-live="polite">
{wsLogs.length === 0 ? (
<div className="ws-log-line ws-log-line--info">{t("ume.subscription.wsLogsEmpty")}</div>
) : (
wsLogs.map((line, idx) => {
const lvl = String(line.level || "info").toLowerCase();
const cls =
lvl === "error" ? "ws-log-line--error" : lvl === "warning" ? "ws-log-line--warning" : "ws-log-line--info";
return (
<div key={`${line.ts}-${idx}`} className={`ws-log-line ${cls}`}>
<span className="ws-log-ts">{formatSystemTime(line.ts)}</span>
<span>[{lvl}] </span>
<span>{line.message}</span>
{line.subscription_id ? (
<span className="muted"> · {String(line.subscription_id).slice(0, 8)}…</span>
) : null}
</div>
);
})
)}
</div>
</div>
</article>
<article className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.tasks.currentTitle")}</h2>
<div className="panel__toolbar-end">
<NmStatusChip color={runningTasks.length > 0 ? "accent" : "success"}>
{t("ume.tasks.running")}: {runningTasks.length}
</NmStatusChip>
<Button
size="sm"
variant="ghost"
isDisabled={syncStatusQuery.isFetching} isDisabled={syncStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })} onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll })}
> >
@ -583,245 +756,106 @@ export function UmePage() {
</div> </div>
) : null} ) : null}
<div className="pt-list"> <div className="pt-list">
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>domain</th> <th>domain</th>
<th>trigger_mode</th> <th>trigger_mode</th>
<th>status</th> <th>status</th>
<th>started_at</th> <th>started_at</th>
<th>error</th> <th>error</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{runningTasks.map((x) => ( {runningTasks.map((x) => (
<tr key={`running-${x.id}`}> <tr key={`running-${x.id}`}>
<td>{x.id}</td> <td>{x.id}</td>
<td>{x.domain}</td> <td>{x.domain}</td>
<td>{x.trigger_mode}</td> <td>{x.trigger_mode}</td>
<td>{x.status}</td> <td>
<td>{formatSystemTime(x.started_at)}</td> <NmStatusChip color={jobChipColor(x.status)}>{x.status}</NmStatusChip>
<td>{x.error_message || t("common.empty")}</td> </td>
</tr> <td>{formatSystemTime(x.started_at)}</td>
))} <td className={x.error_message ? "error-text" : undefined}>
{!syncStatusQuery.isLoading && runningTasks.length === 0 && ( {x.error_message || t("common.empty")}
<tr> </td>
<td colSpan={6}>{t("ume.tasks.noRunning")}</td> </tr>
</tr> ))}
)} {!syncStatusQuery.isLoading && runningTasks.length === 0 && (
</tbody> <tr>
</table> <td colSpan={6}>{t("ume.tasks.noRunning")}</td>
</div> </tr>
)}
</tbody>
</table>
</div>
<h3 className="card__section-title">{t("ume.tasks.runtimeTitle")}</h3> <h3 className="card__section-title">{t("ume.tasks.runtimeTitle")}</h3>
{runtimeTaskError ? ( {runtimeTaskError ? (
<div className="pill pill--high" style={{ marginBottom: 8 }}> <div className="pill pill--high" style={{ marginBottom: 8 }}>
{t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError} {t("ume.tasks.runtimeOpFailed")}: {runtimeTaskError}
</div> </div>
) : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-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
size="sm"
variant="ghost"
className="link-btn"
isDisabled={runtimeTaskMutation.isPending}
onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
>
resume
</Button>
) : (
<Button
size="sm"
variant="ghost"
className="link-btn"
isDisabled={runtimeTaskMutation.isPending}
onPress={() => 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>
</div>
</div>
</article>
<article className="panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("ume.subscription.title")}
<HelpHint text={t("ume.subscription.help")} ariaLabel={t("common.help")} />
</h2>
</div>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
{t("ume.subscription.sub")}: {subscriptionActive ? t("ume.subscription.subActive") : t("ume.subscription.subInactive")}
</span>
{subscriptionActive && alarmSub.subscription_id ? (
<span className="conn-pill" title={alarmSub.wss_uri || ""}>
id: {String(alarmSub.subscription_id).slice(0, 12)}…
</span>
) : null} ) : null}
{subscriptionActive || wsConsumer ? ( <div className="pt-list-table-wrap">
<span <table className="data-table pt-list-table">
className={`conn-pill conn-pill--${wsPillLevel}`} <thead>
title={wsConn?.detail || runtimeLastError(wsConsumer?.last_error, t) || alarmSub.wss_uri || ""} <tr>
> <th>task</th>
WSS: {wsLabel} <th title={t("ume.tasks.intervalTitle")}>interval</th>
</span> <th>status</th>
) : null} <th title={t("ume.tasks.lastRunTitle")}>last_run_at</th>
</div> <th>{t("ume.tasks.lastErrorCol")}</th>
{scheduledSyncSkipped && !serverSubLost ? ( <th>actions</th>
<div className="pill pill--low" style={{ marginTop: 8 }}> </tr>
{t("ume.subscription.wssModeBanner", { mode: currentAlarmsMode })} </thead>
</div> <tbody>
) : null} {runtimeTasks.map((x) => (
{serverSubLost ? ( <tr key={`runtime-${x.task}`}>
<div className="pill pill--medium" style={{ marginTop: 8 }}> <td className="pt-list-task-name" title={x.task}>
{t("ume.subscription.serverLostBanner")} {runtimeTaskLabel(x.task)}
{serverSubLostReason ? ( </td>
<div className="muted" style={{ marginTop: 6, fontSize: 12, wordBreak: "break-word" }}> <td title={typeof x.interval_s === "number" ? `${x.interval_s}s` : undefined}>
{serverSubLostReason.slice(0, 280)} {runtimeIntervalLabel(x.interval_label)}
</div> </td>
) : null} <td>
</div> <NmStatusChip color={jobChipColor(x.status)}>{x.status}</NmStatusChip>
) : null} </td>
<div className="actions-row actions-row--inline"> <td>{x.last_run_at ? formatSystemTime(x.last_run_at) : t("common.empty")}</td>
<Button <td className={x.last_error ? "error-text" : undefined}>
size="sm" {runtimeLastError(x.last_error, t) || t("common.empty")}
variant="secondary" </td>
isDisabled={subPending || (!serverSubLost && subscriptionActive) || !hasToken} <td>
onPress={() => { {Boolean(x.paused) ? (
if (serverSubLost && subscriptionActive) { <Button
if (window.confirm(t("ume.subscription.confirmReestablish"))) { size="sm"
subscriptionEstablishMutation.mutate({ forceReestablish: true }); variant="ghost"
} isDisabled={runtimeTaskMutation.isPending}
return; onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "resume" })}
} >
subscriptionEstablishMutation.mutate(undefined); resume
}} </Button>
> ) : (
{subscriptionEstablishMutation.isPending ? ( <Button
<> size="sm"
<span className="inline-spinner" aria-hidden /> variant="ghost"
{serverSubLost ? t("ume.subscription.reestablishing") : t("ume.subscription.establishing")} isDisabled={runtimeTaskMutation.isPending}
</> onPress={() => runtimeTaskMutation.mutate({ task: x.task, action: "pause" })}
) : serverSubLost ? ( >
t("ume.subscription.reestablish") pause
) : ( </Button>
t("ume.subscription.establish") )}
)} </td>
</Button> </tr>
{serverSubLost && subscriptionActive ? ( ))}
<Button {!syncStatusQuery.isLoading && runtimeTasks.length === 0 && (
size="sm" <tr>
variant="secondary" <td colSpan={6}>{t("ume.tasks.noRuntime")}</td>
isDisabled={subPending} </tr>
onPress={() => { )}
if (confirmClearLocalSubscription()) subscriptionClearLocalMutation.mutate(); </tbody>
}} </table>
>
{subscriptionClearLocalMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.subscription.clearing")}
</>
) : (
t("ume.subscription.clearLocalOnly")
)}
</Button>
) : null}
<Button
size="sm"
variant="danger"
isDisabled={subPending || !subscriptionActive}
onPress={() => subscriptionCancelMutation.mutate(undefined)}
>
{subscriptionCancelMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
{t("ume.subscription.cancelling")}
</>
) : (
t("ume.subscription.cancel")
)}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={subscriptionStatusQuery.isFetching}
onPress={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeAlarmSubscription })}
>
{t("ume.subscription.refreshStatus")}
</Button>
</div>
{subscriptionOpError ? (
<div className="pill pill--high">
{t("ume.subscription.opFailed")}: {subscriptionOpError}
</div>
) : null}
<div style={{ marginTop: 12 }}>
<div className="actions-row actions-row--inline" style={{ marginTop: 0 }}>
<strong style={{ fontSize: 13 }}>{t("ume.subscription.wsLogs")}</strong>
<span className="muted" style={{ fontSize: 12 }}>
{wsConsumer?.last_run_at
? t("ume.subscription.wsActivity", { time: formatSystemTime(String(wsConsumer.last_run_at)) })
: t("ume.subscription.wsAutoRefresh")}
</span>
</div>
<div className="ws-log-panel" role="log" aria-live="polite">
{wsLogs.length === 0 ? (
<div className="ws-log-line ws-log-line--info">{t("ume.subscription.wsLogsEmpty")}</div>
) : (
wsLogs.map((line, idx) => {
const lvl = String(line.level || "info").toLowerCase();
const cls =
lvl === "error" ? "ws-log-line--error" : lvl === "warning" ? "ws-log-line--warning" : "ws-log-line--info";
return (
<div key={`${line.ts}-${idx}`} className={`ws-log-line ${cls}`}>
<span className="ws-log-ts">{formatSystemTime(line.ts)}</span>
<span>[{lvl}] </span>
<span>{line.message}</span>
{line.subscription_id ? (
<span className="muted"> · {String(line.subscription_id).slice(0, 8)}…</span>
) : null}
</div>
);
})
)}
</div> </div>
</div> </div>
</article> </article>
@ -829,52 +863,52 @@ export function UmePage() {
</section> </section>
<section className="ume-entry-grid"> <section className="ume-entry-grid">
<article className="panel ume-entry"> <article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2 className="card-title-with-hint"> <h2 className="card-title-with-hint">
{t("ume.cli.title")} {t("ume.cli.title")}
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} /> <HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
</h2> </h2>
<Button size="sm" variant="secondary" onPress={() => setCliPanelOpen(true)}> <Button size="sm" variant="ghost" onPress={() => setCliPanelOpen(true)}>
{t("ume.cli.showPanel")} {t("ume.cli.showPanel")}
</Button> </Button>
</div> </div>
<p className="ume-entry__summary muted">{t("ume.cli.hint")}</p> <p className="ume-entry__summary muted">{t("ume.cli.hint")}</p>
</article> </article>
<article className="panel ume-entry"> <article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2 className="card-title-with-hint"> <h2 className="card-title-with-hint">
{t("ume.keyAlert.title")} {t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} /> <HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h2> </h2>
<Button size="sm" variant="secondary" onPress={() => setKeyAlertPanelOpen(true)}> <Button size="sm" variant="ghost" onPress={() => setKeyAlertPanelOpen(true)}>
{t("ume.keyAlert.showPanel")} {t("ume.keyAlert.showPanel")}
</Button> </Button>
</div> </div>
<div className="ume-entry__pills actions-row actions-row--inline"> <div className="ume-entry__pills actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${hubWsPill}`}> <NmStatusChip color={pillLevelToChip(hubWsPill)}>
{t("ume.keyAlert.hub")}:{" "} {t("ume.keyAlert.hub")}:{" "}
{keyAlertHubSubscribers > 0 {keyAlertHubSubscribers > 0
? t("ume.keyAlert.hubConnected") ? t("ume.keyAlert.hubConnected")
: t("ume.keyAlert.hubEmpty")} : t("ume.keyAlert.hubEmpty")}
</span> </NmStatusChip>
<span className="conn-pill"> <NmStatusChip color="default">
{t("ume.keyAlert.hubSubscribers")}: {keyAlertHubSubscribers} {t("ume.keyAlert.hubSubscribers")}: {keyAlertHubSubscribers}
</span> </NmStatusChip>
<span className="conn-pill"> <NmStatusChip color="default">
{t("ume.keyAlert.hubPublished")}: {Number(keyAlertHub?.published || 0)} {t("ume.keyAlert.hubPublished")}: {Number(keyAlertHub?.published || 0)}
</span> </NmStatusChip>
<span className="conn-pill"> <NmStatusChip color="default">
{t("ume.keyAlert.hubDeliverOk")}: {Number(keyAlertHub?.deliver_ok || 0)} {t("ume.keyAlert.hubDeliverOk")}: {Number(keyAlertHub?.deliver_ok || 0)}
</span> </NmStatusChip>
</div> </div>
</article> </article>
<article className="panel ume-entry"> <article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.syncStatus.title")}</h2> <h2>{t("ume.syncStatus.title")}</h2>
<Button size="sm" variant="secondary" onPress={() => setSyncStatusPanelOpen(true)}> <Button size="sm" variant="ghost" onPress={() => setSyncStatusPanelOpen(true)}>
{t("ume.syncStatus.showPanel")} {t("ume.syncStatus.showPanel")}
</Button> </Button>
</div> </div>
@ -888,10 +922,10 @@ export function UmePage() {
</p> </p>
</article> </article>
<article className="panel ume-entry"> <article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.ne.title")}</h2> <h2>{t("ume.ne.title")}</h2>
<Button size="sm" variant="secondary" onPress={() => setNePanelOpen(true)}> <Button size="sm" variant="ghost" onPress={() => setNePanelOpen(true)}>
{t("ume.ne.showPanel")} {t("ume.ne.showPanel")}
</Button> </Button>
</div> </div>
@ -900,13 +934,14 @@ export function UmePage() {
</p> </p>
</article> </article>
<article className="panel ume-entry"> <article className="panel ume-entry nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2> <h2>{t("ume.alarms.title")}</h2>
</div> </div>
<p className="ume-entry__summary muted"> <p className="ume-entry__summary muted">
{t("ume.alarms.movedHint")}{" "} {t("ume.alarms.movedHint")}{" "}
<a <a
className="ume-entry__link"
href="/network/alarms" href="/network/alarms"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -1359,7 +1394,9 @@ export function UmePage() {
{String(x.id).slice(0, 8)} {String(x.id).slice(0, 8)}
</td> </td>
<td>{x.domain}</td> <td>{x.domain}</td>
<td>{x.status}</td> <td>
<NmStatusChip color={jobChipColor(x.status)}>{x.status}</NmStatusChip>
</td>
<td>{x.pulled_count}</td> <td>{x.pulled_count}</td>
<td>{x.inserted_count}</td> <td>{x.inserted_count}</td>
<td>{x.updated_count}</td> <td>{x.updated_count}</td>

View file

@ -33,6 +33,12 @@ import type {
import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
import {
edgeChipColor,
jobChipColor,
NmStatusChip,
sourceChipColor,
} from "./nmChips";
const POLL_MS = 2500; const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 10; const TARGET_PAGE_SIZE = 10;
@ -553,7 +559,7 @@ export function LldpLinksPage() {
}; };
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("lldpLinks.title")}</h2> <h2>{t("lldpLinks.title")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -776,7 +782,9 @@ export function LldpLinksPage() {
onChange={() => toggleTarget(row)} onChange={() => toggleTarget(row)}
/> />
</td> </td>
<td>{source}</td> <td>
<NmStatusChip color={sourceChipColor(source)}>{source}</NmStatusChip>
</td>
<td>{row.name}</td> <td>{row.name}</td>
<td>{row.ip_address}</td> <td>{row.ip_address}</td>
<td>{row.vendor || "-"}</td> <td>{row.vendor || "-"}</td>
@ -880,7 +888,9 @@ export function LldpLinksPage() {
const st = e.status === "stale" ? "missing" : e.status; const st = e.status === "stale" ? "missing" : e.status;
return ( return (
<tr key={e.id}> <tr key={e.id}>
<td>{lldpEdgeStatusLabel(t, st)}</td> <td>
<NmStatusChip color={edgeChipColor(st)}>{lldpEdgeStatusLabel(t, st)}</NmStatusChip>
</td>
<td> <td>
<div>{aLabel}</div> <div>{aLabel}</div>
{e.a_ip && e.a_name ? <div className="muted">{e.a_ip}</div> : null} {e.a_ip && e.a_name ? <div className="muted">{e.a_ip}</div> : null}
@ -891,7 +901,11 @@ export function LldpLinksPage() {
{e.b_ip && e.b_name ? <div className="muted">{e.b_ip}</div> : null} {e.b_ip && e.b_name ? <div className="muted">{e.b_ip}</div> : null}
</td> </td>
<td>{e.b_port || "—"}</td> <td>{e.b_port || "—"}</td>
<td>{lldpEdgeSourceLabel(t, e.source)}</td> <td>
<NmStatusChip color={sourceChipColor(e.source)}>
{lldpEdgeSourceLabel(t, e.source)}
</NmStatusChip>
</td>
<td> <td>
{missCount || (st === "missing" ? 1 : 0)} {missCount || (st === "missing" ? 1 : 0)}
{replaced ? ( {replaced ? (
@ -1003,17 +1017,9 @@ export function LldpLinksPage() {
<td>{lldpTriggerLabel(t, job.trigger_mode)}</td> <td>{lldpTriggerLabel(t, job.trigger_mode)}</td>
<td>{lldpScopeLabel(t, job.scope)}</td> <td>{lldpScopeLabel(t, job.scope)}</td>
<td> <td>
<span <NmStatusChip color={jobChipColor(job.status)}>
className={
job.status === "running" || job.status === "pending"
? "pt-list-status--running"
: job.status === "paused"
? "pt-list-status--paused"
: undefined
}
>
{lldpJobStatusLabel(t, job.status)} {lldpJobStatusLabel(t, job.status)}
</span> </NmStatusChip>
</td> </td>
<td> <td>
{job.done}/{job.total} {job.done}/{job.total}
@ -1034,7 +1040,7 @@ export function LldpLinksPage() {
{job.status === "running" || job.status === "pending" ? ( {job.status === "running" || job.status === "pending" ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={pauseMut.isPending} isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(job.id)} onPress={() => pauseMut.mutate(job.id)}
> >
@ -1044,7 +1050,7 @@ export function LldpLinksPage() {
{job.status === "paused" ? ( {job.status === "paused" ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={resumeMut.isPending} isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(job.id)} onPress={() => resumeMut.mutate(job.id)}
> >
@ -1170,7 +1176,23 @@ export function LldpLinksPage() {
<tr key={it.id}> <tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td> <td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td> <td>{it.ne_ip || "—"}</td>
<td>{lldpItemResultLabel(t, it)}</td> <td>
<NmStatusChip
color={
!it.ok ||
Boolean(it.parser_stub) ||
["parser_stub", "empty_cli_output", "vendor_or_device_type_required"].includes(
String(it.error || "").trim(),
)
? "danger"
: (it.unmatched_count ?? (it.unmatched?.length || 0)) > 0
? "warning"
: "success"
}
>
{lldpItemResultLabel(t, it)}
</NmStatusChip>
</td>
<td>{it.neighbors}</td> <td>{it.neighbors}</td>
<td> <td>
+{it.edges_added} / ~{it.edges_updated} +{it.edges_added} / ~{it.edges_updated}

View file

@ -13,16 +13,7 @@ import type { UmeAlarmItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
import { NmStatusChip, severityChipColor } from "./nmChips";
function severityClass(sev: string | null | undefined): string {
const s = String(sev || "").trim().toLowerCase();
if (s.includes("critical")) return "pt-list-status--critical";
if (s.includes("major")) return "pt-list-status--major";
if (s.includes("minor")) return "pt-list-status--minor";
if (s.includes("warning") || s.includes("warn")) return "pt-list-status--warning";
if (s.includes("info") || s.includes("indeterminate")) return "pt-list-status--info";
return "pt-list-status--unknown";
}
/** Current UME alarms query view for Network Management. */ /** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() { export function NetworkAlarmsPage() {
@ -133,7 +124,7 @@ export function NetworkAlarmsPage() {
}; };
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2> <h2>{t("ume.alarms.title")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -233,9 +224,9 @@ export function NetworkAlarmsPage() {
<tr key={x.alarm_key}> <tr key={x.alarm_key}>
<td className="pt-list-time">{formatSystemTime(x.time_created)}</td> <td className="pt-list-time">{formatSystemTime(x.time_created)}</td>
<td> <td>
<span className={`pt-list-status ${severityClass(x.perceived_severity)}`}> <NmStatusChip color={severityChipColor(x.perceived_severity)}>
{x.perceived_severity || "—"} {x.perceived_severity || "—"}
</span> </NmStatusChip>
</td> </td>
<td className="pt-list-num">{x.ne_id}</td> <td className="pt-list-num">{x.ne_id}</td>
<td> <td>

View file

@ -20,6 +20,7 @@ import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows"; import { openNewModuleWindow } from "../../utils/moduleWindows";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
import { NmStatusChip, sourceChipColor } from "./nmChips";
function fmtBytes(n: number): string { function fmtBytes(n: number): string {
if (!n) return "0 B"; if (!n) return "0 B";
@ -149,7 +150,7 @@ export function NetworkConfigsPage() {
}; };
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2> <h2>{t("networkConfigs.title")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -237,7 +238,9 @@ export function NetworkConfigsPage() {
<td className="pt-list-task-name">{row.ne_name || row.target_id}</td> <td className="pt-list-task-name">{row.ne_name || row.target_id}</td>
<td className="pt-list-num">{row.ne_ip}</td> <td className="pt-list-num">{row.ne_ip}</td>
<td>{row.vendor || "—"}</td> <td>{row.vendor || "—"}</td>
<td>{row.source}</td> <td>
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
</td>
<td className="pt-list-num">{fmtBytes(row.plain_size)}</td> <td className="pt-list-num">{fmtBytes(row.plain_size)}</td>
<td className="pt-list-time"> <td className="pt-list-time">
{row.collected_at ? formatSystemTime(row.collected_at) : "—"} {row.collected_at ? formatSystemTime(row.collected_at) : "—"}
@ -246,7 +249,7 @@ export function NetworkConfigsPage() {
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
onPress={() => { onPress={() => {
setSelected({ source: row.source, id: row.target_id }); setSelected({ source: row.source, id: row.target_id });
setTab("primary"); setTab("primary");
@ -256,7 +259,7 @@ export function NetworkConfigsPage() {
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={ isDisabled={
exporting === exportKey || exporting === exportKey ||
exporting.startsWith(`${row.source}:${row.target_id}:`) exporting.startsWith(`${row.source}:${row.target_id}:`)
@ -273,7 +276,7 @@ export function NetworkConfigsPage() {
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
onPress={() => { onPress={() => {
const path = const path =
row.source === "ume" row.source === "ume"

View file

@ -12,16 +12,7 @@ import type { CliTargetItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows"; import { openNewModuleWindow } from "../../utils/moduleWindows";
import { connectChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
function connectStatusClass(status: string | null | undefined): string {
const s = String(status || "").trim().toLowerCase();
if (!s || s === "-" || s === "unknown") return "pt-list-status--unknown";
if (s.includes("fail") || s.includes("down") || s.includes("error")) return "pt-list-status--down";
if (s.includes("ok") || s.includes("up") || s.includes("connected") || s.includes("success")) {
return "pt-list-status--ok";
}
return "pt-list-status--other";
}
function openWebcrt(row: CliTargetItem) { function openWebcrt(row: CliTargetItem) {
const path = const path =
@ -89,7 +80,7 @@ export function NetworkDevicesPage() {
}; };
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2> <h2>{t("networkDevices.title")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -163,24 +154,26 @@ export function NetworkDevicesPage() {
<tbody> <tbody>
{items.map((row) => ( {items.map((row) => (
<tr key={`${row.source}:${row.id}`}> <tr key={`${row.source}:${row.id}`}>
<td>{row.source}</td> <td>
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
</td>
<td className="pt-list-task-name">{row.name || row.id}</td> <td className="pt-list-task-name">{row.name || row.id}</td>
<td className="pt-list-num">{row.ip_address || "—"}</td> <td className="pt-list-num">{row.ip_address || "—"}</td>
<td>{row.vendor || "—"}</td> <td>{row.vendor || "—"}</td>
<td>{row.device_type || row.ne_type || "—"}</td> <td>{row.device_type || row.ne_type || "—"}</td>
<td> <td>
<span className={`pt-list-status ${connectStatusClass(row.connect_status)}`}> <NmStatusChip color={connectChipColor(row.connect_status)}>
{row.connect_status || "—"} {row.connect_status || "—"}
</span> </NmStatusChip>
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<Button size="sm" variant="secondary" onPress={() => openWebcrt(row)}> <Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
WebCRT WebCRT
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
onPress={() => onPress={() =>
openNewModuleWindow({ openNewModuleWindow({
moduleId: "network", moduleId: "network",

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { Accordion } from "@heroui/react";
import { useEffect, useMemo, useState, type Key } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav"; import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
@ -11,76 +12,89 @@ function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolea
); );
} }
function groupsForPath(pathname: string): Set<Key> {
const keys = new Set<Key>();
for (const g of NETWORK_NAV) {
if (groupContainsPath(g.id, pathname)) keys.add(g.id);
}
return keys;
}
export function NetworkLayout() { export function NetworkLayout() {
const { t } = useI18n(); const { t } = useI18n();
const { pathname } = useLocation(); const { pathname } = useLocation();
const [openGroups, setOpenGroups] = useState<Record<NetworkNavGroupId, boolean>>(() => { const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(() => groupsForPath(pathname));
const init = {} as Record<NetworkNavGroupId, boolean>;
for (const g of NETWORK_NAV) {
// Default collapsed; only the group owning the current route starts open.
init[g.id] = groupContainsPath(g.id, pathname);
}
return init;
});
useEffect(() => { useEffect(() => {
for (const g of NETWORK_NAV) { setExpandedKeys((prev) => {
if (groupContainsPath(g.id, pathname)) { const routeKeys = groupsForPath(pathname);
setOpenGroups((prev) => (prev[g.id] ? prev : { ...prev, [g.id]: true })); if ([...routeKeys].every((k) => prev.has(k))) return prev;
} const next = new Set(prev);
} for (const k of routeKeys) next.add(k);
return next;
});
}, [pathname]); }, [pathname]);
const toggleGroup = (id: NetworkNavGroupId) => { const activeGroupIds = useMemo(() => {
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] })); const ids = new Set<NetworkNavGroupId>();
}; for (const g of NETWORK_NAV) {
if (groupContainsPath(g.id, pathname)) ids.add(g.id);
}
return ids;
}, [pathname]);
return ( return (
<div className="network-shell"> <div className="network-shell">
<aside className="network-nav" aria-label={t("network.title")}> <aside className="network-nav" aria-label={t("network.title")}>
<div className="network-nav__brand">{t("network.title")}</div>
<nav className="network-nav__scroll"> <nav className="network-nav__scroll">
{NETWORK_NAV.map((group) => { <Accordion
const open = Boolean(openGroups[group.id]); className="network-nav__accordion"
const activeGroup = groupContainsPath(group.id, pathname); hideSeparator
return ( allowsMultipleExpanded
<div expandedKeys={expandedKeys}
key={group.id} onExpandedChange={setExpandedKeys}
className={`network-nav__group${open ? " is-open" : ""}${activeGroup ? " is-active-group" : ""}`} >
> {NETWORK_NAV.map((group) => {
<button const activeGroup = activeGroupIds.has(group.id);
type="button" return (
className="network-nav__group-toggle" <Accordion.Item
aria-expanded={open} key={group.id}
onClick={() => toggleGroup(group.id)} id={group.id}
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
> >
<span className="network-nav__chevron" aria-hidden> <Accordion.Heading>
{open ? "▾" : "▸"} <Accordion.Trigger className="network-nav__group-toggle">
</span> <span className="network-nav__group-label">{t(group.labelKey)}</span>
<span className="network-nav__group-label">{t(group.labelKey)}</span> <Accordion.Indicator className="network-nav__chevron" />
</button> </Accordion.Trigger>
{open ? ( </Accordion.Heading>
<ul className="network-nav__list"> <Accordion.Panel>
{group.items.map((item) => ( <Accordion.Body className="network-nav__panel-body">
<li key={item.id}> <ul className="network-nav__list">
<NavLink {group.items.map((item) => (
to={item.path} <li key={item.id}>
className={({ isActive }) => <NavLink
`network-nav__link${isActive ? " is-active" : ""}` to={item.path}
} className={({ isActive }) =>
end={ `network-nav__link${isActive ? " is-active" : ""}`
item.path === "/network/devices" || }
item.path === "/network/tasks/port-traffic" end={
} item.path === "/network/devices" ||
> item.path === "/network/tasks/port-traffic"
{t(item.labelKey)} }
</NavLink> >
</li> {t(item.labelKey)}
))} </NavLink>
</ul> </li>
) : null} ))}
</div> </ul>
); </Accordion.Body>
})} </Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</nav> </nav>
</aside> </aside>
<div className="network-main"> <div className="network-main">

View file

@ -49,7 +49,7 @@ export function PortTrafficBoardListPage() {
const boards = boardsQuery.data?.items || []; const boards = boardsQuery.data?.items || [];
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2> <h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -113,7 +113,7 @@ export function PortTrafficBoardListPage() {
<div className="btn-row table-actions"> <div className="btn-row table-actions">
<Button <Button
size="sm" size="sm"
variant="primary" variant="ghost"
onPress={() => openPortTrafficBoardWindow(b.id)} onPress={() => openPortTrafficBoardWindow(b.id)}
> >
{t("portTraffic.boardOpen")} {t("portTraffic.boardOpen")}

View file

@ -36,6 +36,7 @@ import type {
import { downloadCsv, fetchAllPages } from "../../utils/csvExport"; import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display"; import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time"; import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip } from "./nmChips";
const POLL_MS = 5000; const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 10; const TARGET_PAGE_SIZE = 10;
@ -695,7 +696,7 @@ export function PortTrafficPage() {
}; };
return ( return (
<section className="panel"> <section className="panel nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("portTraffic.title")}</h2> <h2>{t("portTraffic.title")}</h2>
<div className="btn-row"> <div className="btn-row">
@ -819,7 +820,6 @@ export function PortTrafficPage() {
</thead> </thead>
<tbody> <tbody>
{devices.map((row) => { {devices.map((row) => {
const tone = statusTone(row.status);
return ( return (
<tr key={row.id}> <tr key={row.id}>
<td> <td>
@ -831,14 +831,14 @@ export function PortTrafficPage() {
</div> </div>
</td> </td>
<td> <td>
<span className={`pt-list-status pt-list-status--${tone}`}> <div className="pt-port-updown">
{statusText(row.status)} <NmStatusChip color={jobChipColor(row.status)}>
</span> {statusText(row.status)}
{row.collect_running ? ( </NmStatusChip>
<span className="pt-list-status pt-list-status--collect"> {row.collect_running ? (
{t("portTraffic.collecting")} <NmStatusChip color="accent">{t("portTraffic.collecting")}</NmStatusChip>
</span> ) : null}
) : null} </div>
</td> </td>
<td className="pt-list-num"> <td className="pt-list-num">
{row.active_target_count}/{row.target_count} {row.active_target_count}/{row.target_count}
@ -852,18 +852,18 @@ export function PortTrafficPage() {
<Button size="sm" variant="primary" onPress={() => openWallList()}> <Button size="sm" variant="primary" onPress={() => openWallList()}>
{t("portTraffic.wall")} {t("portTraffic.wall")}
</Button> </Button>
<Button size="sm" variant="secondary" onPress={() => openEdit(row)}> <Button size="sm" variant="ghost" onPress={() => openEdit(row)}>
{t("portTraffic.edit")} {t("portTraffic.edit")}
</Button> </Button>
{needsNeRebind(row) ? ( {needsNeRebind(row) ? (
<Button size="sm" variant="secondary" onPress={() => openRebind(row)}> <Button size="sm" variant="ghost" onPress={() => openRebind(row)}>
{t("portTraffic.rebind")} {t("portTraffic.rebind")}
</Button> </Button>
) : null} ) : null}
{row.status !== "running" ? ( {row.status !== "running" ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={startMut.isPending} isDisabled={startMut.isPending}
onPress={() => startMut.mutate(row.id)} onPress={() => startMut.mutate(row.id)}
> >
@ -872,7 +872,7 @@ export function PortTrafficPage() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={pauseMut.isPending} isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(row.id)} onPress={() => pauseMut.mutate(row.id)}
> >
@ -882,7 +882,7 @@ export function PortTrafficPage() {
{row.status !== "stopped" ? ( {row.status !== "stopped" ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
isDisabled={stopMut.isPending} isDisabled={stopMut.isPending}
onPress={() => stopMut.mutate(row.id)} onPress={() => stopMut.mutate(row.id)}
> >
@ -891,7 +891,7 @@ export function PortTrafficPage() {
) : null} ) : null}
<Button <Button
size="sm" size="sm"
variant="secondary" variant="ghost"
className={row.last_error ? "pt-list-log-btn--error" : undefined} className={row.last_error ? "pt-list-log-btn--error" : undefined}
onPress={() => { onPress={() => {
setLogDeviceId(row.id); setLogDeviceId(row.id);

View file

@ -274,7 +274,7 @@ export function TopologyClassifyPage() {
}; };
return ( return (
<div className="panel topo-classify"> <div className="panel topo-classify nm-page-panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("topoClassify.title")}</h2> <h2>{t("topoClassify.title")}</h2>
<div className="btn-row"> <div className="btn-row">

View file

@ -0,0 +1,80 @@
import { Chip } from "@heroui/react";
import type { ReactNode } from "react";
export type NmChipColor = "success" | "danger" | "warning" | "default" | "accent";
export function sourceChipColor(source: string | null | undefined): NmChipColor {
const s = String(source || "").trim().toLowerCase();
if (s === "ume") return "accent";
if (s === "managed" || s === "webcrt") return "default";
return "default";
}
/** Connection / health style statuses. */
export function connectChipColor(status: string | null | undefined): NmChipColor {
const s = String(status || "")
.trim()
.toLowerCase();
if (!s || s === "-" || s === "—" || s === "unknown") return "default";
if (s.includes("fail") || s.includes("down") || s.includes("error") || s.includes("critical")) {
return "danger";
}
if (s.includes("ok") || s.includes("up") || s.includes("connected") || s.includes("success") || s.includes("pass")) {
return "success";
}
if (s.includes("warn") || s.includes("degrad") || s.includes("test") || s.includes("major") || s.includes("minor")) {
return "warning";
}
return "accent";
}
/** Job / cycle / task lifecycle statuses. */
export function jobChipColor(status: string | null | undefined): NmChipColor {
const s = String(status || "")
.trim()
.toLowerCase();
if (!s) return "default";
if (s === "running" || s === "pending" || s === "collect") return "success";
if (s === "paused" || s === "draft" || s === "skipped") return "warning";
if (s === "failed" || s === "fail" || s === "error" || s === "stopped") return "danger";
if (s === "success" || s === "completed" || s === "ok" || s === "done") return "success";
return "default";
}
/** Fabric / LLDP edge status (active / missing / stale). */
export function edgeChipColor(status: string | null | undefined): NmChipColor {
const s = String(status || "")
.trim()
.toLowerCase();
if (s === "active" || s === "ok" || s === "up") return "success";
if (s === "missing" || s === "stale" || s === "down") return "warning";
if (s.includes("fail") || s.includes("error")) return "danger";
return "default";
}
/** Alarm perceived severity. */
export function severityChipColor(sev: string | null | undefined): NmChipColor {
const s = String(sev || "")
.trim()
.toLowerCase();
if (s.includes("critical")) return "danger";
if (s.includes("major")) return "warning";
if (s.includes("minor") || s.includes("warning") || s.includes("warn")) return "warning";
if (s.includes("info") || s.includes("indeterminate")) return "accent";
return "default";
}
type NmStatusChipProps = {
color: NmChipColor;
children: ReactNode;
className?: string;
};
/** Soft status/source chip used across Network Management lists. */
export function NmStatusChip({ color, children, className }: NmStatusChipProps) {
return (
<Chip size="sm" variant="soft" color={color} className={className}>
<Chip.Label>{children}</Chip.Label>
</Chip>
);
}