feat(phase17): close technical debt — Dashboard real data, index.ts @deprecated, ADR streaming/auth
- Dashboard.tsx: removed 3 hardcoded mock constants (NODES/AGENTS/ACTIVITY_LOG)
- Swarm Nodes panel: real data from trpc.nodes.list (swarm nodes or containers)
- Container stats: live CPU%/MEM from trpc.nodes.stats, rendered as progress bars
- Active Agents panel: real agents from trpc.agents.list with isActive/isSystem/model/role
- Activity Feed: generated from active agents list (live agent names, models, timestamps)
- Metric cards: real counts from trpc.dashboard.stats (uptime, nodes, agents, gateway)
- All 3 panels have loading state (Loader2 spinner) and empty/error state
- Hero banner subtitle uses real stats.nodes and stats.agents counts
- Cluster Topology footer shows real uptime from dashboard.stats
- server/index.ts: documented as @deprecated legacy static-only entry point
- Added JSDoc block explaining this file is NOT the production server
- Points to server/_core/index.ts as the real server with tRPC/OAuth/seed
- Added console.log WARNING on startup to prevent accidental use
- File retained as historical artefact per Phase 17 decision
- todo.md: Phase 16 debt items closed as [x], Phase 17 section added
- ADR-001: Streaming LLM — status DEFERRED, Phase 18 plan documented
(Go Gateway stream:true + tRPC subscription + Chat.tsx EventSource)
- ADR-002: Authentication — status ACCEPTED as internal tool
(OAuth already partial; protectedProcedure path documented for future)
- Phase 9 routers.ts orchestrator migration verified as complete
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* Design: Grid of metric cards, node status, agent activity feed, cluster health
|
||||
* Colors: Cyan glow for primary metrics, green/amber/red for status
|
||||
* Typography: JetBrains Mono for all data values
|
||||
* Now with REAL Ollama API data integration
|
||||
* Data: 100% real tRPC data — nodes.list, nodes.stats, agents.list, dashboard.stats
|
||||
*/
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
@@ -28,40 +29,18 @@ import { trpc } from "@/lib/trpc";
|
||||
const HERO_BG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/hero-bg-Si4yCvZwFbZMP4XaHUueFi.webp";
|
||||
const SWARM_IMG = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/swarm-cluster-jkxdea5N7sXTSZfbAbKCfs.webp";
|
||||
|
||||
const NODES = [
|
||||
{ id: "node-01", name: "goclaw-manager-01", role: "Manager", status: "ready", cpu: 42, mem: 68, containers: 5, ip: "192.168.1.10" },
|
||||
{ id: "node-02", name: "goclaw-worker-01", role: "Worker", status: "ready", cpu: 28, mem: 45, containers: 3, ip: "192.168.1.11" },
|
||||
{ id: "node-03", name: "goclaw-worker-02", role: "Worker", status: "ready", cpu: 15, mem: 32, containers: 2, ip: "192.168.1.12" },
|
||||
{ id: "node-04", name: "goclaw-worker-03", role: "Worker", status: "drain", cpu: 0, mem: 12, containers: 0, ip: "192.168.1.13" },
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "agent-coder", name: "Coder Agent", model: "claude-3.5-sonnet", status: "running", tasks: 3, uptime: "2d 14h" },
|
||||
{ id: "agent-browser", name: "Browser Agent", model: "gpt-4o", status: "running", tasks: 1, uptime: "2d 14h" },
|
||||
{ id: "agent-mail", name: "Mail Agent", model: "gpt-4o-mini", status: "idle", tasks: 0, uptime: "2d 14h" },
|
||||
{ id: "agent-monitor", name: "Monitor Agent", model: "llama-3.1-8b", status: "running", tasks: 5, uptime: "2d 14h" },
|
||||
{ id: "agent-docs", name: "Docs Agent", model: "claude-3-haiku", status: "error", tasks: 0, uptime: "0h 0m" },
|
||||
];
|
||||
|
||||
const ACTIVITY_LOG = [
|
||||
{ time: "19:24:15", agent: "Coder Agent", action: "Завершил рефакторинг модуля memory.go", type: "success" },
|
||||
{ time: "19:23:48", agent: "Browser Agent", action: "Открыл https://github.com/goclaw/core", type: "info" },
|
||||
{ time: "19:22:11", agent: "Monitor Agent", action: "CPU на node-02 превысил 80% (пик)", type: "warning" },
|
||||
{ time: "19:21:30", agent: "Mail Agent", action: "Обработал 12 входящих писем", type: "success" },
|
||||
{ time: "19:20:05", agent: "Docs Agent", action: "Ошибка подключения к LLM API", type: "error" },
|
||||
{ time: "19:18:44", agent: "Coder Agent", action: "Создал новый скилл: ssh_executor.go", type: "success" },
|
||||
];
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
case "running":
|
||||
case "active":
|
||||
case "success":
|
||||
return "text-neon-green";
|
||||
case "idle":
|
||||
case "info":
|
||||
return "text-primary";
|
||||
case "drain":
|
||||
case "pause":
|
||||
case "warning":
|
||||
return "text-neon-amber";
|
||||
case "error":
|
||||
@@ -75,8 +54,10 @@ function getStatusColor(status: string) {
|
||||
function getStatusBadge(status: string) {
|
||||
const colors: Record<string, string> = {
|
||||
ready: "bg-neon-green/15 text-neon-green border-neon-green/30",
|
||||
active: "bg-neon-green/15 text-neon-green border-neon-green/30",
|
||||
running: "bg-neon-green/15 text-neon-green border-neon-green/30",
|
||||
idle: "bg-primary/15 text-primary border-primary/30",
|
||||
pause: "bg-neon-amber/15 text-neon-amber border-neon-amber/30",
|
||||
drain: "bg-neon-amber/15 text-neon-amber border-neon-amber/30",
|
||||
error: "bg-neon-red/15 text-neon-red border-neon-red/30",
|
||||
};
|
||||
@@ -84,18 +65,58 @@ function getStatusBadge(status: string) {
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
// Real data from Ollama API
|
||||
// ── Real API data ──────────────────────────────────────────────────────────
|
||||
const healthQuery = trpc.ollama.health.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const modelsQuery = trpc.ollama.models.useQuery(undefined, {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const dashboardStats = trpc.dashboard.stats.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const nodesQuery = trpc.nodes.list.useQuery(undefined, {
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const nodeStatsQuery = trpc.nodes.stats.useQuery(undefined, {
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const agentsQuery = trpc.agents.list.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// ── Derived values ─────────────────────────────────────────────────────────
|
||||
const ollamaConnected = healthQuery.data?.connected ?? false;
|
||||
const ollamaLatency = healthQuery.data?.latencyMs ?? 0;
|
||||
const modelCount = modelsQuery.data?.success ? modelsQuery.data.models.length : 0;
|
||||
|
||||
const stats = dashboardStats.data;
|
||||
const nodes = nodesQuery.data?.nodes ?? [];
|
||||
const containers = nodesQuery.data?.containers ?? [];
|
||||
const containerStats = nodeStatsQuery.data?.stats ?? [];
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const activeAgents = agents.filter((a) => a.isActive);
|
||||
|
||||
// Build per-container cpu/mem map from stats
|
||||
const statMap: Record<string, { cpuPct: number; memPct: number; memUseMB: number }> = {};
|
||||
for (const s of containerStats) {
|
||||
statMap[s.id] = { cpuPct: s.cpuPct, memPct: s.memPct, memUseMB: s.memUseMB };
|
||||
}
|
||||
|
||||
// Activity feed: last 6 agent metrics (most recent requests) — use real agents list
|
||||
// Since we don't have a global history endpoint on Dashboard, derive feed from agents
|
||||
const activityFeed = activeAgents.slice(0, 6).map((agent) => ({
|
||||
agent: agent.name,
|
||||
action: `Агент активен · модель ${agent.model}`,
|
||||
type: agent.isActive ? "success" : "info",
|
||||
time: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
|
||||
id: agent.id,
|
||||
}));
|
||||
|
||||
const nodesLoading = nodesQuery.isLoading;
|
||||
const agentsLoading = agentsQuery.isLoading;
|
||||
const statsLoading = dashboardStats.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero banner */}
|
||||
@@ -117,7 +138,11 @@ export default function Dashboard() {
|
||||
GoClaw Swarm Control Center
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1 font-mono">
|
||||
Кластер <span className="text-primary">goclaw-swarm</span> · 4 ноды · 7 агентов · Overlay Network: <span className="text-primary">goclaw-net</span>
|
||||
Кластер <span className="text-primary">goclaw-swarm</span>
|
||||
{!statsLoading && stats && (
|
||||
<> · {stats.nodes} нод · {stats.agents} агентов</>
|
||||
)}
|
||||
{" "}· Overlay Network: <span className="text-primary">goclaw-net</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +171,11 @@ export default function Dashboard() {
|
||||
<Badge variant="outline" className={`text-[9px] font-mono ${ollamaConnected ? "bg-neon-green/15 text-neon-green border-neon-green/30" : "bg-neon-red/15 text-neon-red border-neon-red/30"}`}>
|
||||
{healthQuery.isLoading ? "CHECKING..." : ollamaConnected ? "CONNECTED" : "OFFLINE"}
|
||||
</Badge>
|
||||
{stats?.gatewayOnline && (
|
||||
<Badge variant="outline" className="text-[9px] font-mono bg-primary/15 text-primary border-primary/30">
|
||||
GATEWAY OK
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground">
|
||||
https://ollama.com/v1
|
||||
@@ -172,21 +202,21 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Key metrics row */}
|
||||
{/* Key metrics row — now from dashboard.stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
icon={Server}
|
||||
label="Активные ноды"
|
||||
value="3 / 4"
|
||||
change="+0"
|
||||
value={statsLoading ? "..." : (stats?.nodes ?? `${nodes.length}`)}
|
||||
change={nodesQuery.data?.swarmActive ? "Swarm" : "Standalone"}
|
||||
trend="up"
|
||||
color="text-primary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Bot}
|
||||
label="Агенты"
|
||||
value="5"
|
||||
change="4 active"
|
||||
value={agentsLoading ? "..." : String(activeAgents.length)}
|
||||
change={`${agents.length} total`}
|
||||
trend="up"
|
||||
color="text-neon-green"
|
||||
/>
|
||||
@@ -210,121 +240,240 @@ export default function Dashboard() {
|
||||
|
||||
{/* Main grid: Nodes + Agents + Activity */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Nodes panel */}
|
||||
|
||||
{/* ── Nodes panel (real data) ───────────────────────────────────── */}
|
||||
<Card className="xl:col-span-1 bg-card border-border/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-primary" />
|
||||
Swarm Nodes
|
||||
{nodesLoading && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground ml-auto" />}
|
||||
{!nodesLoading && (
|
||||
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
|
||||
{nodes.length > 0 ? `${nodes.length} nodes` : containers.length > 0 ? `${containers.length} containers` : "no data"}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{NODES.map((node) => (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 rounded-md bg-secondary/30 border border-border/30 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${node.status === "ready" ? "bg-neon-green pulse-indicator" : node.status === "drain" ? "bg-neon-amber" : "bg-neon-red"}`} />
|
||||
<span className="font-mono text-xs font-medium text-foreground">{node.name}</span>
|
||||
{nodesLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span className="text-xs font-mono">Загрузка нод...</span>
|
||||
</div>
|
||||
) : nodes.length > 0 ? (
|
||||
nodes.map((node) => (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 rounded-md bg-secondary/30 border border-border/30 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
node.status === "ready" || node.availability === "active"
|
||||
? "bg-neon-green pulse-indicator"
|
||||
: node.availability === "drain"
|
||||
? "bg-neon-amber"
|
||||
: "bg-neon-red"
|
||||
}`} />
|
||||
<span className="font-mono text-xs font-medium text-foreground truncate max-w-[120px]" title={node.hostname}>
|
||||
{node.hostname}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(node.availability ?? node.status)}`}>
|
||||
{(node.availability ?? node.status).toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(node.status)}`}>
|
||||
{node.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[10px] font-mono">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU</span>
|
||||
<Progress value={node.cpu} className="h-1 mt-1" />
|
||||
<span className={`${node.cpu > 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.cpu}%</span>
|
||||
<div className="grid grid-cols-3 gap-2 text-[10px] font-mono">
|
||||
<div>
|
||||
<span className="text-muted-foreground">ROLE</span>
|
||||
<div className={`mt-1 ${node.role === "manager" ? "text-primary" : "text-foreground"}`}>
|
||||
{node.role}
|
||||
{node.isLeader && <span className="text-neon-amber ml-1">★</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU</span>
|
||||
<div className="text-foreground mt-1">{node.cpuCores}c</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">MEM</span>
|
||||
<div className="text-foreground mt-1">
|
||||
{node.memTotalMB > 1024 ? `${(node.memTotalMB / 1024).toFixed(1)}G` : `${node.memTotalMB}M`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">MEM</span>
|
||||
<Progress value={node.mem} className="h-1 mt-1" />
|
||||
<span className={`${node.mem > 70 ? "text-neon-amber" : "text-neon-green"}`}>{node.mem}%</span>
|
||||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{node.ip} · Docker {node.dockerVersion}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">CONTAINERS</span>
|
||||
<div className="text-foreground mt-1">{node.containers}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
))
|
||||
) : containers.length > 0 ? (
|
||||
// Standalone mode: show containers
|
||||
containers.slice(0, 4).map((c) => {
|
||||
const cs = containerStats.find((s) => s.id.startsWith(c.id.slice(0, 12)) || c.id.startsWith(s.id.slice(0, 12)));
|
||||
return (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 rounded-md bg-secondary/30 border border-border/30 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${c.state === "running" ? "bg-neon-green pulse-indicator" : "bg-neon-amber"}`} />
|
||||
<span className="font-mono text-xs font-medium text-foreground truncate max-w-[120px]" title={c.name}>
|
||||
{c.name.replace(/^\//, "")}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(c.state)}`}>
|
||||
{c.state.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
{cs && (
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] font-mono">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU</span>
|
||||
<Progress value={cs.cpuPct} className="h-1 mt-1" />
|
||||
<span className={cs.cpuPct > 70 ? "text-neon-amber" : "text-neon-green"}>{cs.cpuPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">MEM</span>
|
||||
<Progress value={cs.memPct} className="h-1 mt-1" />
|
||||
<span className={cs.memPct > 70 ? "text-neon-amber" : "text-neon-green"}>{cs.memUseMB.toFixed(0)}MB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
|
||||
<XCircle className="w-5 h-5 text-neon-red" />
|
||||
<span className="text-xs font-mono">
|
||||
{nodesQuery.data?.error ?? "Нет данных о нодах"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agents panel */}
|
||||
{/* ── Agents panel (real data) ──────────────────────────────────── */}
|
||||
<Card className="xl:col-span-1 bg-card border-border/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-primary" />
|
||||
Active Agents
|
||||
{agentsLoading && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground ml-auto" />}
|
||||
{!agentsLoading && (
|
||||
<span className="ml-auto text-[10px] font-mono text-muted-foreground">
|
||||
{activeAgents.length} / {agents.length}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{AGENTS.map((agent) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 rounded-md bg-secondary/30 border border-border/30"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor(agent.status).replace("text-", "bg-")} ${agent.status === "running" ? "pulse-indicator" : ""}`} />
|
||||
<span className="text-xs font-medium text-foreground">{agent.name}</span>
|
||||
{agentsLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span className="text-xs font-mono">Загрузка агентов...</span>
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
|
||||
<Bot className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-xs font-mono">Нет агентов в БД</span>
|
||||
</div>
|
||||
) : (
|
||||
agents.slice(0, 6).map((agent) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 rounded-md bg-secondary/30 border border-border/30"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
agent.isActive ? "bg-neon-green pulse-indicator" : "bg-neon-amber"
|
||||
}`} />
|
||||
<span className="text-xs font-medium text-foreground truncate max-w-[110px]">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{agent.isSystem && (
|
||||
<Badge variant="outline" className="text-[9px] font-mono bg-primary/10 text-primary border-primary/30 px-1 py-0">
|
||||
SYS
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(agent.isActive ? "active" : "pause")}`}>
|
||||
{agent.isActive ? "ACTIVE" : "PAUSED"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(agent.status)}`}>
|
||||
{agent.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground">
|
||||
<span>Model: <span className="text-primary">{agent.model}</span></span>
|
||||
<span>Tasks: <span className="text-foreground">{agent.tasks}</span></span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground">
|
||||
<span>Model: <span className="text-primary truncate max-w-[80px] inline-block align-bottom" title={agent.model}>{agent.model}</span></span>
|
||||
<span className="capitalize">{agent.role}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity feed */}
|
||||
{/* ── Activity feed (derived from real agents) ──────────────────── */}
|
||||
<Card className="xl:col-span-1 bg-card border-border/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
Activity Feed
|
||||
<span className="ml-auto text-[10px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
30s
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 relative">
|
||||
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50" />
|
||||
{ACTIVITY_LOG.map((entry, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="flex items-start gap-3 relative pl-5"
|
||||
>
|
||||
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full border-2 ${
|
||||
entry.type === "success" ? "border-neon-green bg-neon-green/20" :
|
||||
entry.type === "warning" ? "border-neon-amber bg-neon-amber/20" :
|
||||
entry.type === "error" ? "border-neon-red bg-neon-red/20" :
|
||||
"border-primary bg-primary/20"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{entry.time}</span>
|
||||
<span className="text-[11px] font-medium text-primary">{entry.agent}</span>
|
||||
{agentsLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span className="text-xs font-mono">Загрузка...</span>
|
||||
</div>
|
||||
) : activityFeed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
|
||||
<Activity className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-xs font-mono">Нет активности</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 relative">
|
||||
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50" />
|
||||
{activityFeed.map((entry, i) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="flex items-start gap-3 relative pl-5"
|
||||
>
|
||||
<div className={`absolute left-0 top-1.5 w-3.5 h-3.5 rounded-full border-2 ${
|
||||
entry.type === "success" ? "border-neon-green bg-neon-green/20" :
|
||||
entry.type === "warning" ? "border-neon-amber bg-neon-amber/20" :
|
||||
entry.type === "error" ? "border-neon-red bg-neon-red/20" :
|
||||
"border-primary bg-primary/20"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{entry.time}</span>
|
||||
<span className="text-[11px] font-medium text-primary truncate">{entry.agent}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.action}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.action}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -347,7 +496,10 @@ export default function Dashboard() {
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-card via-transparent to-card/50" />
|
||||
<div className="absolute bottom-4 left-6 right-6 flex items-center justify-between">
|
||||
<div className="font-mono text-[11px] text-muted-foreground">
|
||||
Overlay Network: <span className="text-primary">goclaw-net</span> · Subnet: 10.0.0.0/24
|
||||
Overlay Network: <span className="text-primary">goclaw-net</span>
|
||||
{stats && (
|
||||
<span className="ml-2 text-muted-foreground">· Uptime: <span className="text-primary">{stats.uptime}</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mono text-[10px]">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-neon-green" /> Manager</span>
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
/**
|
||||
* server/index.ts — LEGACY STATIC-ONLY ENTRY POINT
|
||||
*
|
||||
* @deprecated This file is NOT used in production or development.
|
||||
*
|
||||
* The real application server is: server/_core/index.ts
|
||||
* - Registers tRPC router (/api/trpc)
|
||||
* - Registers OAuth routes (/api/oauth/callback)
|
||||
* - Runs Vite middleware in development
|
||||
* - Serves pre-built static assets in production (dist/public)
|
||||
* - Seeds default agents on startup
|
||||
*
|
||||
* This file was the original minimal static server created before
|
||||
* tRPC integration. It has NO tRPC routes, NO OAuth, NO seed logic.
|
||||
*
|
||||
* Build entrypoint (tsconfig/vite.config) → server/_core/index.ts
|
||||
* Dockerfile CMD → node dist/index.js (compiled from _core/index.ts)
|
||||
*
|
||||
* ⚠️ DO NOT add business logic here.
|
||||
* DO NOT run this file directly in production.
|
||||
* It is kept as a historical artefact and may be removed in a future
|
||||
* cleanup phase (see todo.md Phase 17 — technical debt).
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import { createServer } from "http";
|
||||
import path from "path";
|
||||
@@ -18,15 +42,15 @@ async function startServer() {
|
||||
|
||||
app.use(express.static(staticPath));
|
||||
|
||||
// Handle client-side routing - serve index.html for all routes
|
||||
// Handle client-side routing — serve index.html for all routes
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(staticPath, "index.html"));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}/`);
|
||||
console.log(`[LEGACY] Static-only server running on http://localhost:${port}/`);
|
||||
console.log("[LEGACY] WARNING: This server has no tRPC routes. Use server/_core/index.ts instead.");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
45
todo.md
45
todo.md
@@ -218,9 +218,42 @@
|
||||
- [x] Fix gateway-proxy.ts: добавлено поле modelWarning в GatewayChatResult
|
||||
- [x] Fix Chat.tsx: добавлено отображение modelWarning в виде amber badge рядом с именем модели
|
||||
|
||||
### Замечания (технический долг)
|
||||
- [ ] Dashboard.tsx: секции "Swarm Nodes", "Active Agents", "Activity Feed" используют хардкоднутые моковые данные — нужно подключить к реальным tRPC endpoints (nodes.list, agents.list)
|
||||
- [ ] server/index.ts: дублирование — этот файл является продакшн-сборкой без tRPC, тогда как реальный сервер server/_core/index.ts. Нужно убрать или задокументировать назначение
|
||||
- [ ] Streaming: ответы LLM приходят целиком (нет SSE/streaming). Помечено в TODO с Phase 3, до сих пор не реализовано
|
||||
- [ ] Аутентификация: все tRPC endpoints используют publicProcedure — нет защиты. Приемлемо для внутреннего инструмента, но нужно задокументировать решение
|
||||
- [ ] Phase 9 TODO: server/routers.ts частично обновлён — orchestrator.ts вызовы заменены на gateway-proxy.ts, но остался неполный пункт "replace orchestrator.ts calls" — проверить актуальность
|
||||
### Замечания (технический долг) → закрыто в Phase 17
|
||||
- [x] Dashboard.tsx: секции "Swarm Nodes", "Active Agents", "Activity Feed" подключены к реальным tRPC (nodes.list, nodes.stats, agents.list, dashboard.stats) — моки NODES/AGENTS/ACTIVITY_LOG удалены
|
||||
- [x] server/index.ts: добавлен @deprecated JSDoc-заголовок с объяснением назначения файла и указанием на реальный сервер server/_core/index.ts
|
||||
- [x] Phase 9 TODO: проверено — orchestrator.ts вызовы в routers.ts заменены на gateway-proxy.ts, пункт актуальности снят
|
||||
|
||||
## Phase 17: Technical Debt Closure (2026-03-21)
|
||||
|
||||
### Исправлено
|
||||
- [x] Dashboard.tsx полностью переведён на реальные данные:
|
||||
- nodes.list → отображает Swarm-ноды или контейнеры в standalone-режиме с CPU/MEM gauge
|
||||
- nodes.stats → live CPU% и MEM для каждого контейнера
|
||||
- agents.list → реальные агенты с isActive/isSystem/model/role
|
||||
- dashboard.stats → uptime, nodes count, agents count, gateway status
|
||||
- Activity Feed генерируется из активных агентов (реальное время)
|
||||
- Все три секции имеют loading state (Loader2 spinner) и empty state
|
||||
- [x] server/index.ts: задокументирован как @deprecated legacy static-only entry point,
|
||||
с указанием: реальный сервер = server/_core/index.ts; содержит предупреждение в console.log
|
||||
|
||||
### Архитектурные решения (ADR — не требуют реализации сейчас)
|
||||
|
||||
#### ADR-001: Streaming LLM responses
|
||||
- **Статус**: ОТЛОЖЕНО (accepted: deferred)
|
||||
- **Контекст**: ответы LLM приходят целиком (non-streaming). Chat UI показывает индикатор "Thinking..." пока не придёт весь ответ
|
||||
- **Решение**: реализовать SSE (Server-Sent Events) в отдельной Phase 18
|
||||
- Go Gateway: заменить `ChatResponse` на `stream: true` + chunked JSON decoder
|
||||
- tRPC: добавить отдельный `orchestrator.chatStream` subscription (или REST SSE endpoint)
|
||||
- Chat.tsx: показывать токены по мере поступления через EventSource / tRPC subscription
|
||||
- **Риски**: нужен рефактор tool-use loop в orchestrator.go для поддержки промежуточного стриминга
|
||||
- **Приоритет**: средний — UX улучшение, не блокирует работу
|
||||
|
||||
#### ADR-002: Authentication / Authorization
|
||||
- **Статус**: ПРИНЯТО как внутренний инструмент (accepted: internal tool)
|
||||
- **Контекст**: все tRPC endpoints используют `publicProcedure` — нет аутентификации
|
||||
- **Решение**: приемлемо для внутреннего инструмента, доступного только в закрытой сети
|
||||
- Если нужна защита: добавить `protectedProcedure` с JWT middleware в server/_core/context.ts
|
||||
- OAuth уже частично реализован (server/_core/oauth.ts, OAUTH_SERVER_URL env var)
|
||||
- При активации: заменить `publicProcedure` на `protectedProcedure` во всех роутерах
|
||||
- **Риски**: текущая архитектура позволяет любому в сети вызывать shell_exec, file_write
|
||||
- **Приоритет**: высокий — если сервис будет доступен публично
|
||||
|
||||
Reference in New Issue
Block a user