Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ services:

dashboard:
build:
context: .
context: /root/ai4math/Barchon/src/barchon
dockerfile: ui/Dockerfile
container_name: kip-dashboard
ports:
- "18080:8081"
volumes:
- .:/project
- .:/projects/KIP
- /root/ai4math/KIP-infra:/projects/KIP-infra
command: ["node", "server/dist/index.js", "--project", "/projects/KIP", "--port", "8081"]
restart: unless-stopped
8 changes: 5 additions & 3 deletions tools/kip-state/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,14 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--project", type=Path, default=Path.cwd(),
help="KIP project root (default: cwd)")
ap.add_argument("--db", type=Path, default=None,
help="Output SQLite path (default: <project>/.dashboard/state.db)")
help="Output SQLite path (default: <project>/.barchon/state.db)")
ap.add_argument("--lean-root", type=Path, default=None,
help="Lean source root (default: <project>/KIP)")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args(argv)

project: Path = args.project.resolve()
db_path: Path = args.db or (project / ".dashboard" / "state.db")
db_path: Path = args.db or (project / ".barchon" / "state.db")
db_path.parent.mkdir(parents=True, exist_ok=True)

log = (lambda *a, **k: None) if args.quiet else (lambda *a, **k: print(*a, **k, file=sys.stderr))
Expand All @@ -315,7 +317,7 @@ def main(argv: list[str] | None = None) -> int:

status_path = project / "blueprint" / "status.yaml"
chapters_dir = project / "blueprint" / "src" / "chapters"
lean_root = project / "KIP"
lean_root = args.lean_root.resolve() if args.lean_root else project / "KIP"
agents_dir = project / "agents"

log(f"[kip-state] project={project}")
Expand Down
35 changes: 31 additions & 4 deletions ui/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { useProject } from './hooks/useApi';
import { useProject, useProjects, useProjectSwitch } from './hooks/useApi';
import Overview from './views/Overview';
import LogViewer from './views/LogViewer';
import Nodes from './views/Nodes';
Expand All @@ -17,15 +17,42 @@ function ConnectionBanner({ isError }: { isError: boolean }) {
);
}

function ProjectSwitcher() {
const { data: project } = useProject();
const { data: projects } = useProjects();
const switcher = useProjectSwitch();

if (!projects || projects.length <= 1) {
return (
<span className="project-badge" title={project?.path}>
{project?.name ?? '…'}
</span>
);
}

return (
<select
className="project-switcher"
value={project?.path ?? ''}
onChange={(e) => switcher.mutate(e.target.value)}
disabled={switcher.isPending}
title={project?.path}
>
{projects.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
</select>
);
}

export default function App() {
const { data: project, isError } = useProject();
const { isError } = useProject();

return (
<div className="app">
<ConnectionBanner isError={isError} />
<header className="header">
<h1>KIP</h1>
{project && <span className="project-badge" title={project.path}>{project.name}</span>}
<ProjectSwitcher />
<nav className="header-nav">
<NavLink to="/" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`} end>Nodes</NavLink>
<NavLink to="/overview" className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}>Overview</NavLink>
Expand Down
27 changes: 27 additions & 0 deletions ui/client/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@ export function useProject() {
});
}

export function useProjects() {
return useQuery<{ name: string; path: string }[]>({
queryKey: ['projects'], queryFn: () => fetchJson('/api/projects'), staleTime: 30000,
});
}

export function useProjectSwitch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (projectPath: string) => {
const res = await fetch(apiUrl('/api/project/switch'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: projectPath }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error || `API ${res.status}`);
}
return res.json() as Promise<{ name: string; path: string; agentsPath: string }>;
},
onSuccess: () => {
qc.invalidateQueries();
},
});
}

export function useAgents() {
return useQuery<AgentSummary[]>({
queryKey: ['agents'], queryFn: () => fetchJson('/api/agents'), refetchInterval: 10000,
Expand Down
13 changes: 13 additions & 0 deletions ui/client/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ code, pre { font-family: var(--font-mono); }
cursor: default;
}

.project-switcher {
font-size: 11px; font-weight: 500; font-family: var(--font-mono);
padding: 2px 8px; border-radius: 3px;
background: rgba(3,102,214,0.08); color: var(--blue);
border: none; cursor: pointer;
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%230366d6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
padding-right: 20px;
}
.project-switcher:hover { background-color: rgba(3,102,214,0.14); }

.header-nav { display: flex; gap: 2px; }
.nav-link {
padding: 6px 12px;
Expand Down
20 changes: 17 additions & 3 deletions ui/client/src/views/Nodes.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import svgPanZoom from 'svg-pan-zoom';
import { useGraph, useGraphSvg } from '../hooks/useApi';
import { useGraph, useGraphSvg, useProject } from '../hooks/useApi';
import { PHASE_COLORS, PHASE_ORDER, PHASE_LABELS } from '../utils/constants';
import NodeDetail from '../components/NodeDetail';
import type { Phase } from '../types';
Expand Down Expand Up @@ -77,6 +77,18 @@ export default function Nodes() {
};

const { data: graph } = useGraph();
const { data: project } = useProject();

// Reset chapter state when switching projects so stale chaptersInit doesn't
// trigger an SVG fetch against the new (possibly empty) project.
const prevProjectPath = useRef<string | undefined>(undefined);
useEffect(() => {
if (prevProjectPath.current !== undefined && prevProjectPath.current !== project?.path) {
setChaptersInit(false);
setActiveChapters(new Set());
}
prevProjectPath.current = project?.path;
}, [project?.path]);

// Debounce search → 300 ms before re-fetching SVG
useEffect(() => {
Expand Down Expand Up @@ -170,11 +182,12 @@ export default function Nodes() {
[allChapters, activeChapters, chaptersInit],
);

const hasGraphData = (graph?.nodes?.length ?? 0) > 0;
const { data: svgText, isFetching, isError } = useGraphSvg({
phases: phasesArr,
chapters: chaptersArr,
q: debouncedSearch,
enabled: chaptersInit,
enabled: chaptersInit && hasGraphData,
});

// Mount the SVG, init pan-zoom, attach click bridging.
Expand Down Expand Up @@ -410,7 +423,8 @@ export default function Nodes() {
<div className={styles.canvasOverlay}>
{isFetching && 'rendering… '}
{isError && 'render failed (check server log)'}
{!isFetching && !isError && (
{!isFetching && !isError && chaptersInit && !hasGraphData && 'No graph data (state.db not built)'}
{!isFetching && !isError && hasGraphData && (
<>graph rendered server-side via <code>dot</code> · {totalNodes} nodes total</>
)}
</div>
Expand Down
48 changes: 43 additions & 5 deletions ui/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ export interface ProjectPaths {
agentsPath: string;
}

export interface ProjectInfo {
name: string;
path: string;
}

export interface ProjectState {
paths: ProjectPaths;
allProjects: ProjectInfo[];
}

function discoverProjects(primaryPath: string): ProjectInfo[] {
const parent = path.dirname(primaryPath);
const found: ProjectInfo[] = [];
try {
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const full = path.join(parent, entry.name);
const hasBarchon = fs.existsSync(path.join(full, '.barchon'));
const hasAgents = fs.existsSync(path.join(full, 'agents'));
if (hasBarchon || hasAgents) found.push({ name: entry.name, path: full });
}
} catch { /* ignore */ }
if (!found.find(p => p.path === primaryPath)) {
found.unshift({ name: path.basename(primaryPath), path: primaryPath });
}
found.sort((a, b) => {
if (a.path === primaryPath) return -1;
if (b.path === primaryPath) return 1;
return a.name.localeCompare(b.name);
});
return found;
}

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Expand All @@ -39,6 +72,11 @@ export async function createServer(options: { projectPath: string; port: number
agentsPath: path.join(projectPath, 'agents'),
};

const state: ProjectState = {
paths,
allProjects: discoverProjects(projectPath),
};

const fastify = Fastify({
logger: false,
rewriteUrl: (req) => {
Expand All @@ -59,11 +97,11 @@ export async function createServer(options: { projectPath: string; port: number
});
}

registerProject(fastify, paths);
registerAgents(fastify, paths);
registerLogs(fastify, paths);
registerSummary(fastify, paths);
registerNodes(fastify, paths);
registerProject(fastify, state);
registerAgents(fastify, state);
registerLogs(fastify, state);
registerSummary(fastify, state);
registerNodes(fastify, state);

await fastify.listen({ port, host: '0.0.0.0' });
return fastify;
Expand Down
8 changes: 4 additions & 4 deletions ui/server/src/routes/agents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import type { FastifyInstance } from 'fastify';
import type { ProjectPaths } from '../index.js';
import type { ProjectState } from '../index.js';
import type { AgentSummary, AgentRun } from '../types.js';

function listAgents(agentsPath: string): string[] {
Expand Down Expand Up @@ -53,10 +53,9 @@ function listRuns(agentsPath: string, agentName: string): AgentRun[] {
});
}

export function register(fastify: FastifyInstance, paths: ProjectPaths) {
const { agentsPath } = paths;

export function register(fastify: FastifyInstance, state: ProjectState) {
fastify.get('/api/agents', async (): Promise<AgentSummary[]> => {
const { agentsPath } = state.paths;
return listAgents(agentsPath).map(name => {
const runs = listRuns(agentsPath, name);
return {
Expand All @@ -68,6 +67,7 @@ export function register(fastify: FastifyInstance, paths: ProjectPaths) {
});

fastify.get<{ Params: { name: string } }>('/api/agents/:name/runs', async (req, reply) => {
const { agentsPath } = state.paths;
const { name } = req.params;
if (!fs.existsSync(path.join(agentsPath, name, 'logs'))) {
return reply.status(404).send({ error: 'Agent not found' });
Expand Down
9 changes: 5 additions & 4 deletions ui/server/src/routes/logs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import type { FastifyInstance } from 'fastify';
import type { ProjectPaths } from '../index.js';
import type { ProjectState } from '../index.js';
import { parseJsonl } from '../utils.js';

interface LogFileEntry { name: string; path: string; size: number; modified: string; agent?: string; runId?: string }
Expand All @@ -15,10 +15,9 @@ function resolveLogPath(agentsPath: string, logPath: string): string | null {
return full;
}

export function register(fastify: FastifyInstance, paths: ProjectPaths) {
const { agentsPath } = paths;

export function register(fastify: FastifyInstance, state: ProjectState) {
fastify.get('/api/logs', async () => {
const { agentsPath } = state.paths;
if (!fs.existsSync(agentsPath)) return { flat: [], groups: [] };

const groups: LogGroup[] = [];
Expand Down Expand Up @@ -67,6 +66,7 @@ export function register(fastify: FastifyInstance, paths: ProjectPaths) {
});

fastify.get('/api/logs/*', async (req, reply) => {
const { agentsPath } = state.paths;
const subpath = (req.params as Record<string, string>)['*'];
if (!subpath) return reply.status(400).send({ error: 'Missing path' });
const filePath = resolveLogPath(agentsPath, subpath);
Expand All @@ -75,6 +75,7 @@ export function register(fastify: FastifyInstance, paths: ProjectPaths) {
});

fastify.get('/api/log-stream/*', { websocket: true }, (socket, req) => {
const { agentsPath } = state.paths;
const subpath = (req.params as Record<string, string>)['*'] || '';
const filePath = resolveLogPath(agentsPath, subpath);
if (!filePath || !fs.existsSync(filePath)) {
Expand Down
Loading