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]]:
try:
from .main import _list_runtime_tasks
from .ume_support import _list_runtime_tasks
except Exception:
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]] = []
for row in _list_runtime_tasks():
task = str(row.get("task") or "")
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.
items.append(
_item(
@ -350,8 +387,8 @@ def _ume_runtime_items() -> list[dict[str, Any]]:
actor="system",
started_at=None,
updated_at=None,
progress=str(row.get("interval_label") or ""),
detail=str(row.get("last_error") or "")[:240],
progress=progress,
detail=detail,
href="/ume",
)
)
@ -437,6 +474,7 @@ def list_ops_tasks(db: Session) -> dict[str, Any]:
"collecting",
"pending",
"paused",
"idle",
"connecting",
"ready",
"testing",
@ -451,9 +489,10 @@ def list_ops_tasks(db: Session) -> dict[str, Any]:
"connecting": 3,
"pending": 4,
"paused": 5,
"ready": 6,
"detached": 7,
"error": 8,
"idle": 6,
"ready": 7,
"detached": 8,
"error": 9,
}
items.sort(
key=lambda x: (

View file

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

View file

@ -1,12 +1,9 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { AppLayout } from "./layout/AppLayout";
import { queryKeys } from "./constants/queryKeys";
import { WorkbenchPage } from "./pages/WorkbenchPage";
import { LoginPage } from "./pages/LoginPage";
import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
import { fetchIntegrationStatus } from "./services/api";
import { useAuth } from "./auth/AuthContext";
const CollectPage = lazy(() => import("./pages/CollectPage").then((m) => ({ default: m.CollectPage })));
@ -84,13 +81,6 @@ function PageFallback() {
function ProtectedApp() {
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) {
return (
@ -107,32 +97,7 @@ function ProtectedApp() {
}
return (
<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,
}}
>
<AppLayout>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/" element={<WorkbenchPage />} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ const POLL_MS = 4000;
function statusTone(status: string): string {
const s = status.toLowerCase();
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 === "failed" || s === "error") return "stopped";
return "other";
@ -36,9 +36,20 @@ function triggerLabel(trigger: string, t: (k: string, vars?: Record<string, stri
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 {
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;
// Avoid "Kind · Kind · x" if an old backend still prefixed the localized kind.
if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) {

View file

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