Move integration health into active tasks and fix dark topology labels.

Drop header netx-api/oclaw pills, restore UME/OClaw runtime rows in ops tasks, and raise node/edge label contrast on the dark canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 09:16:52 +08:00
parent 4dd121b42f
commit 83acb5eef1
10 changed files with 106 additions and 128 deletions

View file

@ -332,13 +332,50 @@ def _ne_connect_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any
def _ume_runtime_items() -> list[dict[str, Any]]: def _ume_runtime_items() -> list[dict[str, Any]]:
try: try:
from .main import _list_runtime_tasks from .ume_support import _list_runtime_tasks
except Exception: except Exception:
return [] return []
fwd: dict[str, Any] | None = None
try:
from .oclaw_alarm_forwarder import forwarder_status
fwd = forwarder_status()
except Exception:
fwd = None
ws_conn: dict[str, Any] | None = None
try:
from .ume_alarm_ws import get_ws_connection_status
ws_conn = get_ws_connection_status()
except Exception:
ws_conn = None
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
for row in _list_runtime_tasks(): for row in _list_runtime_tasks():
task = str(row.get("task") or "") task = str(row.get("task") or "")
status = str(row.get("status") or "unknown") status = str(row.get("status") or "unknown")
progress = str(row.get("interval_label") or "")
detail = str(row.get("last_error") or "")[:240]
if task == "oclaw_alarm_forwarder" and isinstance(fwd, dict):
bits: list[str] = []
if not bool(fwd.get("enabled")):
bits.append("disabled")
elif bool(fwd.get("paused")):
bits.append("paused")
elif bool(fwd.get("connected")):
bits.append("connected")
else:
bits.append("disconnected")
q = int(fwd.get("queue_size") or 0)
if q > 0:
bits.append(f"q={q}")
pub_ok = int(fwd.get("published_ok") or 0)
if pub_ok > 0:
bits.append(f"pub={pub_ok}")
progress = " · ".join([p for p in (progress, *bits) if p])
elif task == "alarms_current_ws_consumer" and isinstance(ws_conn, dict):
state = str(ws_conn.get("state") or ws_conn.get("status") or "").strip()
if state:
progress = " · ".join([p for p in (progress, state) if p])
# Always show UME background loops so ops can see paused/idle too. # Always show UME background loops so ops can see paused/idle too.
items.append( items.append(
_item( _item(
@ -350,8 +387,8 @@ def _ume_runtime_items() -> list[dict[str, Any]]:
actor="system", actor="system",
started_at=None, started_at=None,
updated_at=None, updated_at=None,
progress=str(row.get("interval_label") or ""), progress=progress,
detail=str(row.get("last_error") or "")[:240], detail=detail,
href="/ume", href="/ume",
) )
) )
@ -437,6 +474,7 @@ def list_ops_tasks(db: Session) -> dict[str, Any]:
"collecting", "collecting",
"pending", "pending",
"paused", "paused",
"idle",
"connecting", "connecting",
"ready", "ready",
"testing", "testing",
@ -451,9 +489,10 @@ def list_ops_tasks(db: Session) -> dict[str, Any]:
"connecting": 3, "connecting": 3,
"pending": 4, "pending": 4,
"paused": 5, "paused": 5,
"ready": 6, "idle": 6,
"detached": 7, "ready": 7,
"error": 8, "detached": 8,
"error": 9,
} }
items.sort( items.sort(
key=lambda x: ( key=lambda x: (

View file

@ -30,6 +30,7 @@ from .runtime_task_messages import (
RT_UME_WS_DISABLED_NO_BASE_URL, RT_UME_WS_DISABLED_NO_BASE_URL,
RT_WSS_ACTIVE_SKIP_REST, RT_WSS_ACTIVE_SKIP_REST,
) )
from .oclaw_alarm_forwarder import is_forwarder_enabled
from .ume_alarm_ws import ( from .ume_alarm_ws import (
begin_startup_alarm_sync_gate, begin_startup_alarm_sync_gate,
complete_startup_alarm_sync_gate, complete_startup_alarm_sync_gate,

View file

@ -1,12 +1,9 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { AppLayout } from "./layout/AppLayout"; import { AppLayout } from "./layout/AppLayout";
import { queryKeys } from "./constants/queryKeys";
import { WorkbenchPage } from "./pages/WorkbenchPage"; import { WorkbenchPage } from "./pages/WorkbenchPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage"; import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
import { fetchIntegrationStatus } from "./services/api";
import { useAuth } from "./auth/AuthContext"; import { useAuth } from "./auth/AuthContext";
const CollectPage = lazy(() => import("./pages/CollectPage").then((m) => ({ default: m.CollectPage }))); const CollectPage = lazy(() => import("./pages/CollectPage").then((m) => ({ default: m.CollectPage })));
@ -84,13 +81,6 @@ function PageFallback() {
function ProtectedApp() { function ProtectedApp() {
const { ready, user } = useAuth(); const { ready, user } = useAuth();
const integrationsQuery = useQuery({
queryKey: queryKeys.integrationsStatus,
queryFn: fetchIntegrationStatus,
refetchInterval: 10_000,
staleTime: 5_000,
enabled: ready && Boolean(user) && !user?.must_change_password,
});
if (!ready) { if (!ready) {
return ( return (
@ -107,32 +97,7 @@ function ProtectedApp() {
} }
return ( return (
<AppLayout <AppLayout>
connections={{
netxApi: integrationsQuery.data?.netx_api?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
netxApiLatencyMs:
typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
oclawBridge:
integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
oclawBridgeLatencyMs: undefined,
oclawBridgeErrorKind:
typeof integrationsQuery.data?.oclaw_bridge?.error_kind === "string"
? integrationsQuery.data.oclaw_bridge.error_kind
: undefined,
oclawBridgeError:
typeof integrationsQuery.data?.oclaw_bridge?.error === "string"
? integrationsQuery.data.oclaw_bridge.error
: undefined,
oclawBridgeQueueSize:
typeof integrationsQuery.data?.oclaw_bridge?.queue_size === "number"
? integrationsQuery.data.oclaw_bridge.queue_size
: undefined,
oclawBridgePublishedOk:
typeof integrationsQuery.data?.oclaw_bridge?.published_ok === "number"
? integrationsQuery.data.oclaw_bridge.published_ok
: undefined,
}}
>
<Suspense fallback={<PageFallback />}> <Suspense fallback={<PageFallback />}>
<Routes> <Routes>
<Route path="/" element={<WorkbenchPage />} /> <Route path="/" element={<WorkbenchPage />} />

View file

@ -1654,6 +1654,7 @@ const en = {
detached: "Detached (grace)", detached: "Detached (grace)",
pending: "Pending", pending: "Pending",
paused: "Paused", paused: "Paused",
idle: "Idle",
error: "Error", error: "Error",
failed: "Failed", failed: "Failed",
}, },

View file

@ -1645,6 +1645,7 @@ const zh = {
detached: "已断开(宽限)", detached: "已断开(宽限)",
pending: "排队", pending: "排队",
paused: "暂停", paused: "暂停",
idle: "空闲",
error: "错误", error: "错误",
failed: "失败", failed: "失败",
}, },

View file

@ -7116,7 +7116,7 @@ pre {
} }
.topo-node.is-selected .topo-node__glyph { .topo-node.is-selected .topo-node__glyph {
outline: 2px solid #334155; outline: 2px solid #60a5fa;
outline-offset: 3px; outline-offset: 3px;
border-radius: 4px; border-radius: 4px;
} }
@ -7127,10 +7127,14 @@ pre {
} }
.topo-node.is-selected .topo-node__caption { .topo-node.is-selected .topo-node__caption {
color: #0f172a; color: #f8fafc;
font-weight: 700; font-weight: 700;
} }
.topo-node.is-selected .topo-node__caption-name {
color: #93c5fd;
}
/* Selected link: light highlight only (do not alter geometry / arrows). */ /* Selected link: light highlight only (do not alter geometry / arrows). */
.topo-canvas .react-flow__edge.selected .react-flow__edge-path { .topo-canvas .react-flow__edge.selected .react-flow__edge-path {
stroke: #2563eb !important; stroke: #2563eb !important;
@ -7155,10 +7159,13 @@ pre {
align-items: center; align-items: center;
gap: 1px; gap: 1px;
margin-top: 3px; margin-top: 3px;
color: #334155; color: #e2e8f0;
line-height: 1.2; line-height: 1.2;
max-width: 160px; max-width: 160px;
pointer-events: none; pointer-events: none;
text-shadow:
0 0 3px rgba(15, 23, 42, 0.95),
0 1px 2px rgba(15, 23, 42, 0.85);
} }
.topo-node__caption-name { .topo-node__caption-name {
@ -7166,7 +7173,7 @@ pre {
max-width: 100%; max-width: 100%;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: #0f172a; color: #f1f5f9;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -7176,7 +7183,7 @@ pre {
display: block; display: block;
max-width: 100%; max-width: 100%;
font-size: 10px; font-size: 10px;
color: #64748b; color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -7194,17 +7201,19 @@ pre {
.topo-edge-label { .topo-edge-label {
font-size: 11px; font-size: 11px;
color: #cbd5e1; color: #e2e8f0;
background: rgba(15, 23, 42, 0.92); background: transparent;
border: 1px solid rgba(148, 163, 184, 0.28); border: 0;
border-radius: 4px; border-radius: 0;
padding: 1px 6px; padding: 0;
white-space: nowrap; white-space: nowrap;
text-shadow:
0 0 3px rgba(15, 23, 42, 0.95),
0 1px 2px rgba(15, 23, 42, 0.85);
} }
.topo-edge-label.is-selected { .topo-edge-label.is-selected {
color: #93c5fd; color: #93c5fd;
border-color: rgba(59, 130, 246, 0.55);
font-weight: 600; font-weight: 600;
} }

View file

@ -11,23 +11,11 @@ import { openOrFocusModule } from "../utils/moduleWindows";
import { returnToWorkbench } from "../utils/workbench"; import { returnToWorkbench } from "../utils/workbench";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
type ConnLevel = "up" | "down" | "unknown";
type Props = { type Props = {
connections: {
netxApi: ConnLevel;
netxApiLatencyMs?: number;
oclawBridge: ConnLevel;
oclawBridgeLatencyMs?: number;
oclawBridgeErrorKind?: string;
oclawBridgeError?: string;
oclawBridgeQueueSize?: number;
oclawBridgePublishedOk?: number;
};
children: ReactNode; children: ReactNode;
}; };
export function AppLayout({ connections, children }: Props) { export function AppLayout({ children }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -41,22 +29,6 @@ export function AppLayout({ connections, children }: Props) {
staleTime: 1500, staleTime: 1500,
}); });
const activeTasks = opsTasksQuery.data?.active ?? 0; const activeTasks = opsTasksQuery.data?.active ?? 0;
const netxSuffix =
typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : "";
const oclawSuffix =
connections.oclawBridge === "up"
? typeof connections.oclawBridgePublishedOk === "number"
? ` (pub ${connections.oclawBridgePublishedOk})`
: ""
: connections.oclawBridgeErrorKind
? ` (${connections.oclawBridgeErrorKind})`
: typeof connections.oclawBridgeQueueSize === "number" && connections.oclawBridgeQueueSize > 0
? ` (q ${connections.oclawBridgeQueueSize})`
: "";
const oclawTitle =
connections.oclawBridge === "down" && connections.oclawBridgeError
? connections.oclawBridgeError
: undefined;
useAppWindowRegistration(pathname); useAppWindowRegistration(pathname);
@ -88,47 +60,28 @@ export function AppLayout({ connections, children }: Props) {
) : null} ) : null}
</div> </div>
<div className="app-brand__actions"> <div className="app-brand__actions">
<div className="app-brand__actions-group">
<span className={`conn-pill conn-pill--on-brand conn-pill--${connections.netxApi}`}>
{t("layout.netxApi")}: {connections.netxApi}
{netxSuffix}
</span>
<span
className={`conn-pill conn-pill--on-brand conn-pill--${connections.oclawBridge}`}
title={oclawTitle}
>
{t("layout.oclawBridge")}: {connections.oclawBridge}
{oclawSuffix}
</span>
</div>
{user ? ( {user ? (
<> <div className="app-brand__actions-group">
<span className="app-brand__actions-sep" aria-hidden /> <button
<div className="app-brand__actions-group"> type="button"
<button className={`conn-pill conn-pill--on-brand conn-pill--tasks conn-pill--${activeTasks > 0 ? "up" : "unknown"}`}
type="button" title={t("layout.activeTasksHint")}
className={`conn-pill conn-pill--on-brand conn-pill--tasks conn-pill--${activeTasks > 0 ? "up" : "unknown"}`} onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
title={t("layout.activeTasksHint")} >
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })} {t("layout.activeTasks", { count: activeTasks })}
> </button>
{t("layout.activeTasks", { count: activeTasks })} <span className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}>
</button> {user.username}
<span </span>
className="conn-pill conn-pill--on-brand conn-pill--user" <button
title={user.role} type="button"
> className="header-menu__trigger header-menu__trigger--on-brand"
{user.username} onClick={() => void logout()}
</span> >
<button {t("auth.logout")}
type="button" </button>
className="header-menu__trigger header-menu__trigger--on-brand" <HeaderMenu />
onClick={() => void logout()} </div>
>
{t("auth.logout")}
</button>
<HeaderMenu />
</div>
</>
) : ( ) : (
<HeaderMenu /> <HeaderMenu />
)} )}

View file

@ -334,7 +334,7 @@ export function UmePage() {
setKeyAlertNeTypes([]); setKeyAlertNeTypes([]);
showOk(t("ume.keyAlert.addOk")); showOk(t("ume.keyAlert.addOk"));
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus }); await queryClient.invalidateQueries({ queryKey: ["opsTasks"] });
}, },
onError: (err) => { onError: (err) => {
const msg = String(err); const msg = String(err);
@ -349,7 +349,7 @@ export function UmePage() {
onSuccess: async () => { onSuccess: async () => {
showOk(t("ume.keyAlert.deleteOk")); showOk(t("ume.keyAlert.deleteOk"));
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus }); await queryClient.invalidateQueries({ queryKey: ["opsTasks"] });
}, },
onError: (err) => { onError: (err) => {
const msg = String(err); const msg = String(err);

View file

@ -10,7 +10,7 @@ const POLL_MS = 4000;
function statusTone(status: string): string { function statusTone(status: string): string {
const s = status.toLowerCase(); const s = status.toLowerCase();
if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running"; if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running";
if (s === "ready") return "ok"; if (s === "ready" || s === "idle") return "ok";
if (s === "paused" || s === "pending" || s === "detached") return "paused"; if (s === "paused" || s === "pending" || s === "detached") return "paused";
if (s === "failed" || s === "error") return "stopped"; if (s === "failed" || s === "error") return "stopped";
return "other"; return "other";
@ -36,9 +36,20 @@ function triggerLabel(trigger: string, t: (k: string, vars?: Record<string, stri
return tr === key ? raw : tr; return tr === key ? raw : tr;
} }
function runtimeTaskSubject(
taskId: string,
t: (k: string, vars?: Record<string, string | number>) => string,
): string {
const key = `ume.tasks.runtimeTask.${taskId}`;
const label = t(key);
return label === key ? taskId : label;
}
function taskTitle(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string { function taskTitle(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
const kind = kindLabel(row.kind, t); const kind = kindLabel(row.kind, t);
const subject = String(row.title || "").trim(); const subjectRaw = String(row.title || row.id || "").trim();
const subject =
row.kind === "ume_runtime" && subjectRaw ? runtimeTaskSubject(subjectRaw, t) : subjectRaw;
if (!subject) return kind; if (!subject) return kind;
// Avoid "Kind · Kind · x" if an old backend still prefixed the localized kind. // Avoid "Kind · Kind · x" if an old backend still prefixed the localized kind.
if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) { if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) {

View file

@ -11,7 +11,6 @@ export function ParallelEdge({
markerEnd, markerEnd,
label, label,
labelStyle, labelStyle,
labelBgStyle,
data, data,
selected, selected,
}: EdgeProps) { }: EdgeProps) {
@ -41,7 +40,6 @@ export function ParallelEdge({
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "none", pointerEvents: "none",
...labelStyle, ...labelStyle,
...(labelBgStyle || {}),
}} }}
> >
{label} {label}