Migrate Ops interface patterns

This commit is contained in:
2026-08-03 15:18:32 +02:00
parent cdcb477b55
commit 2b3264372b
8 changed files with 367 additions and 25 deletions
+62 -22
View File
@@ -1,18 +1,22 @@
import { useEffect, useState } from "react";
import { PauseCircle, PlayCircle, RefreshCw } from "lucide-react";
import {
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
DataGrid,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard,
PageScrollViewport,
PageTitle,
StatusBadge,
TableActionGroup,
adminErrorMessage,
hasAnyScope,
i18nMessage,
type ApiSettings,
type AuthInfo,
type DataGridColumn } from
@@ -30,10 +34,16 @@ import {
type OpsSizingAssumption,
type OpsStatus
} from "../../api/ops";
import {
OPS_DOCUMENTATION,
OPS_I18N,
OPS_RECOVERY_DOCUMENTATION,
} from "./interfacePatterns";
export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const [status, setStatus] = useState<OpsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [runningProbes, setRunningProbes] = useState(false);
const [error, setError] = useState("");
const [drainTarget, setDrainTarget] = useState<OpsRuntimeNode | null>(null);
const [nodeActionId, setNodeActionId] = useState("");
@@ -51,6 +61,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
}
async function runChecks() {
setRunningProbes(true);
setLoading(true);
setError("");
try {
@@ -58,6 +69,7 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setRunningProbes(false);
setLoading(false);
}
}
@@ -97,6 +109,13 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
const errorCount = checks.filter((item) => item.state === "error").length;
const ready = status?.readiness.ready ?? false;
const canRunChecks = hasAnyScope(auth, ["ops:operations:run", "system:settings:write"]);
const runProbesDisabledReason = runningProbes
? OPS_I18N.runningProbes
: loading
? OPS_I18N.loading
: !canRunChecks
? OPS_I18N.runPermissionRequired
: undefined;
const queueDepths = status?.summary.worker_metrics.queue_depths ?? {};
const queuedTasks = Object.values(queueDepths).reduce((total, value) => total + value, 0);
const storageUsage = status?.summary.storage_metrics?.capacity_used_percent;
@@ -110,12 +129,25 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
<p>i18n:govoplan-ops.runtime_health_deployment_profile_worker_split_a.55340156</p>
</div>
<div className="button-row compact-actions">
{canRunChecks ? <Button variant="primary" onClick={() => void runChecks()} disabled={loading}>Run probes</Button> : null}
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-ops.reload.cce71553</Button>
<DocumentationHelpLink reference={OPS_DOCUMENTATION} />
<Button variant="primary" onClick={() => void runChecks()} disabled={Boolean(runProbesDisabledReason)} disabledReason={runProbesDisabledReason}>i18n:govoplan-ops.surface.run_probes</Button>
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? OPS_I18N.loading : undefined}><RefreshCw size={16} /> i18n:govoplan-ops.reload.cce71553</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{status && !ready && (
<ActionBlockerHint
reason={{
summary: "i18n:govoplan-ops.readiness_blocked_summary",
details: i18nMessage("i18n:govoplan-ops.readiness_blocked_details", { value0: status.readiness.blockers.length }),
requiredAction: "i18n:govoplan-ops.readiness_blocked_action",
actor: "i18n:govoplan-ops.operations_operator",
target: "i18n:govoplan-ops.readiness_blocked_target"
}}
documentation={OPS_RECOVERY_DOCUMENTATION}
/>
)}
<LoadingFrame loading={loading} label="i18n:govoplan-ops.loading_operations_status.6890fe6e">
<div className="metric-grid">
@@ -172,9 +204,9 @@ export default function OpsPage({ settings, auth }: {settings: ApiSettings;auth:
</LoadingFrame>
<ConfirmDialog
open={Boolean(drainTarget)}
title="Drain runtime node"
message={`Stop routing new work to ${drainTarget?.node_id ?? "this node"}. In-flight work is allowed to finish.`}
confirmLabel="Drain node"
title="i18n:govoplan-ops.surface.drain_node"
message={i18nMessage("i18n:govoplan-ops.drain_runtime_node_message", { value0: drainTarget?.node_id ?? "i18n:govoplan-ops.this_node" })}
confirmLabel="i18n:govoplan-ops.drain_node"
busy={Boolean(nodeActionId)}
onCancel={() => setDrainTarget(null)}
onConfirm={() => void confirmDrain()}
@@ -244,24 +276,32 @@ function RuntimeNodeTable({
width: 56,
value: (node) => node.state,
render: (node) => {
if (!canManage || node.state === "stopped") return null;
const busy = busyNodeId === node.node_id;
return node.state === "draining" ? (
<Button
variant="ghost"
title="Cancel drain"
aria-label={`Cancel drain for ${node.node_id}`}
disabled={busy}
onClick={() => onCancelDrain(node)}
><PlayCircle size={16} /></Button>
) : (
<Button
variant="ghost"
title="Drain node"
aria-label={`Drain ${node.node_id}`}
disabled={busy || node.stale}
onClick={() => onDrain(node)}
><PauseCircle size={16} /></Button>
const cancelling = node.state === "draining";
const disabledReason = !canManage
? OPS_I18N.runPermissionRequired
: busy
? OPS_I18N.nodeActionActive
: node.state === "stopped"
? OPS_I18N.stoppedNode
: !cancelling && node.stale
? OPS_I18N.staleNode
: undefined;
return (
<TableActionGroup
minimumSlots={1}
label={i18nMessage("i18n:govoplan-ops.lifecycle_actions_for_value", { value0: node.node_id })}
actions={[{
id: cancelling ? "cancel-drain" : "drain",
label: cancelling
? i18nMessage("i18n:govoplan-ops.cancel_drain_for_value", { value0: node.node_id })
: i18nMessage("i18n:govoplan-ops.drain_value", { value0: node.node_id }),
icon: cancelling ? <PlayCircle size={16} /> : <PauseCircle size={16} />,
disabled: Boolean(disabledReason),
disabledReason,
onClick: () => cancelling ? onCancelDrain(node) : onDrain(node)
}]}
/>
);
}
}