feat(openclaw): add live ops space scene state
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Failing after 1m7s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Failing after 1m7s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
This commit is contained in:
466
apps/web/src/app/[locale]/openclaw/live-ops-space/page.tsx
Normal file
466
apps/web/src/app/[locale]/openclaw/live-ops-space/page.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
CheckCircle2,
|
||||
MessageSquareText,
|
||||
RefreshCw,
|
||||
Rocket,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppLayout } from "@/components/layout";
|
||||
import { getRuntimeApiBaseUrl } from "@/lib/runtime-api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SceneStatus = "loading" | "ready" | "degraded";
|
||||
|
||||
type SceneZone = {
|
||||
zone_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
type SceneAgent = {
|
||||
agent_id: string;
|
||||
label: string;
|
||||
zone_id: string;
|
||||
state: string;
|
||||
animation: string;
|
||||
position: { x: number; y: number };
|
||||
current_task?: {
|
||||
stage_id?: string | null;
|
||||
recorded?: boolean | null;
|
||||
total?: number | null;
|
||||
recent?: number | null;
|
||||
feeds_learning?: boolean | null;
|
||||
required_for_closed_loop?: boolean | null;
|
||||
};
|
||||
};
|
||||
|
||||
type SceneWorkItem = {
|
||||
work_item_id: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
zone_id: string;
|
||||
animation: string;
|
||||
position: { x: number; y: number };
|
||||
next_controlled_action?: string | null;
|
||||
exit_criteria?: string | null;
|
||||
};
|
||||
|
||||
type SceneState = {
|
||||
schema_version: "openclaw_live_ops_scene_state_v1";
|
||||
generated_at: string;
|
||||
status: string;
|
||||
source: {
|
||||
deploy_readback_marker?: string | null;
|
||||
live_source_connected?: boolean | null;
|
||||
};
|
||||
room: {
|
||||
zones: SceneZone[];
|
||||
animation_loop: {
|
||||
enabled: boolean;
|
||||
tick_ms: number;
|
||||
motion_model: string;
|
||||
};
|
||||
};
|
||||
agents: SceneAgent[];
|
||||
work_items: SceneWorkItem[];
|
||||
rollups: {
|
||||
zone_count: number;
|
||||
agent_count: number;
|
||||
work_item_count: number;
|
||||
animated_entity_count: number;
|
||||
completed_work_item_count: number;
|
||||
blocked_work_item_count: number;
|
||||
source_stage_count: number;
|
||||
recorded_stage_count: number;
|
||||
};
|
||||
boundaries: Record<string, boolean>;
|
||||
};
|
||||
|
||||
const API_BASE = getRuntimeApiBaseUrl();
|
||||
|
||||
const zoneIconByKind: Record<string, typeof Activity> = {
|
||||
control_plane: BrainCircuit,
|
||||
tool_context: Wrench,
|
||||
knowledge_retrieval: SearchCheck,
|
||||
procedure_trust: ShieldCheck,
|
||||
post_apply_verification: CheckCircle2,
|
||||
receipt_delivery: MessageSquareText,
|
||||
release_truth: Rocket,
|
||||
};
|
||||
|
||||
function stateLabel(t: ReturnType<typeof useTranslations>, state: string): string {
|
||||
if (state === "working") return t("states.working");
|
||||
if (state === "verified") return t("states.verified");
|
||||
if (state === "blocked") return t("states.blocked");
|
||||
if (state === "waiting") return t("states.waiting");
|
||||
return t("states.idle");
|
||||
}
|
||||
|
||||
function stateClass(state: string): string {
|
||||
if (state === "working") return "border-[#4a90d9] bg-[#eef5ff] text-[#1f5b9b]";
|
||||
if (state === "verified") return "border-[#8fc29a] bg-[#f0faf2] text-[#17602a]";
|
||||
if (state === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
|
||||
if (state === "waiting") return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
|
||||
return "border-[#d8d3c7] bg-white text-[#5f5b52]";
|
||||
}
|
||||
|
||||
function stateFromWorkItemStatus(status: string): string {
|
||||
if (status === "completed") return "verified";
|
||||
if (status === "in_progress") return "working";
|
||||
if (status === "blocked") return "blocked";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
function shortValue(value?: string | null): string {
|
||||
if (!value) return "--";
|
||||
return value.length > 18 ? `${value.slice(0, 14)}...` : value;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined): string {
|
||||
return new Intl.NumberFormat("zh-TW").format(Number(value ?? 0));
|
||||
}
|
||||
|
||||
export default function OpenClawLiveOpsSpacePage({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const t = useTranslations("openclaw.liveOpsSpace");
|
||||
const locale = useLocale();
|
||||
const [scene, setScene] = useState<SceneState | null>(null);
|
||||
const [status, setStatus] = useState<SceneStatus>("loading");
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
|
||||
const fetchSceneState = useCallback(async () => {
|
||||
if (!API_BASE) {
|
||||
setStatus("degraded");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 12_000);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/agents/openclaw-live-ops-scene-state`,
|
||||
{ cache: "no-store", signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
setStatus("degraded");
|
||||
return;
|
||||
}
|
||||
const payload = (await response.json()) as SceneState;
|
||||
setScene(payload);
|
||||
setUpdatedAt(new Date());
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("degraded");
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSceneState();
|
||||
const timer = window.setInterval(fetchSceneState, 15_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fetchSceneState]);
|
||||
|
||||
const zones = scene?.room.zones ?? [];
|
||||
const agents = scene?.agents ?? [];
|
||||
const workItems = scene?.work_items ?? [];
|
||||
const boundaryOk = useMemo(() => {
|
||||
if (!scene) return false;
|
||||
return [
|
||||
"raw_session_read_allowed",
|
||||
"sqlite_read_allowed",
|
||||
"secret_value_display_allowed",
|
||||
"runtime_action_performed",
|
||||
"host_or_k8s_write_performed",
|
||||
].every((key) => scene.boundaries[key] === false);
|
||||
}, [scene]);
|
||||
|
||||
return (
|
||||
<AppLayout locale={params.locale} fullBleed>
|
||||
<main className="min-h-screen overflow-x-hidden bg-[#f7f8f7] text-[#141413]">
|
||||
<div className="mx-auto flex w-full max-w-[1520px] flex-col gap-4 px-3 py-4 lg:px-5">
|
||||
<section className="grid min-w-0 items-start gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="min-w-0 border border-[#d8d3c7] bg-white">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3 border-b border-[#e5e1d8] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase text-[#77736a]">
|
||||
OpenClaw
|
||||
</p>
|
||||
<h1 className="mt-1 break-words text-2xl font-semibold leading-tight text-[#141413] sm:text-3xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 border px-3 py-1.5 text-xs font-semibold",
|
||||
status === "ready"
|
||||
? "border-[#8fc29a] bg-[#f0faf2] text-[#17602a]"
|
||||
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
)}
|
||||
>
|
||||
{status === "ready" ? (
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{status === "ready"
|
||||
? t("status.ready")
|
||||
: status === "loading"
|
||||
? t("status.loading")
|
||||
: t("status.degraded")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchSceneState()}
|
||||
className="inline-flex h-9 w-9 items-center justify-center border border-[#d8d3c7] bg-white text-[#4f4b44] transition hover:border-[#4a90d9] hover:text-[#1f5b9b]"
|
||||
title={t("actions.refresh")}
|
||||
aria-label={t("actions.refresh")}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-[16/10] min-h-[360px] overflow-hidden bg-[#eef3f8] sm:min-h-[500px]">
|
||||
<div className="absolute inset-x-[5%] bottom-[7%] top-[8%] rotate-[-2deg] skew-y-[-7deg] border border-[#c8d6df] bg-[#fdfefe] shadow-[0_28px_70px_rgba(54,72,88,0.16)]" />
|
||||
<div className="absolute inset-x-[7%] bottom-[11%] top-[12%] rotate-[-2deg] skew-y-[-7deg] bg-[linear-gradient(90deg,rgba(74,144,217,0.12)_1px,transparent_1px),linear-gradient(0deg,rgba(74,144,217,0.1)_1px,transparent_1px)] bg-[size:42px_42px]" />
|
||||
|
||||
{zones.map((zone) => {
|
||||
const Icon = zoneIconByKind[zone.kind] ?? Activity;
|
||||
return (
|
||||
<div
|
||||
key={zone.zone_id}
|
||||
className="absolute flex h-14 w-28 -translate-x-1/2 -translate-y-1/2 rotate-[-2deg] items-center gap-2 border border-[#cbd4d8] bg-white/90 px-2 shadow-[0_10px_24px_rgba(54,72,88,0.12)] backdrop-blur"
|
||||
style={{ left: `${zone.position.x}%`, top: `${zone.position.y}%` }}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center border border-[#d8d3c7] bg-[#f7f8f7] text-[#4a90d9]">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[11px] font-semibold text-[#34302a]">
|
||||
{zone.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{workItems.map((item, index) => (
|
||||
<div
|
||||
key={item.work_item_id}
|
||||
className={cn(
|
||||
"openclaw-work-token absolute h-8 w-20 -translate-x-1/2 -translate-y-1/2 border px-2 py-1 text-[10px] font-semibold shadow-[0_8px_20px_rgba(54,72,88,0.16)]",
|
||||
stateClass(stateFromWorkItemStatus(item.status)),
|
||||
)}
|
||||
style={{
|
||||
left: `${item.position.x}%`,
|
||||
top: `${item.position.y}%`,
|
||||
animationDelay: `${index * 0.35}s`,
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{item.work_item_id}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{agents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className="openclaw-agent absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
left: `${agent.position.x}%`,
|
||||
top: `${agent.position.y}%`,
|
||||
animationDelay: `${index * 0.28}s`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-14 items-center justify-center border-2 bg-white shadow-[0_16px_30px_rgba(54,72,88,0.2)]",
|
||||
stateClass(agent.state),
|
||||
)}
|
||||
>
|
||||
<Bot className="h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-1 max-w-28 truncate border border-[#d8d3c7] bg-white px-1.5 py-0.5 text-center text-[10px] font-semibold text-[#34302a] shadow-sm">
|
||||
{agent.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(145deg,rgba(255,255,255,0.18),transparent_45%,rgba(20,20,19,0.04))]" />
|
||||
<div className="absolute bottom-3 left-3 flex flex-wrap gap-2">
|
||||
<span className="border border-[#d8d3c7] bg-white/90 px-2 py-1 font-mono text-[11px] text-[#5f5b52]">
|
||||
{t("source.marker")} {shortValue(scene?.source.deploy_readback_marker)}
|
||||
</span>
|
||||
<span className="border border-[#d8d3c7] bg-white/90 px-2 py-1 font-mono text-[11px] text-[#5f5b52]">
|
||||
{t("source.updated")}{" "}
|
||||
{updatedAt
|
||||
? updatedAt.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="grid min-w-0 gap-3">
|
||||
<section className="border border-[#d8d3c7] bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-[#141413]">
|
||||
{t("panels.rollups")}
|
||||
</h2>
|
||||
<Activity className="h-4 w-4 text-[#4a90d9]" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
{[
|
||||
["agents", scene?.rollups.agent_count],
|
||||
["workItems", scene?.rollups.work_item_count],
|
||||
["animated", scene?.rollups.animated_entity_count],
|
||||
["blocked", scene?.rollups.blocked_work_item_count],
|
||||
].map(([key, value]) => (
|
||||
<div key={String(key)} className="border border-[#e5e1d8] bg-[#fafafa] p-3">
|
||||
<p className="text-[11px] font-semibold text-[#77736a]">
|
||||
{t(`metrics.${key}`)}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold text-[#141413]">
|
||||
{formatNumber(Number(value ?? 0))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border border-[#d8d3c7] bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-[#141413]">
|
||||
{t("panels.boundaries")}
|
||||
</h2>
|
||||
<ShieldCheck
|
||||
className={cn("h-4 w-4", boundaryOk ? "text-[#17602a]" : "text-[#8a5a08]")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{[
|
||||
"raw_session_read_allowed",
|
||||
"sqlite_read_allowed",
|
||||
"secret_value_display_allowed",
|
||||
"runtime_action_performed",
|
||||
"host_or_k8s_write_performed",
|
||||
].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex min-w-0 items-center justify-between gap-3 border border-[#e5e1d8] px-3 py-2"
|
||||
>
|
||||
<span className="truncate font-mono text-[11px] text-[#5f5b52]">
|
||||
{key}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"border px-2 py-0.5 text-[10px] font-semibold",
|
||||
scene?.boundaries[key] === false
|
||||
? "border-[#8fc29a] bg-[#f0faf2] text-[#17602a]"
|
||||
: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
)}
|
||||
>
|
||||
{scene?.boundaries[key] === false ? t("boundary.closed") : t("boundary.open")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-h-[260px] border border-[#d8d3c7] bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-[#141413]">
|
||||
{t("panels.workItems")}
|
||||
</h2>
|
||||
<SearchCheck className="h-4 w-4 text-[#4a90d9]" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{workItems.length === 0 ? (
|
||||
<div className="border border-[#e5e1d8] bg-[#fafafa] p-4 text-sm text-[#77736a]">
|
||||
{t("empty")}
|
||||
</div>
|
||||
) : (
|
||||
workItems.slice(0, 6).map((item) => (
|
||||
<div key={item.work_item_id} className="border border-[#e5e1d8] p-3">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-[#141413]">
|
||||
{item.title || item.work_item_id}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 border px-2 py-0.5 text-[10px] font-semibold",
|
||||
stateClass(stateFromWorkItemStatus(item.status)),
|
||||
)}
|
||||
>
|
||||
{stateLabel(t, stateFromWorkItemStatus(item.status))}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate font-mono text-[11px] text-[#77736a]">
|
||||
{item.next_controlled_action || item.exit_criteria || "--"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<style jsx global>{`
|
||||
@keyframes openclaw-agent-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) translate3d(0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) translate3d(5px, -7px, 0);
|
||||
}
|
||||
}
|
||||
@keyframes openclaw-work-token-flow {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) translate3d(0, 0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) translate3d(9px, 4px, 0);
|
||||
}
|
||||
}
|
||||
.openclaw-agent {
|
||||
animation: openclaw-agent-drift 4.8s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
.openclaw-work-token {
|
||||
animation: openclaw-work-token-flow 6.2s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.openclaw-agent,
|
||||
.openclaw-work-token {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user