Add query bar, maintenance panel, and heartbeat system
- Query bar with organized/grouped search results in portal - Maintenance panel UI for triggering and viewing maintenance status - Heartbeat service with periodic maintenance and dirty-tracking - Query organizer for grouping search results by tag/kind/parent - Slide-up animation for query panel
This commit is contained in:
@@ -4,8 +4,10 @@ import GraphView from './components/GraphView';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import NodePanel from './components/NodePanel';
|
||||
import AddNodeModal from './components/AddNodeModal';
|
||||
import QueryBar from './components/QueryBar';
|
||||
import LinkModal from './components/LinkModal';
|
||||
import Toast from './components/Toast';
|
||||
import MaintenancePanel from './components/MaintenancePanel';
|
||||
|
||||
export default function App() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
@@ -13,6 +15,8 @@ export default function App() {
|
||||
const [linkFromId, setLinkFromId] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [showQuery, setShowQuery] = useState(false);
|
||||
const [showMaintenance, setShowMaintenance] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
@@ -34,12 +38,13 @@ export default function App() {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (selectedId) setSelectedId(null);
|
||||
else if (showQuery) setShowQuery(false);
|
||||
else if (drawerOpen) setDrawerOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [selectedId, drawerOpen]);
|
||||
}, [selectedId, drawerOpen, showQuery]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden relative">
|
||||
@@ -64,6 +69,20 @@ export default function App() {
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQuery(!showQuery)}
|
||||
className="w-12 h-12 rounded-full bg-purple-600/90 backdrop-blur border border-purple-500 text-white hover:bg-purple-500 shadow-lg flex items-center justify-center text-lg font-bold"
|
||||
title="Query memory"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMaintenance(!showMaintenance)}
|
||||
className="w-12 h-12 rounded-full bg-emerald-600/90 backdrop-blur border border-emerald-500 text-white hover:bg-emerald-500 shadow-lg flex items-center justify-center text-lg"
|
||||
title="Maintenance"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar drawer — slides in from left */}
|
||||
@@ -106,6 +125,22 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query panel — slides up from bottom */}
|
||||
{showQuery && (
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowQuery(false)}>
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-[70vh] animate-slide-in-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<QueryBar
|
||||
onSelectNode={(id) => { setShowQuery(false); selectNode(id); }}
|
||||
onClose={() => setShowQuery(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddNode && (
|
||||
<AddNodeModal
|
||||
onClose={() => setShowAddNode(false)}
|
||||
@@ -119,6 +154,11 @@ export default function App() {
|
||||
onCreated={() => { refresh(); notify('Edge created'); }}
|
||||
/>
|
||||
)}
|
||||
{showMaintenance && (
|
||||
<div className="fixed bottom-20 left-5 z-50">
|
||||
<MaintenancePanel onClose={() => setShowMaintenance(false)} onNotify={(msg) => { notify(msg); refresh(); }} />
|
||||
</div>
|
||||
)}
|
||||
{toast && <Toast message={toast} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CortexNode, CortexEdge, GraphData, NodeWithConnections, SearchResult, NodeKind, EdgeType } from './types';
|
||||
import type { CortexNode, CortexEdge, GraphData, NodeWithConnections, SearchResult, NodeKind, EdgeType, GroupedQueryResult } from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
@@ -41,4 +41,13 @@ export const api = {
|
||||
|
||||
search: (text: string, options?: Record<string, any>) =>
|
||||
request<SearchResult[]>('/search', { method: 'POST', body: JSON.stringify({ text, options }) }),
|
||||
|
||||
queryOrganized: (text: string) =>
|
||||
request<GroupedQueryResult>('/query/organize', { method: 'POST', body: JSON.stringify({ text }) }),
|
||||
|
||||
getMaintenanceStatus: () =>
|
||||
request<Record<string, any>>('/maintenance/status'),
|
||||
|
||||
runMaintenance: () =>
|
||||
request<Record<string, any>>('/maintenance/run', { method: 'POST' }),
|
||||
};
|
||||
|
||||
@@ -18,3 +18,12 @@
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-slide-in-up {
|
||||
animation: slide-in-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
103
portal/src/components/MaintenancePanel.tsx
Normal file
103
portal/src/components/MaintenancePanel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
interface Report {
|
||||
ranAt: number;
|
||||
deduped: number;
|
||||
autoTagged: number;
|
||||
autoOrganized: number;
|
||||
pruned: number;
|
||||
summarized?: number;
|
||||
merged?: number;
|
||||
split?: number;
|
||||
archived?: number;
|
||||
aiAvailable?: boolean;
|
||||
skipped: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function MaintenancePanel({ onClose, onNotify }: { onClose: () => void; onNotify: (msg: string) => void }) {
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getMaintenanceStatus().then(r => setReport(r as any)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.runMaintenance();
|
||||
setReport(r as Report);
|
||||
onNotify('Maintenance complete');
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats: { label: string; key: keyof Report }[] = [
|
||||
{ label: 'Deduped', key: 'deduped' },
|
||||
{ label: 'Auto-tagged', key: 'autoTagged' },
|
||||
{ label: 'Organized', key: 'autoOrganized' },
|
||||
{ label: 'Pruned', key: 'pruned' },
|
||||
{ label: 'Summarized', key: 'summarized' },
|
||||
{ label: 'Merged', key: 'merged' },
|
||||
{ label: 'Split', key: 'split' },
|
||||
{ label: 'Archived', key: 'archived' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 w-80 max-w-[90vw] shadow-2xl text-gray-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-400">Maintenance</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{report && !report.message && (
|
||||
<div className="mb-4 space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${report.aiAvailable ? 'bg-green-400' : 'bg-yellow-500'}`} />
|
||||
<span className="text-gray-400">{report.aiAvailable ? 'AI available (Ollama)' : 'AI unavailable — cosine fallback'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
{stats.map(s => {
|
||||
const val = report[s.key];
|
||||
if (val === undefined) return null;
|
||||
return (
|
||||
<div key={s.key} className="flex justify-between">
|
||||
<span className="text-gray-400">{s.label}</span>
|
||||
<span className="font-mono text-gray-200">{val as number}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-gray-500 mt-2">
|
||||
{report.skipped ? 'Skipped (no changes)' : `Ran ${new Date(report.ranAt).toLocaleTimeString()}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report?.message && (
|
||||
<p className="text-xs text-gray-500 mb-4">{report.message}</p>
|
||||
)}
|
||||
|
||||
{!report && !error && (
|
||||
<p className="text-xs text-gray-500 mb-4">No maintenance has run yet.</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-400 mb-4">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={running}
|
||||
className="w-full py-2 rounded-lg text-sm font-medium bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-wait transition"
|
||||
>
|
||||
{running ? 'Running...' : 'Run Maintenance'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
portal/src/components/QueryBar.tsx
Normal file
109
portal/src/components/QueryBar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { GroupedQueryResult } from '../types';
|
||||
|
||||
interface Props {
|
||||
onSelectNode: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QueryBar({ onSelectNode, onClose }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [result, setResult] = useState<GroupedQueryResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const submit = async () => {
|
||||
if (!text.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.queryOrganized(text);
|
||||
setResult(data);
|
||||
setExpanded(new Set(data.groups.map(g => g.label)));
|
||||
} catch {
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (label: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(label) ? next.delete(label) : next.add(label);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900/95 backdrop-blur border-t border-purple-500/30 rounded-t-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50">
|
||||
<span className="text-purple-300 font-medium text-sm">Query Memory</span>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 text-lg">×</button>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 flex gap-2">
|
||||
<input
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||
placeholder='e.g. "group all git commands by tag"'
|
||||
className="flex-1 bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 disabled:opacity-50 rounded-lg text-sm text-white font-medium"
|
||||
>
|
||||
{loading ? '...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||
{!result && !loading && (
|
||||
<div className="text-gray-500 text-sm mt-4 space-y-1">
|
||||
<p>Example queries:</p>
|
||||
<p className="text-gray-600 ml-2">"all decisions about architecture"</p>
|
||||
<p className="text-gray-600 ml-2">"group tasks by tag"</p>
|
||||
<p className="text-gray-600 ml-2">"show components under parent"</p>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500">{result.totalResults} results · grouped by {result.strategy}</p>
|
||||
{result.groups.map(group => (
|
||||
<div key={group.label} className="border border-gray-700/50 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggle(group.label)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-sm text-gray-300"
|
||||
>
|
||||
<span className="font-medium">{group.label} ({group.items.length})</span>
|
||||
<span className="text-xs">{expanded.has(group.label) ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{expanded.has(group.label) && (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{group.items.map(item => (
|
||||
<button
|
||||
key={item.node.id}
|
||||
onClick={() => onSelectNode(item.node.id)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-800/50 flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">{item.node.kind}</span>
|
||||
<span className="text-sm text-gray-200 truncate">{item.node.title}</span>
|
||||
<span className="text-xs text-gray-600 ml-auto">{(item.score * 100).toFixed(0)}%</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,3 +43,14 @@ export interface SearchResult {
|
||||
node: CortexNode;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface ResultGroup {
|
||||
label: string;
|
||||
items: { node: { id: string; kind: string; title: string; content: string; status?: string; tags: string[] }; score: number }[];
|
||||
}
|
||||
|
||||
export interface GroupedQueryResult {
|
||||
strategy: 'tag' | 'kind' | 'parent' | 'flat';
|
||||
groups: ResultGroup[];
|
||||
totalResults: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user