Initial implementation of vault-os
Complete implementation across all 13 phases: - vault-core: types, YAML frontmatter parsing, entity classification, filesystem ops, config, prompt composition, validation, search - vault-watch: filesystem watcher with daemon write filtering, event classification - vault-scheduler: cron engine, process executor, task runner with retry logic and concurrency limiting - vault-api: Axum REST API (15 route modules), WebSocket with broadcast, AI assistant proxy, validation, templates - Dashboard: React + TypeScript + Tailwind v4 with kanban, CodeMirror editor, dynamic view system, AI chat sidebar - Nix flake with dev shell and NixOS module - Graceful shutdown, inotify overflow recovery, tracing instrumentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
f820a72b04
123 changed files with 18288 additions and 0 deletions
24
dashboard/.gitignore
vendored
Normal file
24
dashboard/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
dashboard/README.md
Normal file
73
dashboard/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
dashboard/eslint.config.js
Normal file
23
dashboard/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
dashboard/index.html
Normal file
13
dashboard/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4235
dashboard/package-lock.json
generated
Normal file
4235
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
dashboard/package.json
Normal file
43
dashboard/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.16",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"codemirror": "^6.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
47
dashboard/src/App.tsx
Normal file
47
dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Layout } from './components/Layout';
|
||||
import { TasksPage } from './pages/TasksPage';
|
||||
import { AgentsPage } from './pages/AgentsPage';
|
||||
import { CronsPage } from './pages/CronsPage';
|
||||
import { AgentQueuePage } from './pages/AgentQueuePage';
|
||||
import { KnowledgePage } from './pages/KnowledgePage';
|
||||
import { EditorPage } from './pages/EditorPage';
|
||||
import { ViewPage } from './pages/ViewPage';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 5000, retry: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
function AppInner() {
|
||||
useWebSocket();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<CommandPalette />
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<TasksPage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/crons" element={<CronsPage />} />
|
||||
<Route path="/queue" element={<AgentQueuePage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/editor" element={<EditorPage />} />
|
||||
<Route path="/view/*" element={<ViewPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInner />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
152
dashboard/src/api/client.ts
Normal file
152
dashboard/src/api/client.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type {
|
||||
Agent,
|
||||
AgentTask,
|
||||
CronJob,
|
||||
HumanTask,
|
||||
KnowledgeNote,
|
||||
Skill,
|
||||
TreeNode,
|
||||
VaultStats,
|
||||
HealthStatus,
|
||||
ViewPageDef,
|
||||
ViewDetail,
|
||||
NotificationItem,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Agents
|
||||
export const listAgents = () => fetchJson<Agent[]>(`${BASE}/agents`);
|
||||
export const getAgent = (name: string) => fetchJson<Agent>(`${BASE}/agents/${name}`);
|
||||
export const triggerAgent = (name: string, context?: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/agents/${name}/trigger`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ context }),
|
||||
});
|
||||
|
||||
// Skills
|
||||
export const listSkills = () => fetchJson<Skill[]>(`${BASE}/skills`);
|
||||
export const getSkill = (name: string) => fetchJson<Skill>(`${BASE}/skills/${name}`);
|
||||
export const skillUsedBy = (name: string) => fetchJson<string[]>(`${BASE}/skills/${name}/used-by`);
|
||||
|
||||
// Crons
|
||||
export const listCrons = () => fetchJson<CronJob[]>(`${BASE}/crons`);
|
||||
export const triggerCron = (name: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/crons/${name}/trigger`, { method: 'POST' });
|
||||
export const pauseCron = (name: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/crons/${name}/pause`, { method: 'POST' });
|
||||
export const resumeCron = (name: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/crons/${name}/resume`, { method: 'POST' });
|
||||
|
||||
// Human Tasks
|
||||
export const listHumanTasks = () => fetchJson<HumanTask[]>(`${BASE}/todos/harald`);
|
||||
export const listHumanTasksByStatus = (status: string) =>
|
||||
fetchJson<HumanTask[]>(`${BASE}/todos/harald/${status}`);
|
||||
export const createHumanTask = (task: {
|
||||
title: string;
|
||||
priority?: string;
|
||||
labels?: string[];
|
||||
body?: string;
|
||||
}) =>
|
||||
fetchJson<{ status: string; path: string }>(`${BASE}/todos/harald`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(task),
|
||||
});
|
||||
export const moveHumanTask = (status: string, id: string, to: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/todos/harald/${status}/${id}/move`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ to }),
|
||||
});
|
||||
export const deleteHumanTask = (status: string, id: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/todos/harald/${status}/${id}`, { method: 'DELETE' });
|
||||
|
||||
// Agent Tasks
|
||||
export const listAgentTasks = () => fetchJson<AgentTask[]>(`${BASE}/todos/agent`);
|
||||
export const getAgentTask = (id: string) => fetchJson<AgentTask>(`${BASE}/todos/agent/${id}`);
|
||||
export const createAgentTask = (task: {
|
||||
title: string;
|
||||
agent: string;
|
||||
priority?: string;
|
||||
body?: string;
|
||||
}) =>
|
||||
fetchJson<{ status: string; path: string }>(`${BASE}/todos/agent`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(task),
|
||||
});
|
||||
|
||||
// Knowledge
|
||||
export const listKnowledge = (q?: string, tag?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (tag) params.set('tag', tag);
|
||||
const qs = params.toString();
|
||||
return fetchJson<KnowledgeNote[]>(`${BASE}/knowledge${qs ? `?${qs}` : ''}`);
|
||||
};
|
||||
export const getKnowledge = (path: string) =>
|
||||
fetchJson<{ path: string; frontmatter: unknown; body: string; html: string }>(
|
||||
`${BASE}/knowledge/${path}`,
|
||||
);
|
||||
|
||||
// Files
|
||||
export const readFile = (path: string) =>
|
||||
fetchJson<{ path: string; frontmatter: unknown; body: string }>(`${BASE}/files/${path}`);
|
||||
export const writeFile = (path: string, data: { frontmatter?: unknown; body?: string; raw?: string }) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/files/${path}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const patchFile = (path: string, updates: Record<string, unknown>) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/files/${path}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
export const deleteFile = (path: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/files/${path}`, { method: 'DELETE' });
|
||||
|
||||
// Tree
|
||||
export const getTree = () => fetchJson<TreeNode>(`${BASE}/tree`);
|
||||
|
||||
// Suggest
|
||||
export const suggestAgents = () => fetchJson<string[]>(`${BASE}/suggest/agents`);
|
||||
export const suggestSkills = () => fetchJson<string[]>(`${BASE}/suggest/skills`);
|
||||
export const suggestTags = () => fetchJson<string[]>(`${BASE}/suggest/tags`);
|
||||
export const suggestFiles = (q?: string) =>
|
||||
fetchJson<string[]>(`${BASE}/suggest/files${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
||||
export const suggestModels = () => fetchJson<string[]>(`${BASE}/suggest/models`);
|
||||
export const suggestMcpServers = () => fetchJson<string[]>(`${BASE}/suggest/mcp-servers`);
|
||||
|
||||
// Stats
|
||||
export const getStats = () => fetchJson<VaultStats>(`${BASE}/stats`);
|
||||
export const getActivity = () =>
|
||||
fetchJson<{ path: string; kind: string; modified: string; name: string }[]>(`${BASE}/activity`);
|
||||
export const getHealth = () => fetchJson<HealthStatus>(`${BASE}/health`);
|
||||
|
||||
// Views
|
||||
export const listViewPages = () => fetchJson<ViewPageDef[]>(`${BASE}/views/pages`);
|
||||
export const listViewWidgets = () => fetchJson<ViewPageDef[]>(`${BASE}/views/widgets`);
|
||||
export const listViewLayouts = () => fetchJson<ViewPageDef[]>(`${BASE}/views/layouts`);
|
||||
export const getView = (path: string) => fetchJson<ViewDetail>(`${BASE}/views/${path}`);
|
||||
export const putView = (path: string, data: { frontmatter?: unknown; body?: string; raw?: string }) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/views/${path}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export const deleteView = (path: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/views/${path}`, { method: 'DELETE' });
|
||||
|
||||
// Notifications
|
||||
export const listNotifications = () => fetchJson<NotificationItem[]>(`${BASE}/notifications`);
|
||||
export const dismissNotification = (id: string) =>
|
||||
fetchJson<{ status: string }>(`${BASE}/notifications/${id}`, { method: 'DELETE' });
|
||||
161
dashboard/src/api/types.ts
Normal file
161
dashboard/src/api/types.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
export type Priority = 'urgent' | 'high' | 'medium' | 'low';
|
||||
export type TaskStatus = 'urgent' | 'open' | 'in-progress' | 'done';
|
||||
export type AgentTaskStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
export type RunStatus = 'success' | 'failure' | 'timeout';
|
||||
|
||||
export interface Agent {
|
||||
name: string;
|
||||
executable: string;
|
||||
model?: string;
|
||||
escalate_to?: string;
|
||||
mcp_servers: string[];
|
||||
skills: string[];
|
||||
timeout: number;
|
||||
max_retries: number;
|
||||
env: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
version?: number;
|
||||
requires_mcp: string[];
|
||||
inputs: string[];
|
||||
outputs: string[];
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
name: string;
|
||||
title: string;
|
||||
schedule: string;
|
||||
agent: string;
|
||||
enabled: boolean;
|
||||
status: 'active' | 'paused';
|
||||
last_run?: string;
|
||||
last_status?: RunStatus;
|
||||
next_run?: string;
|
||||
run_count: number;
|
||||
}
|
||||
|
||||
export interface HumanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: Priority;
|
||||
status: TaskStatus;
|
||||
source?: string;
|
||||
repo?: string;
|
||||
labels: string[];
|
||||
created: string;
|
||||
due?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
title: string;
|
||||
agent: string;
|
||||
priority: Priority;
|
||||
type?: string;
|
||||
status: AgentTaskStatus;
|
||||
created: string;
|
||||
started?: string;
|
||||
completed?: string;
|
||||
retry: number;
|
||||
max_retries: number;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeNote {
|
||||
path: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
export interface VaultStats {
|
||||
agents: number;
|
||||
skills: number;
|
||||
crons_scheduled: number;
|
||||
human_tasks: Record<TaskStatus, number>;
|
||||
agent_tasks: Record<AgentTaskStatus, number>;
|
||||
knowledge_notes: number;
|
||||
total_tasks_executed: number;
|
||||
total_cron_fires: number;
|
||||
}
|
||||
|
||||
export interface WsEvent {
|
||||
type: string;
|
||||
area: string;
|
||||
path: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
version: string;
|
||||
uptime_secs: number;
|
||||
agents: number;
|
||||
crons_scheduled: number;
|
||||
total_tasks_executed: number;
|
||||
}
|
||||
|
||||
// View system types
|
||||
|
||||
export interface ViewPageDef {
|
||||
name: string;
|
||||
type: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
route?: string;
|
||||
position?: number;
|
||||
layout?: string;
|
||||
component?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WidgetInstanceDef {
|
||||
widget: string;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ViewRegions {
|
||||
[region: string]: WidgetInstanceDef[];
|
||||
}
|
||||
|
||||
export interface ViewDetail {
|
||||
path: string;
|
||||
frontmatter: {
|
||||
type: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
route?: string;
|
||||
position?: number;
|
||||
layout?: string;
|
||||
regions?: ViewRegions;
|
||||
name?: string;
|
||||
description?: string;
|
||||
component?: string;
|
||||
} | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
level?: string;
|
||||
source?: string;
|
||||
created?: string;
|
||||
expires?: string;
|
||||
}
|
||||
80
dashboard/src/api/ws.ts
Normal file
80
dashboard/src/api/ws.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { WsEvent } from './types';
|
||||
|
||||
type Listener = (event: WsEvent) => void;
|
||||
|
||||
export class VaultWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners: Map<string, Set<Listener>> = new Map();
|
||||
private globalListeners: Set<Listener> = new Set();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private url: string;
|
||||
|
||||
constructor(url?: string) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.url = url || `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onmessage = (msg) => {
|
||||
try {
|
||||
const event: WsEvent = JSON.parse(msg.data);
|
||||
this.globalListeners.forEach((fn) => fn(event));
|
||||
this.listeners.get(event.type)?.forEach((fn) => fn(event));
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
/** Listen to a specific event type */
|
||||
on(type: string, fn: Listener): () => void {
|
||||
if (!this.listeners.has(type)) {
|
||||
this.listeners.set(type, new Set());
|
||||
}
|
||||
this.listeners.get(type)!.add(fn);
|
||||
return () => this.listeners.get(type)?.delete(fn);
|
||||
}
|
||||
|
||||
/** Listen to all events */
|
||||
onAny(fn: Listener): () => void {
|
||||
this.globalListeners.add(fn);
|
||||
return () => this.globalListeners.delete(fn);
|
||||
}
|
||||
|
||||
send(action: Record<string, unknown>) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(action));
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
export const vaultWs = new VaultWebSocket();
|
||||
39
dashboard/src/components/ActivityFeed.tsx
Normal file
39
dashboard/src/components/ActivityFeed.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useActivity } from '../hooks/useApi';
|
||||
|
||||
export function ActivityFeed() {
|
||||
const { data: activity, isLoading } = useActivity();
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-text-muted">Loading...</div>;
|
||||
if (!activity?.length) return <div className="p-4 text-sm text-text-muted">No recent activity</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{activity.slice(0, 20).map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-4 py-1.5 text-xs">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${kindColor(item.kind)}`} />
|
||||
<span className="flex-1 truncate text-text-secondary">{item.name}</span>
|
||||
<span className="text-text-muted">{timeAgo(item.modified)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function kindColor(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'human_task': return 'bg-accent';
|
||||
case 'agent_task': return 'bg-warning';
|
||||
case 'knowledge': return 'bg-success';
|
||||
default: return 'bg-text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return 'now';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h`;
|
||||
return `${Math.floor(hrs / 24)}d`;
|
||||
}
|
||||
42
dashboard/src/components/AgentCard.tsx
Normal file
42
dashboard/src/components/AgentCard.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Agent } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
onTrigger: (name: string) => void;
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, onTrigger }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-raised p-4 transition-colors hover:border-border-hover">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{agent.name}</h3>
|
||||
<button
|
||||
onClick={() => onTrigger(agent.name)}
|
||||
className="rounded bg-accent/15 px-2 py-0.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
Trigger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-xs text-text-secondary">
|
||||
<span className="mr-3">{agent.executable}</span>
|
||||
{agent.model && <span className="text-text-muted">{agent.model}</span>}
|
||||
</div>
|
||||
|
||||
{agent.skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.skills.map((s) => (
|
||||
<span key={s} className="rounded bg-surface-overlay px-1.5 py-0.5 text-xs text-text-secondary">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-text-muted">
|
||||
<span>timeout: {agent.timeout}s</span>
|
||||
{agent.max_retries > 0 && <span>retries: {agent.max_retries}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
dashboard/src/components/CommandPalette.tsx
Normal file
94
dashboard/src/components/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const commands: Command[] = [
|
||||
{ id: 'new-task', label: 'New Human Task', action: () => navigate('/editor?new=todos/harald/open') },
|
||||
{ id: 'new-agent-task', label: 'New Agent Task', action: () => navigate('/editor?new=todos/agent/queued') },
|
||||
{ id: 'new-agent', label: 'New Agent', action: () => navigate('/editor?new=agents') },
|
||||
{ id: 'new-skill', label: 'New Skill', action: () => navigate('/editor?new=skills') },
|
||||
{ id: 'new-cron', label: 'New Cron Job', action: () => navigate('/editor?new=crons/active') },
|
||||
{ id: 'new-note', label: 'New Knowledge Note', action: () => navigate('/editor?new=knowledge') },
|
||||
{ id: 'nav-tasks', label: 'Go to Tasks', action: () => navigate('/') },
|
||||
{ id: 'nav-agents', label: 'Go to Agents', action: () => navigate('/agents') },
|
||||
{ id: 'nav-crons', label: 'Go to Crons', action: () => navigate('/crons') },
|
||||
{ id: 'nav-queue', label: 'Go to Agent Queue', action: () => navigate('/queue') },
|
||||
{ id: 'nav-knowledge', label: 'Go to Knowledge', action: () => navigate('/knowledge') },
|
||||
{ id: 'nav-editor', label: 'Open Editor', action: () => navigate('/editor') },
|
||||
];
|
||||
|
||||
const filtered = query
|
||||
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: commands;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
setQuery('');
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setOpen(false)}>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
<div
|
||||
className="relative w-full max-w-md rounded-lg border border-border bg-surface-raised shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full rounded-t-lg border-b border-border bg-transparent px-4 py-3 text-sm text-text-primary outline-none"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered.length > 0) {
|
||||
filtered[0].action();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-64 overflow-auto py-1">
|
||||
{filtered.map((cmd) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text-primary"
|
||||
onClick={() => {
|
||||
cmd.action();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{cmd.label}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-text-muted">No matching commands</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
dashboard/src/components/CronRow.tsx
Normal file
44
dashboard/src/components/CronRow.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { CronJob } from '../api/types';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
interface Props {
|
||||
cron: CronJob;
|
||||
onTrigger: (name: string) => void;
|
||||
onToggle: (name: string, active: boolean) => void;
|
||||
}
|
||||
|
||||
export function CronRow({ cron, onTrigger, onToggle }: Props) {
|
||||
const isActive = cron.status === 'active';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-text-primary">{cron.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
|
||||
<code className="rounded bg-surface-overlay px-1.5 py-0.5">{cron.schedule}</code>
|
||||
<span>agent: {cron.agent}</span>
|
||||
<span>runs: {cron.run_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{cron.last_status && <StatusBadge value={cron.last_status} />}
|
||||
<StatusBadge value={cron.status} />
|
||||
|
||||
<button
|
||||
onClick={() => onToggle(cron.name, isActive)}
|
||||
className="rounded px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-surface-overlay"
|
||||
>
|
||||
{isActive ? 'Pause' : 'Resume'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onTrigger(cron.name)}
|
||||
className="rounded bg-accent/15 px-2 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
Fire
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
dashboard/src/components/FileTree.tsx
Normal file
89
dashboard/src/components/FileTree.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useState } from 'react';
|
||||
import type { TreeNode } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
tree: TreeNode;
|
||||
selectedPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onCreateFile?: (dir: string) => void;
|
||||
}
|
||||
|
||||
export function FileTree({ tree, selectedPath, onSelect, onCreateFile }: Props) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{tree.children?.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onCreateFile={onCreateFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeItem({
|
||||
node,
|
||||
depth,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onCreateFile,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
selectedPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onCreateFile?: (dir: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth < 1);
|
||||
const isDir = node.type === 'directory';
|
||||
const isSelected = node.path === selectedPath;
|
||||
const pad = `${depth * 12 + 8}px`;
|
||||
|
||||
if (isDir) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center py-0.5 pr-2 text-text-secondary hover:bg-surface-overlay"
|
||||
style={{ paddingLeft: pad }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onCreateFile?.(node.path);
|
||||
}}
|
||||
>
|
||||
<span className="mr-1 text-xs text-text-muted">{expanded ? '\u25BE' : '\u25B8'}</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</div>
|
||||
{expanded &&
|
||||
node.children?.map((child) => (
|
||||
<TreeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
onCreateFile={onCreateFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`cursor-pointer truncate py-0.5 pr-2 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-secondary hover:bg-surface-overlay hover:text-text-primary'
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
{node.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
dashboard/src/components/Kanban.tsx
Normal file
77
dashboard/src/components/Kanban.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
type DropResult,
|
||||
} from '@hello-pangea/dnd';
|
||||
import type { HumanTask, TaskStatus } from '../api/types';
|
||||
import { TaskCard } from './TaskCard';
|
||||
|
||||
const COLUMNS: { id: TaskStatus; label: string }[] = [
|
||||
{ id: 'urgent', label: 'Urgent' },
|
||||
{ id: 'open', label: 'Open' },
|
||||
{ id: 'in-progress', label: 'In Progress' },
|
||||
{ id: 'done', label: 'Done' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
tasks: HumanTask[];
|
||||
onMove: (id: string, fromStatus: string, toStatus: string) => void;
|
||||
}
|
||||
|
||||
export function Kanban({ tasks, onMove }: Props) {
|
||||
const byStatus = (status: TaskStatus) => tasks.filter((t) => t.status === status);
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const fromStatus = result.source.droppableId;
|
||||
const toStatus = result.destination.droppableId;
|
||||
if (fromStatus === toStatus) return;
|
||||
onMove(result.draggableId, fromStatus, toStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="flex gap-4 p-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const items = byStatus(col.id);
|
||||
return (
|
||||
<div key={col.id} className="flex w-72 shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<h3 className="text-sm font-semibold text-text-secondary">{col.label}</h3>
|
||||
<span className="text-xs text-text-muted">{items.length}</span>
|
||||
</div>
|
||||
|
||||
<Droppable droppableId={col.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`flex min-h-[200px] flex-col gap-2 rounded-lg border border-border p-2 transition-colors ${
|
||||
snapshot.isDraggingOver ? 'border-accent/40 bg-accent/5' : 'bg-surface'
|
||||
}`}
|
||||
>
|
||||
{items.map((task, idx) => (
|
||||
<Draggable key={task.id} draggableId={task.id} index={idx}>
|
||||
{(prov) => (
|
||||
<div
|
||||
ref={prov.innerRef}
|
||||
{...prov.draggableProps}
|
||||
{...prov.dragHandleProps}
|
||||
>
|
||||
<TaskCard task={task} />
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
13
dashboard/src/components/Layout.tsx
Normal file
13
dashboard/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Outlet } from 'react-router-dom';
|
||||
import { NavigationSidebar } from './NavigationSidebar';
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<NavigationSidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
dashboard/src/components/NavigationSidebar.tsx
Normal file
96
dashboard/src/components/NavigationSidebar.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import { useHealth } from '../hooks/useApi';
|
||||
import { useViewPages } from '../views/ViewRenderer';
|
||||
|
||||
/** Static built-in navigation entries */
|
||||
const builtinNav = [
|
||||
{ to: '/', label: 'Tasks', icon: '/' },
|
||||
{ to: '/agents', label: 'Agents' },
|
||||
{ to: '/crons', label: 'Crons' },
|
||||
{ to: '/queue', label: 'Queue' },
|
||||
{ to: '/knowledge', label: 'Knowledge' },
|
||||
{ to: '/editor', label: 'Editor' },
|
||||
];
|
||||
|
||||
export function NavigationSidebar() {
|
||||
const { data: health } = useHealth();
|
||||
const { data: viewPages } = useViewPages();
|
||||
|
||||
// Build dynamic nav entries from view pages, sorted by position
|
||||
const dynamicNav = (viewPages || [])
|
||||
.filter((p) => p.route && p.title)
|
||||
.sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
|
||||
.map((p) => ({
|
||||
to: p.route!.startsWith('/') ? p.route! : `/${p.route}`,
|
||||
label: p.title || p.name,
|
||||
icon: p.icon,
|
||||
}));
|
||||
|
||||
return (
|
||||
<aside className="flex w-52 shrink-0 flex-col border-r border-border bg-surface-raised">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<span className="text-lg font-bold text-accent">vault:os</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-auto px-2 py-3">
|
||||
<div className="space-y-0.5">
|
||||
{builtinNav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent font-medium'
|
||||
: 'text-text-secondary hover:bg-surface-overlay hover:text-text-primary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dynamicNav.length > 0 && (
|
||||
<>
|
||||
<div className="my-3 border-t border-border" />
|
||||
<div className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
Views
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{dynamicNav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent font-medium'
|
||||
: 'text-text-secondary hover:bg-surface-overlay hover:text-text-primary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon && <span className="mr-1.5">{item.icon}</span>}
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 text-xs text-text-muted">
|
||||
{health ? (
|
||||
<>
|
||||
<div>v{health.version}</div>
|
||||
<div>{health.agents} agents</div>
|
||||
<div>{health.crons_scheduled} crons</div>
|
||||
</>
|
||||
) : (
|
||||
<div>connecting...</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
49
dashboard/src/components/NotificationBanner.tsx
Normal file
49
dashboard/src/components/NotificationBanner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { listNotifications, dismissNotification } from '../api/client';
|
||||
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
info: 'bg-accent/10 border-accent/30 text-accent',
|
||||
warning: 'bg-warning/10 border-warning/30 text-warning',
|
||||
error: 'bg-danger/10 border-danger/30 text-danger',
|
||||
success: 'bg-success/10 border-success/30 text-success',
|
||||
};
|
||||
|
||||
export function NotificationBanner() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: notifications } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: listNotifications,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationFn: dismissNotification,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
});
|
||||
|
||||
if (!notifications?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-6 pt-4">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${
|
||||
LEVEL_STYLES[n.level || 'info'] || LEVEL_STYLES.info
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{n.title}</div>
|
||||
{n.message && <div className="mt-0.5 text-xs opacity-80">{n.message}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss.mutate(n.id)}
|
||||
className="shrink-0 text-xs opacity-60 hover:opacity-100"
|
||||
>
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
dashboard/src/components/StatusBadge.tsx
Normal file
26
dashboard/src/components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const styles: Record<string, string> = {
|
||||
urgent: 'bg-urgent/20 text-urgent',
|
||||
high: 'bg-danger/20 text-danger',
|
||||
medium: 'bg-warning/20 text-warning',
|
||||
low: 'bg-text-muted/20 text-text-secondary',
|
||||
open: 'bg-accent/20 text-accent',
|
||||
'in-progress': 'bg-warning/20 text-warning',
|
||||
done: 'bg-success/20 text-success',
|
||||
queued: 'bg-text-muted/20 text-text-secondary',
|
||||
running: 'bg-accent/20 text-accent',
|
||||
failed: 'bg-danger/20 text-danger',
|
||||
active: 'bg-success/20 text-success',
|
||||
paused: 'bg-text-muted/20 text-text-secondary',
|
||||
success: 'bg-success/20 text-success',
|
||||
failure: 'bg-danger/20 text-danger',
|
||||
timeout: 'bg-warning/20 text-warning',
|
||||
};
|
||||
|
||||
export function StatusBadge({ value }: { value: string }) {
|
||||
const cls = styles[value] || 'bg-surface-overlay text-text-secondary';
|
||||
return (
|
||||
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
35
dashboard/src/components/TaskCard.tsx
Normal file
35
dashboard/src/components/TaskCard.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { HumanTask } from '../api/types';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
|
||||
export function TaskCard({ task }: { task: HumanTask }) {
|
||||
const age = timeAgo(task.created);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-raised p-3 transition-colors hover:border-border-hover">
|
||||
<div className="mb-1.5 text-sm font-medium text-text-primary">{task.title}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge value={task.priority} />
|
||||
{task.labels.map((l) => (
|
||||
<span key={l} className="rounded bg-surface-overlay px-1.5 py-0.5 text-xs text-text-secondary">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-text-muted">
|
||||
<span>{age}</span>
|
||||
{task.source && <span>via {task.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
185
dashboard/src/components/assistant/ChatSidebar.tsx
Normal file
185
dashboard/src/components/assistant/ChatSidebar.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { DiffView } from './DiffView';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filePath?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Extract unified diff blocks from markdown-formatted assistant response */
|
||||
function extractDiffs(content: string): string[] {
|
||||
const diffs: string[] = [];
|
||||
const regex = /```(?:diff)?\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const block = match[1].trim();
|
||||
if (block.includes('@@') || block.startsWith('---') || block.startsWith('diff ')) {
|
||||
diffs.push(block);
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
export function ChatSidebar({ filePath, onClose }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState('local/qwen3');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [applyingDiff, setApplyingDiff] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
|
||||
const userMsg: Message = { role: 'user', content: text };
|
||||
const newMessages = [...messages, userMsg];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/assistant/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: newMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
model,
|
||||
file_path: filePath,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Request failed' }));
|
||||
setMessages([...newMessages, { role: 'assistant', content: `Error: ${err.error}` }]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setMessages([...newMessages, { role: 'assistant', content: data.message.content }]);
|
||||
} catch (e) {
|
||||
setMessages([
|
||||
...newMessages,
|
||||
{ role: 'assistant', content: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyDiff = async (diff: string) => {
|
||||
if (!filePath) return;
|
||||
setApplyingDiff(diff);
|
||||
try {
|
||||
const res = await fetch('/api/assistant/apply-diff', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath, diff }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Apply failed' }));
|
||||
alert(`Failed to apply diff: ${err.error}`);
|
||||
}
|
||||
} finally {
|
||||
setApplyingDiff(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDiff = (diff: string) => {
|
||||
// Remove the diff from the last assistant message display (user chose to reject)
|
||||
void diff;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-raised">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-sm font-medium text-text-primary">Assistant</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector value={model} onChange={setModel} />
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 space-y-3 overflow-auto p-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
Ask about the current file or request edits. The assistant will suggest diffs you can
|
||||
apply directly.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i}>
|
||||
<div
|
||||
className={`rounded-md px-3 py-2 text-sm ${
|
||||
msg.role === 'user'
|
||||
? 'ml-4 bg-accent/10 text-text-primary'
|
||||
: 'mr-4 bg-surface-overlay text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
</div>
|
||||
{/* Render extractable diffs as apply/reject widgets */}
|
||||
{msg.role === 'assistant' &&
|
||||
extractDiffs(msg.content).map((diff, di) => (
|
||||
<div key={di} className="mt-2">
|
||||
<DiffView
|
||||
diff={diff}
|
||||
onApply={() => applyDiff(diff)}
|
||||
onReject={() => removeDiff(diff)}
|
||||
applying={applyingDiff === diff}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="mr-4 rounded-md bg-surface-overlay px-3 py-2 text-sm text-text-muted">
|
||||
Thinking...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask about this file..."
|
||||
className="flex-1 rounded border border-border bg-surface-base px-2 py-1.5 text-sm text-text-primary outline-none placeholder:text-text-muted focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
className="rounded bg-accent px-3 py-1.5 text-sm text-white hover:bg-accent/80 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{filePath && (
|
||||
<div className="mt-1.5 truncate text-[10px] text-text-muted">Context: {filePath}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
dashboard/src/components/assistant/DiffView.tsx
Normal file
51
dashboard/src/components/assistant/DiffView.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
interface Props {
|
||||
diff: string;
|
||||
onApply: () => void;
|
||||
onReject: () => void;
|
||||
applying?: boolean;
|
||||
}
|
||||
|
||||
export function DiffView({ diff, onApply, onReject, applying }: Props) {
|
||||
const lines = diff.split('\n');
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface-overlay">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-1.5">
|
||||
<span className="text-xs font-medium text-text-secondary">Suggested Changes</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onReject}
|
||||
disabled={applying}
|
||||
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-danger/10 hover:text-danger"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={applying}
|
||||
className="rounded bg-accent px-2 py-0.5 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="overflow-auto p-2 text-xs leading-relaxed">
|
||||
{lines.map((line, i) => {
|
||||
let cls = 'text-text-secondary';
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
cls = 'text-success bg-success/10';
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
cls = 'text-danger bg-danger/10';
|
||||
} else if (line.startsWith('@@')) {
|
||||
cls = 'text-accent';
|
||||
}
|
||||
return (
|
||||
<div key={i} className={cls}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
dashboard/src/components/assistant/ModelSelector.tsx
Normal file
31
dashboard/src/components/assistant/ModelSelector.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
async function fetchModels() {
|
||||
const res = await fetch('/api/assistant/models');
|
||||
if (!res.ok) throw new Error('Failed to fetch models');
|
||||
return res.json() as Promise<{ id: string; name: string }[]>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
}
|
||||
|
||||
export function ModelSelector({ value, onChange }: Props) {
|
||||
const { data: models } = useQuery({ queryKey: ['assistant-models'], queryFn: fetchModels });
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded border border-border bg-surface-raised px-2 py-1 text-xs text-text-secondary outline-none focus:border-accent"
|
||||
>
|
||||
{(models || []).map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
{!models?.length && <option value={value}>{value}</option>}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
254
dashboard/src/components/editor/FileEditor.tsx
Normal file
254
dashboard/src/components/editor/FileEditor.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
import { MarkdownPreview, renderMarkdown } from './MarkdownPreview';
|
||||
import { AgentForm } from '../forms/AgentForm';
|
||||
import { CronForm } from '../forms/CronForm';
|
||||
import { HumanTaskForm } from '../forms/HumanTaskForm';
|
||||
import { AgentTaskForm } from '../forms/AgentTaskForm';
|
||||
import { SkillForm } from '../forms/SkillForm';
|
||||
import { KnowledgeForm } from '../forms/KnowledgeForm';
|
||||
import { readFile, writeFile } from '../../api/client';
|
||||
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
type FmMode = 'form' | 'yaml';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
onSaved?: () => void;
|
||||
onToggleAssistant?: () => void;
|
||||
}
|
||||
|
||||
export function FileEditor({ path, onSaved, onToggleAssistant }: Props) {
|
||||
const [frontmatter, setFrontmatter] = useState<Record<string, unknown>>({});
|
||||
const [body, setBody] = useState('');
|
||||
const [rawYaml, setRawYaml] = useState('');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('edit');
|
||||
const [fmMode, setFmMode] = useState<FmMode>('form');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const entityType = detectEntityType(path);
|
||||
|
||||
// Load file
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
setError(undefined);
|
||||
readFile(path)
|
||||
.then((data) => {
|
||||
const fm = (data.frontmatter as Record<string, unknown>) || {};
|
||||
setFrontmatter(fm);
|
||||
setBody(data.body || '');
|
||||
setRawYaml(toYaml(fm));
|
||||
setDirty(false);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [path]);
|
||||
|
||||
const handleFmChange = (values: Record<string, unknown>) => {
|
||||
setFrontmatter(values);
|
||||
setRawYaml(toYaml(values));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleYamlChange = (yaml: string) => {
|
||||
setRawYaml(yaml);
|
||||
try {
|
||||
// We don't parse YAML client-side; just track the raw value
|
||||
setDirty(true);
|
||||
} catch {
|
||||
// ignore parse errors during editing
|
||||
}
|
||||
};
|
||||
|
||||
const handleBodyChange = (value: string) => {
|
||||
setBody(value);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
if (fmMode === 'yaml') {
|
||||
// Send raw content
|
||||
const raw = rawYaml.trim()
|
||||
? `---\n${rawYaml.trimEnd()}\n---\n${body}`
|
||||
: body;
|
||||
await writeFile(path, { raw });
|
||||
} else {
|
||||
await writeFile(path, { frontmatter, body });
|
||||
}
|
||||
setDirty(false);
|
||||
onSaved?.();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ctrl+S / Cmd+S
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (dirty) handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm text-text-secondary">{path}</span>
|
||||
{dirty && <span className="text-xs text-warning">unsaved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View mode toggle */}
|
||||
{(['edit', 'split', 'preview'] as ViewMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setViewMode(m)}
|
||||
className={`rounded px-2 py-1 text-xs transition-colors ${
|
||||
viewMode === m
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 text-border">|</span>
|
||||
{/* Frontmatter mode toggle */}
|
||||
<button
|
||||
onClick={() => setFmMode(fmMode === 'form' ? 'yaml' : 'form')}
|
||||
className="rounded px-2 py-1 text-xs text-text-muted hover:text-text-secondary"
|
||||
>
|
||||
{fmMode === 'form' ? 'YAML' : 'Form'}
|
||||
</button>
|
||||
{onToggleAssistant && (
|
||||
<button
|
||||
onClick={onToggleAssistant}
|
||||
className="rounded px-2 py-1 text-xs text-text-muted hover:text-accent"
|
||||
title="Toggle AI Assistant"
|
||||
>
|
||||
AI
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded bg-accent px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-xs text-danger">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Frontmatter panel */}
|
||||
<div className="w-72 shrink-0 overflow-auto border-r border-border">
|
||||
{fmMode === 'form' ? (
|
||||
renderEntityForm(entityType, frontmatter, handleFmChange)
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<MarkdownEditor value={rawYaml} onChange={handleYamlChange} placeholder="YAML frontmatter..." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body editor / preview */}
|
||||
<div className="flex min-w-0 flex-1">
|
||||
{(viewMode === 'edit' || viewMode === 'split') && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} min-w-0`}>
|
||||
<MarkdownEditor value={body} onChange={handleBodyChange} placeholder="Write markdown..." />
|
||||
</div>
|
||||
)}
|
||||
{(viewMode === 'preview' || viewMode === 'split') && (
|
||||
<div
|
||||
className={`${viewMode === 'split' ? 'w-1/2 border-l border-border' : 'w-full'} overflow-auto`}
|
||||
>
|
||||
<MarkdownPreview html={renderMarkdown(body)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EntityType = 'agent' | 'skill' | 'cron' | 'human-task' | 'agent-task' | 'knowledge' | 'generic';
|
||||
|
||||
function detectEntityType(path: string): EntityType {
|
||||
if (path.startsWith('agents/')) return 'agent';
|
||||
if (path.startsWith('skills/')) return 'skill';
|
||||
if (path.startsWith('crons/')) return 'cron';
|
||||
if (path.startsWith('todos/harald/')) return 'human-task';
|
||||
if (path.startsWith('todos/agent/')) return 'agent-task';
|
||||
if (path.startsWith('knowledge/')) return 'knowledge';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function renderEntityForm(
|
||||
type: EntityType,
|
||||
values: Record<string, unknown>,
|
||||
onChange: (v: Record<string, unknown>) => void,
|
||||
) {
|
||||
switch (type) {
|
||||
case 'agent':
|
||||
return <AgentForm values={values} onChange={onChange} />;
|
||||
case 'skill':
|
||||
return <SkillForm values={values} onChange={onChange} />;
|
||||
case 'cron':
|
||||
return <CronForm values={values} onChange={onChange} />;
|
||||
case 'human-task':
|
||||
return <HumanTaskForm values={values} onChange={onChange} />;
|
||||
case 'agent-task':
|
||||
return <AgentTaskForm values={values} onChange={onChange} />;
|
||||
case 'knowledge':
|
||||
return <KnowledgeForm values={values} onChange={onChange} />;
|
||||
default:
|
||||
return (
|
||||
<div className="p-4 text-xs text-text-muted">
|
||||
No structured form for this file type. Switch to YAML mode.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toYaml(obj: Record<string, unknown>): string {
|
||||
// Simple YAML serialization for the form -> YAML toggle
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${key}: []`);
|
||||
} else {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(` - ${typeof item === 'string' ? item : JSON.stringify(item)}`);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
||||
} else if (typeof value === 'string' && value.includes('\n')) {
|
||||
lines.push(`${key}: |`);
|
||||
for (const line of value.split('\n')) {
|
||||
lines.push(` ${line}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
190
dashboard/src/components/editor/FrontmatterForm.tsx
Normal file
190
dashboard/src/components/editor/FrontmatterForm.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
fields: FieldDef[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface FieldDef {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'number' | 'select' | 'tags' | 'datetime' | 'checkbox' | 'json';
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function FrontmatterForm({ fields, values, onChange }: Props) {
|
||||
const update = (name: string, value: unknown) => {
|
||||
onChange({ ...values, [name]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
<label className="mb-1 block text-xs font-medium text-text-secondary">
|
||||
{field.label}
|
||||
{field.required && <span className="text-danger"> *</span>}
|
||||
</label>
|
||||
<FieldInput field={field} value={values[field.name]} onChange={(v) => update(field.name, v)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldDef;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
const cls =
|
||||
'w-full rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent';
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<input
|
||||
className={cls}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
className={`${cls} min-h-[60px] resize-y`}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={cls}
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select className={cls} value={(value as string) || ''} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{field.options?.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'tags':
|
||||
return <TagsInput value={(value as string[]) || []} onChange={onChange} />;
|
||||
|
||||
case 'datetime':
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={cls}
|
||||
value={toDatetimeLocal((value as string) || '')}
|
||||
onChange={(e) => onChange(e.target.value ? new Date(e.target.value).toISOString() : undefined)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-border"
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<textarea
|
||||
className={`${cls} min-h-[60px] resize-y font-mono text-xs`}
|
||||
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2) || ''}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
onChange(JSON.parse(e.target.value));
|
||||
} catch {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={field.placeholder || '{}'}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function TagsInput({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const add = () => {
|
||||
const tag = input.trim();
|
||||
if (tag && !value.includes(tag)) {
|
||||
onChange([...value, tag]);
|
||||
}
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
{value.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded bg-surface-overlay px-2 py-0.5 text-xs text-text-secondary"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => onChange(value.filter((t) => t !== tag))}
|
||||
className="text-text-muted hover:text-danger"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
placeholder="Add tag..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toDatetimeLocal(iso: string): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toISOString().slice(0, 16);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
92
dashboard/src/components/editor/MarkdownEditor.tsx
Normal file
92
dashboard/src/components/editor/MarkdownEditor.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, placeholder as phPlugin } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
autocompletion,
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { suggestFiles } from '../../api/client';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Wiki-link `[[...]]` async autocompletion source. */
|
||||
async function wikiLinkCompletion(ctx: CompletionContext): Promise<CompletionResult | null> {
|
||||
const before = ctx.matchBefore(/\[\[[^\]]*$/);
|
||||
if (!before) return null;
|
||||
const query = before.text.slice(2); // strip [[
|
||||
|
||||
const files = await suggestFiles(query).catch(() => [] as string[]);
|
||||
return {
|
||||
from: before.from + 2,
|
||||
filter: false,
|
||||
options: files.map((f) => ({
|
||||
label: f,
|
||||
apply: f + ']]',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function MarkdownEditor({ value, onChange, placeholder }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
history(),
|
||||
markdown(),
|
||||
oneDark,
|
||||
autocompletion({ override: [wikiLinkCompletion] }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '14px' },
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
'.cm-content': { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
|
||||
}),
|
||||
...(placeholder ? [phPlugin(placeholder)] : []),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: containerRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// Only create editor once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync external value changes (e.g., file load) without re-creating editor
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={containerRef} className="h-full" />;
|
||||
}
|
||||
27
dashboard/src/components/editor/MarkdownPreview.tsx
Normal file
27
dashboard/src/components/editor/MarkdownPreview.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
interface Props {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export function MarkdownPreview({ html }: Props) {
|
||||
return (
|
||||
<article
|
||||
className="prose prose-invert max-w-none p-4"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render markdown to HTML client-side (basic). */
|
||||
export function renderMarkdown(md: string): string {
|
||||
// Simple client-side rendering for preview — real rendering done server-side
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\[\[(.+?)\]\]/g, '<a href="#" class="text-accent">$1</a>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
21
dashboard/src/components/forms/AgentForm.tsx
Normal file
21
dashboard/src/components/forms/AgentForm.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true, placeholder: 'my-agent' },
|
||||
{ name: 'executable', label: 'Executable', type: 'select', required: true, options: ['claude-code', 'ollama', 'custom'] },
|
||||
{ name: 'model', label: 'Model', type: 'text', placeholder: 'sonnet' },
|
||||
{ name: 'escalate_to', label: 'Escalate To', type: 'text', placeholder: 'opus' },
|
||||
{ name: 'skills', label: 'Skills', type: 'tags' },
|
||||
{ name: 'mcp_servers', label: 'MCP Servers', type: 'tags' },
|
||||
{ name: 'timeout', label: 'Timeout (seconds)', type: 'number', placeholder: '600' },
|
||||
{ name: 'max_retries', label: 'Max Retries', type: 'number', placeholder: '0' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AgentForm({ values, onChange }: Props) {
|
||||
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
|
||||
}
|
||||
19
dashboard/src/components/forms/AgentTaskForm.tsx
Normal file
19
dashboard/src/components/forms/AgentTaskForm.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ name: 'title', label: 'Title', type: 'text', required: true },
|
||||
{ name: 'agent', label: 'Agent', type: 'text', required: true },
|
||||
{ name: 'priority', label: 'Priority', type: 'select', options: ['urgent', 'high', 'medium', 'low'] },
|
||||
{ name: 'type', label: 'Task Type', type: 'text', placeholder: 'review' },
|
||||
{ name: 'max_retries', label: 'Max Retries', type: 'number', placeholder: '0' },
|
||||
{ name: 'input', label: 'Input (JSON)', type: 'json' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function AgentTaskForm({ values, onChange }: Props) {
|
||||
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
|
||||
}
|
||||
17
dashboard/src/components/forms/CronForm.tsx
Normal file
17
dashboard/src/components/forms/CronForm.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ name: 'title', label: 'Title', type: 'text', required: true, placeholder: 'Daily code review' },
|
||||
{ name: 'schedule', label: 'Schedule (cron)', type: 'text', required: true, placeholder: '0 9 * * *' },
|
||||
{ name: 'agent', label: 'Agent', type: 'text', required: true, placeholder: 'reviewer' },
|
||||
{ name: 'enabled', label: 'Enabled', type: 'checkbox' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CronForm({ values, onChange }: Props) {
|
||||
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
|
||||
}
|
||||
19
dashboard/src/components/forms/HumanTaskForm.tsx
Normal file
19
dashboard/src/components/forms/HumanTaskForm.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ name: 'title', label: 'Title', type: 'text', required: true },
|
||||
{ name: 'priority', label: 'Priority', type: 'select', options: ['urgent', 'high', 'medium', 'low'] },
|
||||
{ name: 'labels', label: 'Labels', type: 'tags' },
|
||||
{ name: 'repo', label: 'Repository', type: 'text', placeholder: 'owner/repo' },
|
||||
{ name: 'due', label: 'Due Date', type: 'datetime' },
|
||||
{ name: 'source', label: 'Source', type: 'text', placeholder: 'self' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function HumanTaskForm({ values, onChange }: Props) {
|
||||
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
|
||||
}
|
||||
17
dashboard/src/components/forms/KnowledgeForm.tsx
Normal file
17
dashboard/src/components/forms/KnowledgeForm.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'tags', label: 'Tags', type: 'tags' },
|
||||
{ name: 'source', label: 'Source', type: 'text', placeholder: 'agent-name or self' },
|
||||
{ name: 'related', label: 'Related Files', type: 'tags' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function KnowledgeForm({ values, onChange }: Props) {
|
||||
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
|
||||
}
|
||||
19
dashboard/src/components/forms/SkillForm.tsx
Normal file
19
dashboard/src/components/forms/SkillForm.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true, placeholder: 'read-vault' },
|
||||
{ name: 'description', label: 'Description', type: 'textarea', required: true },
|
||||
{ name: 'version', label: 'Version', type: 'number', placeholder: '1' },
|
||||
{ name: 'requires_mcp', label: 'Requires MCP', type: 'tags' },
|
||||
{ name: 'inputs', label: 'Inputs', type: 'tags' },
|
||||
{ name: 'outputs', label: 'Outputs', type: 'tags' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function SkillForm({ values, onChange }: Props) {
|
||||
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
|
||||
}
|
||||
98
dashboard/src/hooks/useApi.ts
Normal file
98
dashboard/src/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../api/client';
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({ queryKey: ['agents'], queryFn: api.listAgents });
|
||||
}
|
||||
|
||||
export function useAgent(name: string) {
|
||||
return useQuery({ queryKey: ['agents', name], queryFn: () => api.getAgent(name), enabled: !!name });
|
||||
}
|
||||
|
||||
export function useSkills() {
|
||||
return useQuery({ queryKey: ['skills'], queryFn: api.listSkills });
|
||||
}
|
||||
|
||||
export function useCrons() {
|
||||
return useQuery({ queryKey: ['crons'], queryFn: api.listCrons });
|
||||
}
|
||||
|
||||
export function useHumanTasks() {
|
||||
return useQuery({ queryKey: ['humanTasks'], queryFn: api.listHumanTasks });
|
||||
}
|
||||
|
||||
export function useAgentTasks() {
|
||||
return useQuery({ queryKey: ['agentTasks'], queryFn: api.listAgentTasks });
|
||||
}
|
||||
|
||||
export function useKnowledge(q?: string, tag?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', q, tag],
|
||||
queryFn: () => api.listKnowledge(q, tag),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStats() {
|
||||
return useQuery({ queryKey: ['stats'], queryFn: api.getStats, refetchInterval: 30000 });
|
||||
}
|
||||
|
||||
export function useActivity() {
|
||||
return useQuery({ queryKey: ['activity'], queryFn: api.getActivity });
|
||||
}
|
||||
|
||||
export function useHealth() {
|
||||
return useQuery({ queryKey: ['health'], queryFn: api.getHealth, refetchInterval: 15000 });
|
||||
}
|
||||
|
||||
export function useMoveHumanTask() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ status, id, to }: { status: string; id: string; to: string }) =>
|
||||
api.moveHumanTask(status, id, to),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['humanTasks'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateHumanTask() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.createHumanTask,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['humanTasks'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTriggerAgent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, context }: { name: string; context?: string }) =>
|
||||
api.triggerAgent(name, context),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['agentTasks'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTriggerCron() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => api.triggerCron(name),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['crons'] });
|
||||
qc.invalidateQueries({ queryKey: ['agentTasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePauseCron() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => api.pauseCron(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['crons'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResumeCron() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => api.resumeCron(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['crons'] }),
|
||||
});
|
||||
}
|
||||
47
dashboard/src/hooks/useWebSocket.ts
Normal file
47
dashboard/src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { vaultWs } from '../api/ws';
|
||||
import type { WsEvent } from '../api/types';
|
||||
|
||||
/** Connect/disconnect the global WebSocket with the component lifecycle. */
|
||||
export function useWebSocket() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
vaultWs.connect();
|
||||
|
||||
const unsub = vaultWs.onAny((event: WsEvent) => {
|
||||
// Invalidate relevant queries based on event area
|
||||
if (event.type.startsWith('agent_task'))
|
||||
queryClient.invalidateQueries({ queryKey: ['agentTasks'] });
|
||||
if (event.type.startsWith('human_task'))
|
||||
queryClient.invalidateQueries({ queryKey: ['humanTasks'] });
|
||||
if (event.type.startsWith('agent_') && !event.type.startsWith('agent_task'))
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||
if (event.type.startsWith('skill_'))
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] });
|
||||
if (event.type.startsWith('cron_'))
|
||||
queryClient.invalidateQueries({ queryKey: ['crons'] });
|
||||
if (event.type.startsWith('knowledge_'))
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge'] });
|
||||
// Always invalidate stats/activity on any change
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['activity'] });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
vaultWs.disconnect();
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
||||
/** Subscribe to a specific event type. */
|
||||
export function useVaultEvent(type: string, handler: (event: WsEvent) => void) {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
return vaultWs.on(type, (e) => handlerRef.current(e));
|
||||
}, [type]);
|
||||
}
|
||||
24
dashboard/src/index.css
Normal file
24
dashboard/src/index.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface: #0f1117;
|
||||
--color-surface-raised: #1a1d27;
|
||||
--color-surface-overlay: #252833;
|
||||
--color-border: #2e3241;
|
||||
--color-border-hover: #3d4254;
|
||||
--color-text-primary: #e1e4ed;
|
||||
--color-text-secondary: #8b90a0;
|
||||
--color-text-muted: #5c6070;
|
||||
--color-accent: #6c8cff;
|
||||
--color-accent-hover: #8aa3ff;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #fbbf24;
|
||||
--color-danger: #f87171;
|
||||
--color-urgent: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface text-text-primary;
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
70
dashboard/src/pages/AgentQueuePage.tsx
Normal file
70
dashboard/src/pages/AgentQueuePage.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useAgentTasks } from '../hooks/useApi';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import type { AgentTask, AgentTaskStatus } from '../api/types';
|
||||
|
||||
const COLUMNS: { id: AgentTaskStatus; label: string }[] = [
|
||||
{ id: 'queued', label: 'Queued' },
|
||||
{ id: 'running', label: 'Running' },
|
||||
{ id: 'done', label: 'Done' },
|
||||
{ id: 'failed', label: 'Failed' },
|
||||
];
|
||||
|
||||
export function AgentQueuePage() {
|
||||
const { data: tasks, isLoading } = useAgentTasks();
|
||||
|
||||
const byStatus = (s: AgentTaskStatus) => (tasks || []).filter((t) => t.status === s);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold">Agent Queue</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-text-muted">Loading...</div>
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const items = byStatus(col.id);
|
||||
return (
|
||||
<div key={col.id} className="w-72 shrink-0">
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<h3 className="text-sm font-semibold text-text-secondary">{col.label}</h3>
|
||||
<span className="text-xs text-text-muted">{items.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-surface p-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-text-muted">Empty</div>
|
||||
) : (
|
||||
items.map((task) => <AgentTaskCard key={task.id} task={task} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentTaskCard({ task }: { task: AgentTask }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-raised p-3">
|
||||
<div className="mb-1 text-sm font-medium text-text-primary">{task.title}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<span>{task.agent}</span>
|
||||
<StatusBadge value={task.priority} />
|
||||
{task.type && <span className="text-text-muted">{task.type}</span>}
|
||||
</div>
|
||||
{task.error && (
|
||||
<div className="mt-2 truncate rounded bg-danger/10 px-2 py-1 text-xs text-danger">
|
||||
{task.error}
|
||||
</div>
|
||||
)}
|
||||
{task.started && (
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
started: {new Date(task.started).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
dashboard/src/pages/AgentsPage.tsx
Normal file
29
dashboard/src/pages/AgentsPage.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { AgentCard } from '../components/AgentCard';
|
||||
import { useAgents, useTriggerAgent } from '../hooks/useApi';
|
||||
|
||||
export function AgentsPage() {
|
||||
const { data: agents, isLoading } = useAgents();
|
||||
const triggerMut = useTriggerAgent();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold">Agents</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-text-muted">Loading agents...</div>
|
||||
) : !agents?.length ? (
|
||||
<div className="text-text-muted">No agents defined. Create markdown files in agents/</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
onTrigger={(name) => triggerMut.mutate({ name })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
dashboard/src/pages/CronsPage.tsx
Normal file
40
dashboard/src/pages/CronsPage.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { CronRow } from '../components/CronRow';
|
||||
import { useCrons, useTriggerCron, usePauseCron, useResumeCron } from '../hooks/useApi';
|
||||
|
||||
export function CronsPage() {
|
||||
const { data: crons, isLoading } = useCrons();
|
||||
const triggerMut = useTriggerCron();
|
||||
const pauseMut = usePauseCron();
|
||||
const resumeMut = useResumeCron();
|
||||
|
||||
const handleToggle = (name: string, isActive: boolean) => {
|
||||
if (isActive) {
|
||||
pauseMut.mutate(name);
|
||||
} else {
|
||||
resumeMut.mutate(name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold">Cron Jobs</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-text-muted">Loading crons...</div>
|
||||
) : !crons?.length ? (
|
||||
<div className="text-text-muted">No cron jobs defined. Create markdown files in crons/active/</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface-raised">
|
||||
{crons.map((cron) => (
|
||||
<CronRow
|
||||
key={cron.name}
|
||||
cron={cron}
|
||||
onTrigger={(name) => triggerMut.mutate(name)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
dashboard/src/pages/EditorPage.tsx
Normal file
93
dashboard/src/pages/EditorPage.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileTree } from '../components/FileTree';
|
||||
import { FileEditor } from '../components/editor/FileEditor';
|
||||
import { ChatSidebar } from '../components/assistant/ChatSidebar';
|
||||
import { getTree, writeFile } from '../api/client';
|
||||
import { slugify } from '../utils';
|
||||
|
||||
export function EditorPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedPath, setSelectedPath] = useState(searchParams.get('file') || '');
|
||||
const [showAssistant, setShowAssistant] = useState(false);
|
||||
const { data: tree, refetch: refetchTree } = useQuery({
|
||||
queryKey: ['tree'],
|
||||
queryFn: getTree,
|
||||
});
|
||||
|
||||
// Handle ?new=path for creating new files
|
||||
useEffect(() => {
|
||||
const newDir = searchParams.get('new');
|
||||
if (newDir) {
|
||||
const name = prompt('File name (without .md):');
|
||||
if (name) {
|
||||
const slug = slugify(name);
|
||||
const path = `${newDir}/${slug}.md`;
|
||||
writeFile(path, { frontmatter: { title: name }, body: '' }).then(() => {
|
||||
setSelectedPath(path);
|
||||
setSearchParams({});
|
||||
refetchTree();
|
||||
});
|
||||
} else {
|
||||
setSearchParams({});
|
||||
}
|
||||
}
|
||||
}, [searchParams, setSearchParams, refetchTree]);
|
||||
|
||||
const handleSelect = (path: string) => {
|
||||
if (path.endsWith('.md')) {
|
||||
setSelectedPath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFile = (dir: string) => {
|
||||
const name = prompt('New file name (without .md):');
|
||||
if (name) {
|
||||
const slug = slugify(name);
|
||||
const path = `${dir}/${slug}.md`;
|
||||
writeFile(path, { frontmatter: {}, body: '' }).then(() => {
|
||||
setSelectedPath(path);
|
||||
refetchTree();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* File tree sidebar */}
|
||||
<div className="w-56 shrink-0 overflow-auto border-r border-border bg-surface-raised py-2">
|
||||
{tree ? (
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onCreateFile={handleCreateFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-xs text-text-muted">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{selectedPath ? (
|
||||
<FileEditor
|
||||
path={selectedPath}
|
||||
onSaved={() => refetchTree()}
|
||||
onToggleAssistant={() => setShowAssistant((v) => !v)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
Select a file or press Cmd+K to create one
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Assistant sidebar */}
|
||||
{showAssistant && (
|
||||
<ChatSidebar filePath={selectedPath} onClose={() => setShowAssistant(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
dashboard/src/pages/KnowledgePage.tsx
Normal file
96
dashboard/src/pages/KnowledgePage.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useState } from 'react';
|
||||
import { useKnowledge } from '../hooks/useApi';
|
||||
import { getKnowledge } from '../api/client';
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedTag, setSelectedTag] = useState<string>();
|
||||
const [selectedNote, setSelectedNote] = useState<{
|
||||
path: string;
|
||||
html: string;
|
||||
body: string;
|
||||
frontmatter: unknown;
|
||||
} | null>(null);
|
||||
|
||||
const { data: notes, isLoading } = useKnowledge(search || undefined, selectedTag);
|
||||
|
||||
const allTags = [...new Set((notes || []).flatMap((n) => n.tags))].sort();
|
||||
|
||||
const openNote = async (path: string) => {
|
||||
const data = await getKnowledge(path.replace(/^knowledge\//, ''));
|
||||
setSelectedNote(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="w-80 shrink-0 border-r border-border">
|
||||
<div className="border-b border-border p-4">
|
||||
<h1 className="mb-3 text-lg font-semibold">Knowledge</h1>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent"
|
||||
/>
|
||||
{allTags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? undefined : tag)}
|
||||
className={`rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
selectedTag === tag
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface-overlay text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-text-muted">Loading...</div>
|
||||
) : !notes?.length ? (
|
||||
<div className="p-4 text-sm text-text-muted">No notes found</div>
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<button
|
||||
key={note.path}
|
||||
onClick={() => openNote(note.path)}
|
||||
className={`block w-full border-b border-border px-4 py-2.5 text-left transition-colors hover:bg-surface-overlay ${
|
||||
selectedNote?.path === note.path.replace(/^knowledge\//, '')
|
||||
? 'bg-surface-overlay'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary">{note.title}</div>
|
||||
{note.tags.length > 0 && (
|
||||
<div className="mt-0.5 flex gap-1">
|
||||
{note.tags.map((t) => (
|
||||
<span key={t} className="text-xs text-text-muted">#{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{selectedNote ? (
|
||||
<article
|
||||
className="prose prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: selectedNote.html }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-text-muted">Select a note to view</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
dashboard/src/pages/TasksPage.tsx
Normal file
84
dashboard/src/pages/TasksPage.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Kanban } from '../components/Kanban';
|
||||
import { ActivityFeed } from '../components/ActivityFeed';
|
||||
import { useHumanTasks, useMoveHumanTask, useCreateHumanTask, useStats } from '../hooks/useApi';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function TasksPage() {
|
||||
const { data: tasks, isLoading } = useHumanTasks();
|
||||
const { data: stats } = useStats();
|
||||
const moveMut = useMoveHumanTask();
|
||||
const createMut = useCreateHumanTask();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
|
||||
const handleMove = (id: string, fromStatus: string, toStatus: string) => {
|
||||
moveMut.mutate({ status: fromStatus, id, to: toStatus });
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newTitle.trim()) return;
|
||||
createMut.mutate({ title: newTitle.trim() }, {
|
||||
onSuccess: () => { setNewTitle(''); setShowCreate(false); },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold">Tasks</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-2">
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
placeholder="Task title..."
|
||||
className="flex-1 rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleCreate} className="rounded bg-accent px-3 py-1.5 text-xs text-white">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-text-muted">Loading tasks...</div>
|
||||
) : (
|
||||
<Kanban tasks={tasks || []} onMove={handleMove} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="w-64 shrink-0 border-l border-border bg-surface-raised">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text-secondary">Overview</h2>
|
||||
{stats && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(stats.human_tasks).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between text-xs">
|
||||
<StatusBadge value={k} />
|
||||
<span className="text-text-muted">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<h2 className="mb-2 text-sm font-semibold text-text-secondary">Recent Activity</h2>
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
dashboard/src/pages/ViewPage.tsx
Normal file
18
dashboard/src/pages/ViewPage.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { ViewRenderer } from '../views/ViewRenderer';
|
||||
import { NotificationBanner } from '../components/NotificationBanner';
|
||||
|
||||
export function ViewPage() {
|
||||
const { '*': viewPath } = useParams();
|
||||
|
||||
if (!viewPath) {
|
||||
return <div className="p-6 text-sm text-text-muted">No view specified</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationBanner />
|
||||
<ViewRenderer viewPath={`pages/${viewPath}`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
dashboard/src/utils.ts
Normal file
6
dashboard/src/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
52
dashboard/src/views/LayoutRenderer.tsx
Normal file
52
dashboard/src/views/LayoutRenderer.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { ViewRegions, WidgetInstanceDef } from '../api/types';
|
||||
import { WidgetRenderer } from './WidgetRenderer';
|
||||
|
||||
interface Props {
|
||||
layout?: string;
|
||||
regions?: ViewRegions;
|
||||
}
|
||||
|
||||
function renderWidgets(widgets: WidgetInstanceDef[]) {
|
||||
return widgets.map((w, i) => <WidgetRenderer key={`${w.widget}-${i}`} instance={w} />);
|
||||
}
|
||||
|
||||
/** Single-column: all widgets stacked in "main" region */
|
||||
function SingleColumn({ regions }: { regions: ViewRegions }) {
|
||||
const main = regions['main'] || [];
|
||||
return <div className="space-y-4">{renderWidgets(main)}</div>;
|
||||
}
|
||||
|
||||
/** Two-column: "left" and "right" regions side by side */
|
||||
function TwoColumn({ regions }: { regions: ViewRegions }) {
|
||||
const left = regions['left'] || regions['main'] || [];
|
||||
const right = regions['right'] || regions['sidebar'] || [];
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-4">{renderWidgets(left)}</div>
|
||||
<div className="space-y-4">{renderWidgets(right)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard grid: renders all regions as a responsive grid */
|
||||
function DashboardGrid({ regions }: { regions: ViewRegions }) {
|
||||
const all = Object.values(regions).flat();
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{renderWidgets(all)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutRenderer({ layout = 'single', regions = {} }: Props) {
|
||||
switch (layout) {
|
||||
case 'two-column':
|
||||
return <TwoColumn regions={regions} />;
|
||||
case 'dashboard':
|
||||
case 'grid':
|
||||
return <DashboardGrid regions={regions} />;
|
||||
case 'single':
|
||||
default:
|
||||
return <SingleColumn regions={regions} />;
|
||||
}
|
||||
}
|
||||
45
dashboard/src/views/ViewRenderer.tsx
Normal file
45
dashboard/src/views/ViewRenderer.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getView, listViewPages } from '../api/client';
|
||||
import { LayoutRenderer } from './LayoutRenderer';
|
||||
|
||||
interface Props {
|
||||
/** The path of the view page definition (relative to views/) */
|
||||
viewPath: string;
|
||||
}
|
||||
|
||||
export function ViewRenderer({ viewPath }: Props) {
|
||||
const { data: view, isLoading, error } = useQuery({
|
||||
queryKey: ['view', viewPath],
|
||||
queryFn: () => getView(viewPath),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-sm text-text-muted">Loading view...</div>;
|
||||
}
|
||||
|
||||
if (error || !view?.frontmatter) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-text-muted">
|
||||
Failed to load view: {viewPath}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { title, layout, regions } = view.frontmatter;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{title && <h1 className="mb-4 text-xl font-bold text-text-primary">{title}</h1>}
|
||||
<LayoutRenderer layout={layout} regions={regions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook to get the list of dynamic view pages for navigation */
|
||||
export function useViewPages() {
|
||||
return useQuery({
|
||||
queryKey: ['viewPages'],
|
||||
queryFn: listViewPages,
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
46
dashboard/src/views/WidgetRenderer.tsx
Normal file
46
dashboard/src/views/WidgetRenderer.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { WidgetInstanceDef } from '../api/types';
|
||||
import { KanbanWidget } from '../widgets/KanbanWidget';
|
||||
import { ActivityFeedWidget } from '../widgets/ActivityFeedWidget';
|
||||
import { AgentStatusBar } from '../widgets/AgentStatusBar';
|
||||
import { StatsWidget } from '../widgets/StatsWidget';
|
||||
import { CronScheduleWidget } from '../widgets/CronScheduleWidget';
|
||||
import { FileListWidget } from '../widgets/FileListWidget';
|
||||
import { MarkdownViewerWidget } from '../widgets/MarkdownViewerWidget';
|
||||
import { TableWidget } from '../widgets/TableWidget';
|
||||
import { TagCloudWidget } from '../widgets/TagCloudWidget';
|
||||
import { FileTreeWidget } from '../widgets/FileTreeWidget';
|
||||
|
||||
const WIDGET_MAP: Record<string, React.ComponentType<Record<string, unknown>>> = {
|
||||
Kanban: KanbanWidget as React.ComponentType<Record<string, unknown>>,
|
||||
ActivityFeed: ActivityFeedWidget as React.ComponentType<Record<string, unknown>>,
|
||||
AgentStatusBar: AgentStatusBar as React.ComponentType<Record<string, unknown>>,
|
||||
Stats: StatsWidget as React.ComponentType<Record<string, unknown>>,
|
||||
CronSchedule: CronScheduleWidget as React.ComponentType<Record<string, unknown>>,
|
||||
FileList: FileListWidget as React.ComponentType<Record<string, unknown>>,
|
||||
MarkdownViewer: MarkdownViewerWidget as React.ComponentType<Record<string, unknown>>,
|
||||
Table: TableWidget as React.ComponentType<Record<string, unknown>>,
|
||||
TagCloud: TagCloudWidget as React.ComponentType<Record<string, unknown>>,
|
||||
FileTree: FileTreeWidget as React.ComponentType<Record<string, unknown>>,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
instance: WidgetInstanceDef;
|
||||
}
|
||||
|
||||
export function WidgetRenderer({ instance }: Props) {
|
||||
const Component = WIDGET_MAP[instance.widget];
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-raised p-3 text-xs text-text-muted">
|
||||
Unknown widget: {instance.widget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-raised">
|
||||
<Component {...(instance.props || {})} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
dashboard/src/widgets/ActivityFeedWidget.tsx
Normal file
9
dashboard/src/widgets/ActivityFeedWidget.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { ActivityFeed } from '../components/ActivityFeed';
|
||||
|
||||
interface Props {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function ActivityFeedWidget({}: Props) {
|
||||
return <ActivityFeed />;
|
||||
}
|
||||
36
dashboard/src/widgets/AgentStatusBar.tsx
Normal file
36
dashboard/src/widgets/AgentStatusBar.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useAgents, useAgentTasks } from '../hooks/useApi';
|
||||
|
||||
export function AgentStatusBar() {
|
||||
const { data: agents } = useAgents();
|
||||
const { data: tasks } = useAgentTasks();
|
||||
|
||||
const running = (tasks || []).filter((t) => t.status === 'running');
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 p-3">
|
||||
{(agents || []).map((agent) => {
|
||||
const active = running.filter((t) => t.agent === agent.name);
|
||||
const isActive = active.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={agent.name}
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs ${
|
||||
isActive
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border bg-surface-raised text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isActive ? 'bg-accent animate-pulse' : 'bg-text-muted'}`}
|
||||
/>
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{isActive && <span className="text-text-muted">{active.length} running</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!agents || agents.length === 0) && (
|
||||
<span className="text-xs text-text-muted">No agents defined</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
dashboard/src/widgets/CronScheduleWidget.tsx
Normal file
24
dashboard/src/widgets/CronScheduleWidget.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { CronRow } from '../components/CronRow';
|
||||
import { useCrons, useTriggerCron, usePauseCron, useResumeCron } from '../hooks/useApi';
|
||||
|
||||
export function CronScheduleWidget() {
|
||||
const { data: crons } = useCrons();
|
||||
const triggerMut = useTriggerCron();
|
||||
const pauseMut = usePauseCron();
|
||||
const resumeMut = useResumeCron();
|
||||
|
||||
if (!crons?.length) return <div className="p-3 text-xs text-text-muted">No crons</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border">
|
||||
{crons.map((cron) => (
|
||||
<CronRow
|
||||
key={cron.name}
|
||||
cron={cron}
|
||||
onTrigger={(name) => triggerMut.mutate(name)}
|
||||
onToggle={(name, active) => (active ? pauseMut : resumeMut).mutate(name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
dashboard/src/widgets/FileListWidget.tsx
Normal file
33
dashboard/src/widgets/FileListWidget.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { suggestFiles } from '../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
source?: string;
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
export function FileListWidget({ source, filter }: Props) {
|
||||
const { data: files } = useQuery({
|
||||
queryKey: ['suggestFiles', filter],
|
||||
queryFn: () => suggestFiles(filter || source),
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{(files || []).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => navigate(`/editor?file=${encodeURIComponent(f)}`)}
|
||||
className="block w-full truncate border-b border-border px-3 py-1.5 text-left text-xs text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text-primary last:border-b-0"
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
{(!files || files.length === 0) && (
|
||||
<div className="p-3 text-xs text-text-muted">No files</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
dashboard/src/widgets/FileTreeWidget.tsx
Normal file
20
dashboard/src/widgets/FileTreeWidget.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTree } from '../api/client';
|
||||
import { FileTree } from '../components/FileTree';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function FileTreeWidget() {
|
||||
const { data: tree } = useQuery({ queryKey: ['tree'], queryFn: getTree });
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!tree) return <div className="p-3 text-xs text-text-muted">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-h-80 overflow-auto py-1">
|
||||
<FileTree
|
||||
tree={tree}
|
||||
onSelect={(path) => navigate(`/editor?file=${encodeURIComponent(path)}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
dashboard/src/widgets/KanbanWidget.tsx
Normal file
18
dashboard/src/widgets/KanbanWidget.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Kanban } from '../components/Kanban';
|
||||
import { useHumanTasks, useMoveHumanTask } from '../hooks/useApi';
|
||||
|
||||
interface Props {
|
||||
source?: string;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanWidget(_props: Props) {
|
||||
const { data: tasks } = useHumanTasks();
|
||||
const moveMut = useMoveHumanTask();
|
||||
|
||||
const handleMove = (id: string, from: string, to: string) => {
|
||||
moveMut.mutate({ status: from, id, to });
|
||||
};
|
||||
|
||||
return <Kanban tasks={tasks || []} onMove={handleMove} />;
|
||||
}
|
||||
21
dashboard/src/widgets/MarkdownViewerWidget.tsx
Normal file
21
dashboard/src/widgets/MarkdownViewerWidget.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface Props {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export function MarkdownViewerWidget({ content }: Props) {
|
||||
if (!content) return <div className="p-3 text-xs text-text-muted">No content</div>;
|
||||
|
||||
// Basic markdown-to-html for widget context
|
||||
const html = content
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-sm font-semibold mt-2 mb-1">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-base font-semibold mt-3 mb-1">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-lg font-bold mt-3 mb-1">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code class="bg-surface-overlay px-1 rounded text-xs">$1</code>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
|
||||
return (
|
||||
<div className="p-3 text-sm text-text-secondary" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
);
|
||||
}
|
||||
35
dashboard/src/widgets/StatsWidget.tsx
Normal file
35
dashboard/src/widgets/StatsWidget.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useStats } from '../hooks/useApi';
|
||||
|
||||
interface Props {
|
||||
keys?: string[];
|
||||
}
|
||||
|
||||
export function StatsWidget({ keys }: Props) {
|
||||
const { data: stats, isLoading } = useStats();
|
||||
|
||||
if (isLoading || !stats) return <div className="p-3 text-xs text-text-muted">Loading...</div>;
|
||||
|
||||
const entries: [string, number][] = [
|
||||
['Agents', stats.agents],
|
||||
['Skills', stats.skills],
|
||||
['Crons', stats.crons_scheduled],
|
||||
['Knowledge', stats.knowledge_notes],
|
||||
['Tasks Executed', stats.total_tasks_executed],
|
||||
['Cron Fires', stats.total_cron_fires],
|
||||
];
|
||||
|
||||
const filtered = keys
|
||||
? entries.filter(([k]) => keys.some((f) => k.toLowerCase().includes(f.toLowerCase())))
|
||||
: entries;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 p-3">
|
||||
{filtered.map(([label, value]) => (
|
||||
<div key={label} className="rounded-md border border-border bg-surface p-3">
|
||||
<div className="text-2xl font-bold text-text-primary">{value}</div>
|
||||
<div className="text-xs text-text-secondary">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
dashboard/src/widgets/TableWidget.tsx
Normal file
37
dashboard/src/widgets/TableWidget.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
interface Props {
|
||||
columns?: { key: string; label: string }[];
|
||||
data?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export function TableWidget({ columns, data }: Props) {
|
||||
if (!data?.length || !columns?.length) {
|
||||
return <div className="p-3 text-xs text-text-muted">No data</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="px-3 py-2 text-xs font-medium text-text-secondary">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-b-0 hover:bg-surface-overlay">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-3 py-1.5 text-text-primary">
|
||||
{String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
dashboard/src/widgets/TagCloudWidget.tsx
Normal file
21
dashboard/src/widgets/TagCloudWidget.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { suggestTags } from '../api/client';
|
||||
|
||||
export function TagCloudWidget() {
|
||||
const { data: tags } = useQuery({ queryKey: ['tags'], queryFn: suggestTags });
|
||||
|
||||
if (!tags?.length) return <div className="p-3 text-xs text-text-muted">No tags</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 p-3">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-surface-overlay px-2.5 py-0.5 text-xs text-text-secondary transition-colors hover:bg-accent/15 hover:text-accent"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
dashboard/tsconfig.app.json
Normal file
28
dashboard/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard/tsconfig.json
Normal file
7
dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
dashboard/tsconfig.node.json
Normal file
26
dashboard/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
dashboard/vite.config.ts
Normal file
13
dashboard/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8080',
|
||||
'/ws': { target: 'ws://127.0.0.1:8080', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue