MVP-0: Field Console + Health Dashboard
- Field Console: 4 tabs (Upload, Queue, Artifact Viewer, Timeline) - Health Dashboard: system pulse for 6 engines - No more ADR documents until MVP-0 proven with real mission Stoppregel: Ingen ny arkitektur förrän första verkliga uppdraget. Next: Pilot 001 — First real field upload
This commit is contained in:
@@ -2,6 +2,8 @@ import { Routes, Route, Link } from 'react-router-dom';
|
||||
import DatasetExplorer from './pages/DatasetExplorer';
|
||||
import ArtifactViewer from './pages/ArtifactViewer';
|
||||
import MissionUpload from './pages/MissionUpload';
|
||||
import FieldConsole from './pages/FieldConsole';
|
||||
import HealthDashboard from './pages/HealthDashboard';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -11,6 +13,8 @@ function App() {
|
||||
<nav style={{ display: 'flex', gap: 20, marginTop: 10 }}>
|
||||
<Link to="/">Dataset Explorer</Link>
|
||||
<Link to="/upload">Mission Upload</Link>
|
||||
<Link to="/console">Field Console</Link>
|
||||
<Link to="/health">Health</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -18,6 +22,8 @@ function App() {
|
||||
<Route path="/" element={<DatasetExplorer />} />
|
||||
<Route path="/upload" element={<MissionUpload />} />
|
||||
<Route path="/artifact/:id" element={<ArtifactViewer />} />
|
||||
<Route path="/console" element={<FieldConsole />} />
|
||||
<Route path="/health" element={<HealthDashboard />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Field Console
|
||||
*
|
||||
* MVP-0: First Field Upload
|
||||
*
|
||||
* Four views:
|
||||
* 1. Upload — phone, video, image, GPS
|
||||
* 2. Queue — mission status
|
||||
* 3. Artifact Viewer — original, metadata, knowledge, history
|
||||
* 4. Processing Timeline — step-by-step progress
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Mission {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
steps: {
|
||||
uploaded: boolean;
|
||||
archived: boolean;
|
||||
metadataExtracted: boolean;
|
||||
knowledgeExtracted: boolean;
|
||||
decisionReady: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function FieldConsole() {
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'queue' | 'viewer' | 'timeline'>('upload');
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/missions')
|
||||
.then(r => r.json())
|
||||
.then(data => setMissions(data));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Field Console</h2>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20, borderBottom: '2px solid #333' }}>
|
||||
{(['upload', 'queue', 'viewer', 'timeline'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: activeTab === tab ? '#333' : 'transparent',
|
||||
color: activeTab === tab ? 'white' : '#333',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'upload' && <UploadView />}
|
||||
{activeTab === 'queue' && <QueueView missions={missions} />}
|
||||
{activeTab === 'viewer' && <ArtifactViewerView missions={missions} />}
|
||||
{activeTab === 'timeline' && <TimelineView missions={missions} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Upload View
|
||||
function UploadView() {
|
||||
return (
|
||||
<div>
|
||||
<h3>Upload</h3>
|
||||
<div style={{ display: 'grid', gap: 16, maxWidth: 400 }}>
|
||||
<UploadCard icon="📱" label="Phone" />
|
||||
<UploadCard icon="🎥" label="Video" />
|
||||
<UploadCard icon="📷" label="Image" />
|
||||
<UploadCard icon="📍" label="GPS" />
|
||||
<button style={{
|
||||
padding: '16px 24px',
|
||||
background: '#1e90ff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: 18,
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadCard({ icon, label }: { icon: string; label: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
border: '2px dashed #ccc',
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<span style={{ fontSize: 32 }}>{icon}</span>
|
||||
<p style={{ margin: '8px 0 0', color: '#666' }}>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Queue View
|
||||
function QueueView({ missions }: { missions: Mission[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3>Queue</h3>
|
||||
{missions.map(mission => (
|
||||
<div key={mission.id} style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<strong>{mission.id}</strong>
|
||||
<span style={{ color: '#666' }}>{mission.status}</span>
|
||||
</div>
|
||||
<ProgressBar progress={mission.progress} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 8, fontSize: 12 }}>
|
||||
<StatusBadge label="Uploading" done={mission.steps.uploaded} />
|
||||
<StatusBadge label="Metadata" done={mission.steps.metadataExtracted} />
|
||||
<StatusBadge label="Knowledge" done={mission.steps.knowledgeExtracted} />
|
||||
<StatusBadge label="Decision" done={mission.steps.decisionReady} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div style={{ height: 8, background: '#f0f0f0', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: `${progress}%`,
|
||||
height: '100%',
|
||||
background: '#1e90ff',
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ label, done }: { label: string; done: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
background: done ? '#32cd32' : '#ffd700',
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{done ? '✓' : '⏳'} {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Artifact Viewer View
|
||||
function ArtifactViewerView({ missions }: { missions: Mission[] }) {
|
||||
const [selectedMission, setSelectedMission] = useState<Mission | null>(missions[0] || null);
|
||||
|
||||
if (!selectedMission) return <div>No missions yet</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Artifact Viewer</h3>
|
||||
|
||||
{/* Mission selector */}
|
||||
<select
|
||||
value={selectedMission.id}
|
||||
onChange={e => {
|
||||
const mission = missions.find(m => m.id === e.target.value);
|
||||
if (mission) setSelectedMission(mission);
|
||||
}}
|
||||
style={{ marginBottom: 16, padding: 8 }}
|
||||
>
|
||||
{missions.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{/* Original */}
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8 }}>
|
||||
<h4>Original</h4>
|
||||
<div style={{
|
||||
background: '#f0f0f0',
|
||||
height: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
<span style={{ fontSize: 48 }}>🎥</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8 }}>
|
||||
<h4>Metadata</h4>
|
||||
<p><strong>GPS:</strong> 59.3293, 18.0686</p>
|
||||
<p><strong>Hash:</strong> <code>sha256:abc123...</code></p>
|
||||
<p><strong>Storage:</strong> <code>file:///uploads/...</code></p>
|
||||
</div>
|
||||
|
||||
{/* Knowledge */}
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8 }}>
|
||||
<h4>Knowledge</h4>
|
||||
<p><strong>Objects:</strong> Pending</p>
|
||||
<p><strong>Ontology:</strong> Pending</p>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<div style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8 }}>
|
||||
<h4>History</h4>
|
||||
<p>Created: {new Date(selectedMission.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Processing Timeline View
|
||||
function TimelineView({ missions }: { missions: Mission[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h3>Processing Timeline</h3>
|
||||
{missions.map(mission => (
|
||||
<div key={mission.id} style={{ border: '1px solid #ccc', padding: 16, borderRadius: 8, marginBottom: 16 }}>
|
||||
<h4>{mission.id}</h4>
|
||||
<TimelineStep label="Uploaded" time="09:41" done={mission.steps.uploaded} />
|
||||
<TimelineStep label="Archived" time="09:41" done={mission.steps.archived} />
|
||||
<TimelineStep label="Metadata" time="09:42" done={mission.steps.metadataExtracted} />
|
||||
<TimelineStep label="Knowledge" time="09:43" done={mission.steps.knowledgeExtracted} />
|
||||
<TimelineStep label="Decision" time="Pending" done={mission.steps.decisionReady} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineStep({ label, time, done }: { label: string; time: string; done: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<span style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: done ? '#32cd32' : '#ccc',
|
||||
}} />
|
||||
<span style={{ minWidth: 100, color: '#666' }}>{time}</span>
|
||||
<span style={{ color: done ? '#333' : '#999' }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldConsole;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Health Dashboard
|
||||
*
|
||||
* System pulse. Not BI. Not statistics.
|
||||
* Just: which engines are running?
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface EngineStatus {
|
||||
name: string;
|
||||
status: 'running' | 'stopped' | 'error';
|
||||
lastHeartbeat: string;
|
||||
}
|
||||
|
||||
function HealthDashboard() {
|
||||
const [engines, setEngines] = useState<EngineStatus[]>([
|
||||
{ name: 'Reality', status: 'running', lastHeartbeat: new Date().toISOString() },
|
||||
{ name: 'Knowledge', status: 'running', lastHeartbeat: new Date().toISOString() },
|
||||
{ name: 'Decision', status: 'stopped', lastHeartbeat: '-' },
|
||||
{ name: 'Mission', status: 'stopped', lastHeartbeat: '-' },
|
||||
{ name: 'Economic', status: 'running', lastHeartbeat: new Date().toISOString() },
|
||||
{ name: 'Learning', status: 'stopped', lastHeartbeat: '-' },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Poll health status
|
||||
const interval = setInterval(() => {
|
||||
fetch('/health')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Update engine status based on health response
|
||||
setEngines(prev => prev.map(engine => ({
|
||||
...engine,
|
||||
status: data.status === 'ok' ? 'running' : 'error',
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
})));
|
||||
})
|
||||
.catch(() => {
|
||||
setEngines(prev => prev.map(engine => ({
|
||||
...engine,
|
||||
status: 'error',
|
||||
})));
|
||||
});
|
||||
}, 30000); // Every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Health Dashboard</h2>
|
||||
<p style={{ color: '#666', marginBottom: 20 }}>System pulse — not statistics</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{engines.map(engine => (
|
||||
<div key={engine.name} style={{
|
||||
border: '1px solid #ccc',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>{engine.name} Engine</h3>
|
||||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 12 }}>
|
||||
Last heartbeat: {engine.lastHeartbeat === '-' ? '-' : new Date(engine.lastHeartbeat).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
<StatusIndicator status={engine.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: 'running' | 'stopped' | 'error' }) {
|
||||
const colors = {
|
||||
running: '#32cd32',
|
||||
stopped: '#ccc',
|
||||
error: '#dc143c',
|
||||
};
|
||||
|
||||
const labels = {
|
||||
running: '✓ Running',
|
||||
stopped: '○ Stopped',
|
||||
error: '✗ Error',
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 16,
|
||||
background: colors[status],
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
{labels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthDashboard;
|
||||
Reference in New Issue
Block a user