TBM Configuration Management System

- Config versioning with approval workflow (Draft/Approved/Archived)
- Fleet management with real-time machine status
- Deploy configs to machines with status tracking
- Full audit logging for compliance
- React dashboard + FastAPI backend + machine simulator
This commit is contained in:
SpartanTech
2025-11-30 21:15:43 -06:00
commit f62ef2cf05
32 changed files with 9438 additions and 0 deletions

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TBM Config Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2719
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "tbm-config-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"zustand": "^4.4.7",
"lucide-react": "^0.303.0"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

70
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore, useToastStore } from './store';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Machines from './pages/Machines';
import Configurations from './pages/Configurations';
import AuditLog from './pages/AuditLog';
import { X } from 'lucide-react';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function Toasts() {
const { toasts, removeToast } = useToastStore();
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`
flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg fade-in
${toast.type === 'success' ? 'bg-green-500/20 border border-green-500/50 text-green-400' : ''}
${toast.type === 'error' ? 'bg-red-500/20 border border-red-500/50 text-red-400' : ''}
${toast.type === 'info' ? 'bg-tbc-orange/20 border border-tbc-orange/50 text-tbc-orange' : ''}
`}
>
<span className="text-sm">{toast.message}</span>
<button onClick={() => removeToast(toast.id)} className="hover:opacity-70">
<X size={16} />
</button>
</div>
))}
</div>
);
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/machines" element={<Machines />} />
<Route path="/configurations" element={<Configurations />} />
<Route path="/audit" element={<AuditLog />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
<Toasts />
</BrowserRouter>
);
}

149
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,149 @@
// API client for TBM Config Manager
import type { User, Machine, Configuration, Deployment, AuditLog, Stats, ConfigParams } from './types';
const API_BASE = '/api';
// Get token from localStorage
function getToken(): string | null {
return localStorage.getItem('token');
}
// API request helper
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
// Auth API
export const authApi = {
login: async (username: string, password: string): Promise<{ token: string; user: User }> => {
const result = await request<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
localStorage.setItem('token', result.token);
localStorage.setItem('user', JSON.stringify(result.user));
return result;
},
logout: async (): Promise<void> => {
try {
await request('/auth/logout', { method: 'POST' });
} finally {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
},
me: (): Promise<User> => request('/auth/me'),
getStoredUser: (): User | null => {
const stored = localStorage.getItem('user');
return stored ? JSON.parse(stored) : null;
},
isLoggedIn: (): boolean => {
return !!getToken();
},
};
// Machines API
export const machinesApi = {
list: (): Promise<Machine[]> => request('/machines'),
get: (id: string): Promise<Machine> => request(`/machines/${id}`),
};
// Configurations API
export const configsApi = {
list: (params?: { machine_type?: string; status?: string }): Promise<Configuration[]> => {
const query = new URLSearchParams(params as Record<string, string>).toString();
return request(`/configs${query ? `?${query}` : ''}`);
},
get: (id: string): Promise<Configuration> => request(`/configs/${id}`),
create: (data: {
version: string;
name: string;
machine_type: string;
config: ConfigParams;
}): Promise<{ id: string; version: string; status: string }> =>
request('/configs', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: {
version: string;
name: string;
machine_type: string;
config: ConfigParams;
}): Promise<{ ok: boolean }> =>
request(`/configs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
approve: (id: string): Promise<{ ok: boolean; status: string }> =>
request(`/configs/${id}/approve`, { method: 'PUT' }),
};
// Deployments API
export const deploymentsApi = {
list: (params?: { machine_id?: string; status?: string }): Promise<Deployment[]> => {
const query = new URLSearchParams(params as Record<string, string>).toString();
return request(`/deployments${query ? `?${query}` : ''}`);
},
get: (id: string): Promise<Deployment> => request(`/deployments/${id}`),
create: (config_id: string, machine_id: string): Promise<{ id: string; status: string; config_version: string }> =>
request('/deployments', {
method: 'POST',
body: JSON.stringify({ config_id, machine_id }),
}),
};
// Audit API
export const auditApi = {
list: (params?: { user?: string; action?: string; limit?: number }): Promise<AuditLog[]> => {
const query = new URLSearchParams(params as Record<string, string>).toString();
return request(`/audit${query ? `?${query}` : ''}`);
},
};
// Stats API
export const statsApi = {
get: (): Promise<Stats> => request('/stats'),
};

View File

@@ -0,0 +1,131 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store';
import {
LayoutDashboard,
HardDrive,
FileCode,
ScrollText,
LogOut,
Menu,
X
} from 'lucide-react';
import { useState } from 'react';
interface LayoutProps {
children: React.ReactNode;
}
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/machines', icon: HardDrive, label: 'Machines' },
{ to: '/configurations', icon: FileCode, label: 'Configurations' },
{ to: '/audit', icon: ScrollText, label: 'Audit Log' },
];
export default function Layout({ children }: LayoutProps) {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const [sidebarOpen, setSidebarOpen] = useState(false);
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-tbc-black flex">
{/* Sidebar */}
<aside className={`
fixed inset-y-0 left-0 z-40 w-64 bg-neutral-900 border-r border-neutral-800
transform transition-transform duration-200 ease-in-out
lg:relative lg:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-6 border-b border-neutral-800">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-tbc-orange rounded flex items-center justify-center">
<span className="font-bold text-white text-sm">TB</span>
</div>
<span className="font-semibold text-white tracking-tight">TBM Config</span>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-neutral-400 hover:text-white"
>
<X size={20} />
</button>
</div>
{/* Navigation */}
<nav className="p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) => `
flex items-center gap-3 px-4 py-3 rounded-lg transition-colors
${isActive
? 'bg-tbc-orange/10 text-tbc-orange border border-tbc-orange/30'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}
`}
>
<item.icon size={20} />
<span className="font-medium">{item.label}</span>
</NavLink>
))}
</nav>
{/* User info */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-neutral-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white">{user?.name}</p>
<p className="text-xs text-neutral-500 capitalize">{user?.role}</p>
</div>
<button
onClick={handleLogout}
className="p-2 text-neutral-400 hover:text-red-400 transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
</div>
</aside>
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<main className="flex-1 flex flex-col min-h-screen">
{/* Top bar */}
<header className="h-16 flex items-center justify-between px-6 border-b border-neutral-800 bg-neutral-900/50">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden text-neutral-400 hover:text-white"
>
<Menu size={24} />
</button>
<div className="flex items-center gap-2 ml-auto">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs text-neutral-400">System Online</span>
</div>
</header>
{/* Page content */}
<div className="flex-1 p-6 overflow-auto">
{children}
</div>
</main>
</div>
);
}

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--tbc-black: #0A0A0A;
--tbc-white: #FAFAFA;
--tbc-orange: #FF5722;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: #0A0A0A;
color: #FAFAFA;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #171717;
}
::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #525252;
}
/* Animations */
@keyframes pulse-orange {
0%, 100% {
box-shadow: 0 0 0 0 rgba(255, 87, 34, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(255, 87, 34, 0);
}
}
.pulse-orange {
animation: pulse-orange 2s infinite;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import { auditApi } from '../api';
import type { AuditLog as AuditLogType } from '../types';
import {
ScrollText,
Filter,
User,
FileCode,
Rocket,
LogIn,
CheckCircle,
XCircle,
RefreshCw
} from 'lucide-react';
export default function AuditLog() {
const [logs, setLogs] = useState<AuditLogType[]>([]);
const [loading, setLoading] = useState(true);
const [userFilter, setUserFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
const loadLogs = async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (userFilter) params.user = userFilter;
if (actionFilter) params.action = actionFilter;
const data = await auditApi.list(params);
setLogs(data);
} catch (err) {
console.error('Failed to load audit logs:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLogs();
}, [userFilter, actionFilter]);
const formatTime = (iso: string) => {
const date = new Date(iso);
return date.toLocaleString();
};
const getActionIcon = (action: string) => {
if (action.includes('login')) return <LogIn size={16} className="text-blue-400" />;
if (action.includes('config')) return <FileCode size={16} className="text-purple-400" />;
if (action.includes('deployment')) {
if (action.includes('success')) return <CheckCircle size={16} className="text-green-400" />;
if (action.includes('failed')) return <XCircle size={16} className="text-red-400" />;
return <Rocket size={16} className="text-amber-400" />;
}
return <ScrollText size={16} className="text-neutral-400" />;
};
const getActionBadgeClass = (action: string) => {
if (action.includes('login')) return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
if (action.includes('created')) return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
if (action.includes('approved')) return 'bg-green-500/10 text-green-400 border-green-500/30';
if (action.includes('success')) return 'bg-green-500/10 text-green-400 border-green-500/30';
if (action.includes('failed')) return 'bg-red-500/10 text-red-400 border-red-500/30';
if (action.includes('initiated')) return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
return 'bg-neutral-500/10 text-neutral-400 border-neutral-500/30';
};
const uniqueUsers = [...new Set(logs.map(l => l.user))];
const uniqueActions = [...new Set(logs.map(l => l.action.split('.')[0]))];
return (
<div className="space-y-6 fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
<p className="text-neutral-500 mt-1">Track all system activity and changes</p>
</div>
<button
onClick={loadLogs}
className="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700
text-neutral-300 rounded-lg transition-colors"
>
<RefreshCw size={16} />
Refresh
</button>
</div>
{/* Filters */}
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div className="flex items-center gap-2 mb-4">
<Filter size={16} className="text-neutral-500" />
<span className="text-sm font-medium text-neutral-400">Filters</span>
</div>
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-neutral-500 mb-1">User</label>
<select
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2
text-white text-sm focus:outline-none focus:border-tbc-orange"
>
<option value="">All Users</option>
{uniqueUsers.map(user => (
<option key={user} value={user}>{user}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-xs text-neutral-500 mb-1">Action Type</label>
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2
text-white text-sm focus:outline-none focus:border-tbc-orange"
>
<option value="">All Actions</option>
{uniqueActions.map(action => (
<option key={action} value={action}>{action}</option>
))}
</select>
</div>
{(userFilter || actionFilter) && (
<div className="flex items-end">
<button
onClick={() => { setUserFilter(''); setActionFilter(''); }}
className="px-3 py-2 text-sm text-tbc-orange hover:text-white transition-colors"
>
Clear Filters
</button>
</div>
)}
</div>
</div>
{/* Log table */}
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-tbc-orange border-t-transparent rounded-full animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="text-center py-12 text-neutral-500">
No audit logs found
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-neutral-800">
<th className="px-6 py-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">
Timestamp
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">
Resource
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">
Details
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-800">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-neutral-800/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-neutral-400 font-mono">
{formatTime(log.timestamp)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-neutral-800 rounded">
<User size={14} className="text-neutral-400" />
</div>
<span className="text-sm text-white">{log.user}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{getActionIcon(log.action)}
<span className={`px-2 py-1 text-xs font-medium rounded border ${getActionBadgeClass(log.action)}`}>
{log.action}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-neutral-300 font-mono">
{log.resource}
</span>
</td>
<td className="px-6 py-4">
{Object.keys(log.details).length > 0 ? (
<code className="text-xs text-neutral-500 bg-neutral-800 px-2 py-1 rounded">
{JSON.stringify(log.details)}
</code>
) : (
<span className="text-xs text-neutral-600">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Log count */}
{!loading && logs.length > 0 && (
<div className="text-sm text-neutral-500 text-right">
Showing {logs.length} entries
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,488 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
HardDrive,
FileCode,
Rocket,
Clock,
RefreshCw,
AlertTriangle,
Wifi,
WifiOff,
Activity,
Gauge,
Navigation,
Shield,
Pause,
Wrench,
Power
} from 'lucide-react';
interface FleetMachine {
id: string;
name: string;
machine_type: string;
status: 'online' | 'offline';
operational_mode: 'active' | 'standby' | 'maintenance' | 'decommissioned';
current_version: string | null;
last_seen: string | null;
last_seen_formatted: string;
config_name: string | null;
operational: {
thrust_kn: number;
rpm: number;
advance_rate: number;
face_pressure: number;
steering_mode: string;
} | null;
}
interface ConfigVersion {
version: string;
name: string;
machine_type: string;
status: string;
machine_count: number;
machines_using: string | null;
}
interface ActivityItem {
timestamp: string;
user: string;
action: string;
resource: string;
details: Record<string, unknown>;
}
interface DashboardData {
fleet: FleetMachine[];
config_versions: ConfigVersion[];
weekly_deployments: { date: string; total: number; success: number; failed: number }[];
recent_activity: ActivityItem[];
pending_count: number;
machines_needing_attention: FleetMachine[];
summary: {
total_machines: number;
online_machines: number;
total_configs: number;
pending_deployments: number;
};
}
const getModeIcon = (mode: string) => {
switch (mode) {
case 'active': return Power;
case 'standby': return Pause;
case 'maintenance': return Wrench;
case 'decommissioned': return Power;
default: return Power;
}
};
const getModeColor = (mode: string) => {
switch (mode) {
case 'active': return 'text-green-400';
case 'standby': return 'text-blue-400';
case 'maintenance': return 'text-amber-400';
case 'decommissioned': return 'text-neutral-500';
default: return 'text-neutral-400';
}
};
export default function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const navigate = useNavigate();
const loadDashboard = async (showRefresh = false) => {
if (showRefresh) setRefreshing(true);
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/dashboard', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const dashboardData = await response.json();
setData(dashboardData);
}
} catch (err) {
console.error('Failed to load dashboard:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
loadDashboard();
const interval = setInterval(() => loadDashboard(), 5000); // Poll every 5 seconds
return () => clearInterval(interval);
}, []);
const getActionIcon = (action: string) => {
if (action.includes('login')) return '🔐';
if (action.includes('created')) return '📝';
if (action.includes('approved')) return '✅';
if (action.includes('success')) return '🚀';
if (action.includes('failed')) return '❌';
if (action.includes('initiated')) return '⏳';
if (action.includes('mode')) return '⚙️';
return '📋';
};
const formatActivityTime = (iso: string) => {
const date = new Date(iso);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-tbc-orange border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!data) return null;
return (
<div className="space-y-6 fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Fleet Dashboard</h1>
<p className="text-neutral-500 mt-1">TBM Configuration Management Overview</p>
</div>
<button
onClick={() => loadDashboard(true)}
disabled={refreshing}
className="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700
text-neutral-300 rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Alerts Banner - Only for active machines that are offline */}
{data.machines_needing_attention.length > 0 && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 flex items-center gap-4">
<AlertTriangle className="text-red-500 flex-shrink-0" size={24} />
<div>
<p className="text-red-400 font-medium">
{data.machines_needing_attention.length} active machine(s) offline
</p>
<p className="text-red-500/70 text-sm">
{data.machines_needing_attention.map(m => m.id).join(', ')} - should be online but not responding
</p>
</div>
</div>
)}
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/10 rounded-lg">
<HardDrive size={20} className="text-green-500" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{data.summary.online_machines}/{data.summary.total_machines}
</p>
<p className="text-sm text-neutral-500">Machines Online</p>
</div>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg">
<FileCode size={20} className="text-blue-500" />
</div>
<div>
<p className="text-2xl font-bold text-white">{data.summary.total_configs}</p>
<p className="text-sm text-neutral-500">Config Versions</p>
</div>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-tbc-orange/10 rounded-lg">
<Rocket size={20} className="text-tbc-orange" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{data.weekly_deployments.reduce((sum, d) => sum + d.success, 0)}
</p>
<p className="text-sm text-neutral-500">Deploys (7 days)</p>
</div>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${data.summary.pending_deployments > 0 ? 'bg-amber-500/10' : 'bg-neutral-800'}`}>
<Clock size={20} className={data.summary.pending_deployments > 0 ? 'text-amber-500' : 'text-neutral-500'} />
</div>
<div>
<p className="text-2xl font-bold text-white">{data.summary.pending_deployments}</p>
<p className="text-sm text-neutral-500">Pending</p>
</div>
</div>
</div>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Fleet Overview - Takes 2 columns */}
<div className="lg:col-span-2 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
<div className="px-6 py-4 border-b border-neutral-800 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Fleet Overview</h2>
<button
onClick={() => navigate('/machines')}
className="text-sm text-tbc-orange hover:text-white transition-colors"
>
View All →
</button>
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{data.fleet.map((machine) => {
const ModeIcon = getModeIcon(machine.operational_mode);
const needsAttention = machine.status === 'offline' && machine.operational_mode === 'active';
return (
<div
key={machine.id}
className={`bg-neutral-800/50 rounded-lg p-4 border transition-colors cursor-pointer hover:border-neutral-600
${needsAttention ? 'border-red-500/50 bg-red-500/5' :
machine.status === 'online' ? 'border-green-500/30' : 'border-neutral-700'}`}
onClick={() => navigate('/machines')}
>
{/* Machine Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{machine.status === 'online' ? (
<Wifi size={16} className="text-green-500" />
) : (
<WifiOff size={16} className="text-neutral-500" />
)}
<span className="font-semibold text-white">{machine.id}</span>
{needsAttention && (
<AlertTriangle size={14} className="text-red-400" />
)}
</div>
<div className="flex items-center gap-2">
<ModeIcon size={12} className={getModeColor(machine.operational_mode)} />
<span className="text-xs text-tbc-orange font-mono bg-tbc-orange/10 px-2 py-0.5 rounded">
v{machine.current_version || '—'}
</span>
</div>
</div>
<p className="text-sm text-neutral-400 mb-3">{machine.name}</p>
{/* Operational Params */}
{machine.operational && machine.status === 'online' ? (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5">
<Gauge size={12} className="text-neutral-500" />
<span className="text-neutral-500">Thrust:</span>
<span className="text-white font-mono">{machine.operational.thrust_kn} kN</span>
</div>
<div className="flex items-center gap-1.5">
<Activity size={12} className="text-neutral-500" />
<span className="text-neutral-500">RPM:</span>
<span className="text-white font-mono">{machine.operational.rpm}</span>
</div>
<div className="flex items-center gap-1.5">
<Rocket size={12} className="text-neutral-500" />
<span className="text-neutral-500">Advance:</span>
<span className="text-white font-mono">{machine.operational.advance_rate} mm/min</span>
</div>
<div className="flex items-center gap-1.5">
<Navigation size={12} className="text-neutral-500" />
<span className="text-neutral-500">Steering:</span>
<span className="text-white font-mono">{machine.operational.steering_mode}</span>
</div>
</div>
) : (
<div className="text-xs text-neutral-600 italic">
{machine.operational_mode === 'standby' ? 'Machine on standby' :
machine.operational_mode === 'maintenance' ? 'Under maintenance' :
machine.operational_mode === 'decommissioned' ? 'Decommissioned' :
machine.status === 'offline' ? 'Machine offline' : 'No config loaded'}
</div>
)}
{/* Last Seen */}
<div className="mt-3 pt-2 border-t border-neutral-700 text-xs text-neutral-500">
Last seen: {machine.last_seen_formatted}
</div>
</div>
);
})}
</div>
</div>
{/* Right Column - Activity Feed */}
<div className="space-y-6">
{/* Config Versions */}
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-800">
<h3 className="font-semibold text-white">Config Versions</h3>
</div>
<div className="p-3 space-y-2 max-h-48 overflow-auto">
{data.config_versions.map((cv) => (
<div
key={`${cv.version}-${cv.machine_type}`}
className="flex items-center justify-between bg-neutral-800/50 rounded px-3 py-2"
>
<div>
<span className="font-mono text-tbc-orange text-sm">v{cv.version}</span>
<span className="text-neutral-500 text-xs ml-2">{cv.machine_type}</span>
</div>
<div className="flex items-center gap-1">
{cv.machine_count > 0 ? (
<span className="bg-green-500/20 text-green-400 text-xs px-2 py-0.5 rounded-full">
{cv.machine_count} machine{cv.machine_count !== 1 ? 's' : ''}
</span>
) : (
<span className="text-neutral-600 text-xs">unused</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="font-semibold text-white">Recent Activity</h3>
<button
onClick={() => navigate('/audit')}
className="text-xs text-tbc-orange hover:text-white transition-colors"
>
View All →
</button>
</div>
<div className="divide-y divide-neutral-800 max-h-64 overflow-auto">
{data.recent_activity.map((activity, idx) => (
<div key={idx} className="px-4 py-3 hover:bg-neutral-800/50 transition-colors">
<div className="flex items-start gap-2">
<span className="text-base">{getActionIcon(activity.action)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">
<span className="text-neutral-400">{activity.user}</span>
{' · '}
<span className="text-neutral-300">{activity.action.replace('.', ' ')}</span>
</p>
<p className="text-xs text-neutral-500 truncate">{activity.resource}</p>
</div>
<span className="text-xs text-neutral-600 whitespace-nowrap">
{formatActivityTime(activity.timestamp)}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Weekly Deployment Chart (simple bar representation) */}
{data.weekly_deployments.length > 0 && (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
<h3 className="font-semibold text-white mb-4">Deployment Activity (Last 7 Days)</h3>
<div className="flex items-end justify-between gap-2 h-24">
{data.weekly_deployments.map((day, idx) => {
const maxTotal = Math.max(...data.weekly_deployments.map(d => d.total), 1);
const height = (day.total / maxTotal) * 100;
const successHeight = day.total > 0 ? (day.success / day.total) * height : 0;
return (
<div key={idx} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full relative" style={{ height: '80px' }}>
{/* Failed portion */}
<div
className="absolute bottom-0 w-full bg-red-500/30 rounded-t"
style={{ height: `${height}%` }}
/>
{/* Success portion */}
<div
className="absolute bottom-0 w-full bg-green-500 rounded-t"
style={{ height: `${successHeight}%` }}
/>
</div>
<span className="text-xs text-neutral-500">
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' })}
</span>
</div>
);
})}
</div>
<div className="flex items-center justify-center gap-6 mt-4 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded" />
<span className="text-neutral-400">Success</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-500/30 rounded" />
<span className="text-neutral-400">Failed</span>
</div>
</div>
</div>
)}
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => navigate('/configurations')}
className="bg-neutral-900 border border-neutral-800 hover:border-tbc-orange/50 rounded-xl p-4
text-left transition-colors group"
>
<FileCode className="text-tbc-orange mb-2" size={24} />
<h3 className="font-semibold text-white group-hover:text-tbc-orange transition-colors">
Create Configuration
</h3>
<p className="text-sm text-neutral-500">Define new operating parameters</p>
</button>
<button
onClick={() => navigate('/machines')}
className="bg-neutral-900 border border-neutral-800 hover:border-tbc-orange/50 rounded-xl p-4
text-left transition-colors group"
>
<Rocket className="text-tbc-orange mb-2" size={24} />
<h3 className="font-semibold text-white group-hover:text-tbc-orange transition-colors">
Deploy to Machine
</h3>
<p className="text-sm text-neutral-500">Push configuration to a TBM</p>
</button>
<button
onClick={() => navigate('/audit')}
className="bg-neutral-900 border border-neutral-800 hover:border-tbc-orange/50 rounded-xl p-4
text-left transition-colors group"
>
<Shield className="text-tbc-orange mb-2" size={24} />
<h3 className="font-semibold text-white group-hover:text-tbc-orange transition-colors">
View Audit Log
</h3>
<p className="text-sm text-neutral-500">Review all system activity</p>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store';
import { Loader2 } from 'lucide-react';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading, error } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login(username, password);
navigate('/');
} catch {
// Error is already in store
}
};
return (
<div className="min-h-screen bg-tbc-black flex items-center justify-center p-4">
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient(
0deg,
transparent,
transparent 50px,
rgba(255,87,34,0.1) 50px,
rgba(255,87,34,0.1) 51px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 50px,
rgba(255,87,34,0.1) 50px,
rgba(255,87,34,0.1) 51px
)`
}} />
</div>
<div className="w-full max-w-md relative">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-tbc-orange rounded-lg mb-4">
<span className="font-bold text-white text-2xl">TB</span>
</div>
<h1 className="text-2xl font-bold text-white">TBM Config Manager</h1>
<p className="text-neutral-500 mt-1">Configuration Management System</p>
</div>
{/* Login form */}
<form
onSubmit={handleSubmit}
className="bg-neutral-900 border border-neutral-800 rounded-xl p-8 shadow-2xl"
>
<div className="space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-400 mb-2">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-4 py-3 text-white
focus:outline-none focus:border-tbc-orange focus:ring-1 focus:ring-tbc-orange
transition-colors"
placeholder="Enter username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-400 mb-2">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-4 py-3 text-white
focus:outline-none focus:border-tbc-orange focus:ring-1 focus:ring-tbc-orange
transition-colors"
placeholder="Enter password"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-tbc-orange hover:bg-tbc-orange-dark text-white font-semibold
py-3 px-4 rounded-lg transition-colors disabled:opacity-50
disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 size={20} className="animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</button>
</div>
{/* Demo credentials */}
<div className="mt-6 pt-6 border-t border-neutral-800">
<p className="text-xs text-neutral-500 text-center mb-3">Demo Credentials</p>
<div className="grid grid-cols-3 gap-2 text-xs">
<button
type="button"
onClick={() => { setUsername('admin'); setPassword('admin123'); }}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-400 py-2 px-3 rounded transition-colors"
>
Admin
</button>
<button
type="button"
onClick={() => { setUsername('engineer'); setPassword('eng123'); }}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-400 py-2 px-3 rounded transition-colors"
>
Engineer
</button>
<button
type="button"
onClick={() => { setUsername('operator'); setPassword('op123'); }}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-400 py-2 px-3 rounded transition-colors"
>
Operator
</button>
</div>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,621 @@
import { useEffect, useState } from 'react';
import { machinesApi, configsApi, deploymentsApi } from '../api';
import { useToastStore } from '../store';
import type { Machine, Configuration } from '../types';
import {
HardDrive,
Wifi,
WifiOff,
Rocket,
X,
RefreshCw,
Clock,
History,
CheckCircle,
XCircle,
AlertCircle,
Settings,
Pause,
Wrench,
Power
} from 'lucide-react';
interface DeploymentHistoryItem {
id: string;
config_version: string;
config_name: string;
config_id: string;
status: 'pending' | 'success' | 'failed';
initiated_by: string;
initiated_at: string;
completed_at: string | null;
error: string | null;
}
interface MachineHistory {
machine: {
id: string;
name: string;
machine_type: string;
current_version: string | null;
status: string;
};
deployments: DeploymentHistoryItem[];
total_deployments: number;
successful_deployments: number;
failed_deployments: number;
}
const OPERATIONAL_MODES = [
{ value: 'active', label: 'Active', icon: Power, color: 'green', description: 'Machine should be online and operational' },
{ value: 'standby', label: 'Standby', icon: Pause, color: 'blue', description: 'Intentionally idle between projects' },
{ value: 'maintenance', label: 'Maintenance', icon: Wrench, color: 'amber', description: 'Under repair or scheduled maintenance' },
{ value: 'decommissioned', label: 'Decommissioned', icon: Power, color: 'neutral', description: 'No longer in service' },
];
export default function Machines() {
const [machines, setMachines] = useState<Machine[]>([]);
const [configs, setConfigs] = useState<Configuration[]>([]);
const [loading, setLoading] = useState(true);
const [deployModal, setDeployModal] = useState<{ machine: Machine } | null>(null);
const [selectedConfig, setSelectedConfig] = useState<string>('');
const [deploying, setDeploying] = useState(false);
const addToast = useToastStore((s) => s.addToast);
// History modal state
const [historyModal, setHistoryModal] = useState<Machine | null>(null);
const [historyData, setHistoryData] = useState<MachineHistory | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
// Mode change modal state
const [modeModal, setModeModal] = useState<Machine | null>(null);
const [newMode, setNewMode] = useState<string>('');
const [changingMode, setChangingMode] = useState(false);
const loadData = async () => {
try {
const [machinesData, configsData] = await Promise.all([
machinesApi.list(),
configsApi.list({ status: 'approved' }),
]);
setMachines(machinesData);
setConfigs(configsData);
} catch (err) {
console.error('Failed to load data:', err);
addToast('Failed to load machines', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
const interval = setInterval(loadData, 5000); // Poll every 5 seconds for faster status updates
return () => clearInterval(interval);
}, []);
const handleDeploy = async () => {
if (!deployModal || !selectedConfig) return;
setDeploying(true);
try {
const result = await deploymentsApi.create(selectedConfig, deployModal.machine.id);
addToast(`Deployment initiated: v${result.config_version}`, 'success');
setDeployModal(null);
setSelectedConfig('');
loadData();
} catch (err) {
addToast((err as Error).message, 'error');
} finally {
setDeploying(false);
}
};
const loadHistory = async (machine: Machine) => {
setHistoryModal(machine);
setHistoryLoading(true);
setHistoryData(null);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/machines/${machine.id}/history`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load history');
const data = await response.json();
setHistoryData(data);
} catch (err) {
addToast((err as Error).message, 'error');
setHistoryModal(null);
} finally {
setHistoryLoading(false);
}
};
const handleModeChange = async () => {
if (!modeModal || !newMode) return;
setChangingMode(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/machines/${modeModal.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ operational_mode: newMode })
});
if (!response.ok) throw new Error('Failed to update mode');
addToast(`${modeModal.id} set to ${newMode}`, 'success');
setModeModal(null);
setNewMode('');
loadData();
} catch (err) {
addToast((err as Error).message, 'error');
} finally {
setChangingMode(false);
}
};
const handleQuickModeChange = async (machine: Machine, newModeValue: string) => {
if (newModeValue === machine.operational_mode) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/machines/${machine.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ operational_mode: newModeValue })
});
if (!response.ok) throw new Error('Failed to update mode');
addToast(`${machine.id} set to ${newModeValue}`, 'success');
loadData();
} catch (err) {
addToast((err as Error).message, 'error');
}
};
const openModeModal = (machine: Machine) => {
setModeModal(machine);
setNewMode(machine.operational_mode || 'active');
};
const formatDateTime = (iso: string) => {
return new Date(iso).toLocaleString();
};
const getModeConfig = (mode: string) => {
return OPERATIONAL_MODES.find(m => m.value === mode) || OPERATIONAL_MODES[0];
};
// Check if machine needs attention (active but offline)
const needsAttention = (machine: Machine) => {
return machine.status === 'offline' && machine.operational_mode === 'active';
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-tbc-orange border-t-transparent rounded-full animate-spin" />
</div>
);
}
// Get compatible configs for selected machine
const compatibleConfigs = deployModal
? configs.filter(c => c.machine_type === deployModal.machine.machine_type)
: [];
return (
<div className="space-y-6 fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Machines</h1>
<p className="text-neutral-500 mt-1">Monitor and manage TBM fleet</p>
</div>
<button
onClick={loadData}
className="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700
text-neutral-300 rounded-lg transition-colors"
>
<RefreshCw size={16} />
Refresh
</button>
</div>
{/* Machine grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{machines.map((machine) => {
const modeConfig = getModeConfig(machine.operational_mode);
const attention = needsAttention(machine);
return (
<div
key={machine.id}
className={`bg-neutral-900 border rounded-xl overflow-hidden transition-colors ${
attention
? 'border-red-500/50 bg-red-500/5'
: 'border-neutral-800 hover:border-neutral-700'
}`}
>
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${machine.status === 'online' ? 'bg-green-500/10' : 'bg-neutral-800'}`}>
<HardDrive size={20} className={machine.status === 'online' ? 'text-green-500' : 'text-neutral-500'} />
</div>
<div>
<h3 className="font-semibold text-white flex items-center gap-2">
{machine.id}
{attention && (
<span className="text-red-400" title="Needs attention">
<AlertCircle size={14} />
</span>
)}
</h3>
<p className="text-xs text-neutral-500">{machine.machine_type}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Online/Offline status */}
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium
${machine.status === 'online'
? 'bg-green-500/10 text-green-400 border border-green-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{machine.status === 'online' ? <Wifi size={10} /> : <WifiOff size={10} />}
{machine.status}
</div>
</div>
</div>
{/* Details */}
<div className="px-6 py-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-500">Name</span>
<span className="text-sm text-white">{machine.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-500">Mode</span>
<select
value={machine.operational_mode || 'active'}
onChange={(e) => handleQuickModeChange(machine, e.target.value)}
className={`text-sm px-2 py-1 rounded border-0 cursor-pointer transition-colors
${modeConfig.color === 'green' ? 'bg-green-500/20 text-green-400' :
modeConfig.color === 'blue' ? 'bg-blue-500/20 text-blue-400' :
modeConfig.color === 'amber' ? 'bg-amber-500/20 text-amber-400' :
'bg-neutral-700 text-neutral-400'
}`}
>
<option value="active" className="bg-neutral-800 text-white">Active</option>
<option value="standby" className="bg-neutral-800 text-white">Standby</option>
<option value="maintenance" className="bg-neutral-800 text-white">Maintenance</option>
<option value="decommissioned" className="bg-neutral-800 text-white">Decommissioned</option>
</select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-500">Current Config</span>
<span className="text-sm text-tbc-orange font-mono">
{machine.current_version || 'None'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-500">Last Seen</span>
<span className="text-sm text-neutral-400 flex items-center gap-1">
<Clock size={12} />
{machine.last_seen_formatted || 'Never'}
</span>
</div>
</div>
{/* Actions */}
<div className="px-6 py-4 border-t border-neutral-800 flex gap-2">
<button
onClick={() => {
setSelectedConfig('');
setDeployModal({ machine });
}}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-tbc-orange
hover:bg-orange-600 text-white rounded-lg transition-colors text-sm font-medium"
>
<Rocket size={16} />
Deploy Config
</button>
<button
onClick={() => loadHistory(machine)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-neutral-800
hover:bg-neutral-700 text-neutral-300 rounded-lg transition-colors text-sm"
title="View deployment history"
>
<History size={16} />
</button>
</div>
</div>
);
})}
</div>
{machines.length === 0 && (
<div className="text-center py-12 text-neutral-500">
No machines found.
</div>
)}
{/* Deploy Modal */}
{deployModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-neutral-900 rounded-xl border border-neutral-800 w-full max-w-md">
<div className="p-6 border-b border-neutral-800 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Deploy Configuration</h2>
<p className="text-neutral-500 text-sm mt-1">to {deployModal.machine.id}</p>
</div>
<button onClick={() => setDeployModal(null)} className="text-neutral-400 hover:text-white">
<X size={24} />
</button>
</div>
<div className="p-6">
<div className="bg-neutral-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center gap-3">
<HardDrive className="text-tbc-orange" size={24} />
<div>
<p className="font-semibold text-white">{deployModal.machine.name}</p>
<p className="text-sm text-neutral-400">
Current: {deployModal.machine.current_version || 'No config'}
</p>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2">
Select Configuration
</label>
<select
value={selectedConfig}
onChange={(e) => setSelectedConfig(e.target.value)}
className="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg
text-white focus:outline-none focus:border-tbc-orange"
>
<option value="">Choose a configuration...</option>
{compatibleConfigs.map((config) => (
<option key={config.id} value={config.id}>
v{config.version} - {config.name}
</option>
))}
</select>
{compatibleConfigs.length === 0 && (
<p className="mt-2 text-amber-500 text-sm">
No approved configurations available for {deployModal.machine.machine_type}
</p>
)}
</div>
</div>
<div className="p-6 border-t border-neutral-800 flex justify-end gap-3">
<button
onClick={() => setDeployModal(null)}
className="px-4 py-2 text-neutral-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleDeploy}
disabled={!selectedConfig || deploying}
className="flex items-center gap-2 px-6 py-2 bg-tbc-orange hover:bg-orange-600
text-white rounded-lg transition-colors disabled:opacity-50"
>
{deploying ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Deploying...
</>
) : (
<>
<Rocket size={16} />
Deploy
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Mode Change Modal */}
{modeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-neutral-900 rounded-xl border border-neutral-800 w-full max-w-md">
<div className="p-6 border-b border-neutral-800 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Change Operational Mode</h2>
<p className="text-neutral-500 text-sm mt-1">{modeModal.id} - {modeModal.name}</p>
</div>
<button onClick={() => setModeModal(null)} className="text-neutral-400 hover:text-white">
<X size={24} />
</button>
</div>
<div className="p-6 space-y-3">
{OPERATIONAL_MODES.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.value}
onClick={() => setNewMode(mode.value)}
className={`w-full p-4 rounded-lg border text-left transition-colors ${
newMode === mode.value
? 'border-tbc-orange bg-tbc-orange/10'
: 'border-neutral-700 hover:border-neutral-600'
}`}
>
<div className="flex items-center gap-3">
<Icon size={20} className={
mode.color === 'green' ? 'text-green-400' :
mode.color === 'blue' ? 'text-blue-400' :
mode.color === 'amber' ? 'text-amber-400' :
'text-neutral-400'
} />
<div>
<p className="font-medium text-white">{mode.label}</p>
<p className="text-sm text-neutral-500">{mode.description}</p>
</div>
</div>
</button>
);
})}
</div>
<div className="p-6 border-t border-neutral-800 flex justify-end gap-3">
<button
onClick={() => setModeModal(null)}
className="px-4 py-2 text-neutral-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleModeChange}
disabled={changingMode || newMode === modeModal.operational_mode}
className="px-6 py-2 bg-tbc-orange hover:bg-orange-600 text-white rounded-lg
transition-colors disabled:opacity-50"
>
{changingMode ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
{/* History Modal */}
{historyModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-neutral-900 rounded-xl border border-neutral-800 w-full max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-neutral-800 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white">Deployment History</h2>
<p className="text-neutral-500 text-sm mt-1">{historyModal.id} - {historyModal.name}</p>
</div>
<button onClick={() => setHistoryModal(null)} className="text-neutral-400 hover:text-white">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{historyLoading ? (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-2 border-tbc-orange border-t-transparent rounded-full animate-spin" />
</div>
) : historyData ? (
<>
{/* Stats */}
<div className="p-6 border-b border-neutral-800 grid grid-cols-3 gap-4">
<div className="bg-neutral-800/50 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-white">{historyData.total_deployments}</p>
<p className="text-sm text-neutral-500">Total Deployments</p>
</div>
<div className="bg-green-500/10 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-green-400">{historyData.successful_deployments}</p>
<p className="text-sm text-green-500/70">Successful</p>
</div>
<div className="bg-red-500/10 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-red-400">{historyData.failed_deployments}</p>
<p className="text-sm text-red-500/70">Failed</p>
</div>
</div>
{/* Timeline */}
<div className="p-6">
{historyData.deployments.length === 0 ? (
<div className="text-center py-8 text-neutral-500">
No deployment history found
</div>
) : (
<div className="space-y-4">
{historyData.deployments.map((deployment, idx) => (
<div
key={deployment.id}
className={`relative pl-8 pb-4 ${
idx !== historyData.deployments.length - 1 ? 'border-l-2 border-neutral-700' : ''
}`}
>
{/* Timeline dot */}
<div className={`absolute left-0 top-0 -translate-x-1/2 w-4 h-4 rounded-full flex items-center justify-center
${deployment.status === 'success' ? 'bg-green-500' :
deployment.status === 'failed' ? 'bg-red-500' : 'bg-amber-500'}`}
>
{deployment.status === 'success' && <CheckCircle size={10} className="text-white" />}
{deployment.status === 'failed' && <XCircle size={10} className="text-white" />}
{deployment.status === 'pending' && <AlertCircle size={10} className="text-white" />}
</div>
{/* Content */}
<div className="bg-neutral-800/50 rounded-lg p-4 ml-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-tbc-orange font-mono font-medium">v{deployment.config_version}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
deployment.status === 'success' ? 'bg-green-500/20 text-green-400' :
deployment.status === 'failed' ? 'bg-red-500/20 text-red-400' :
'bg-amber-500/20 text-amber-400'
}`}>
{deployment.status}
</span>
</div>
<span className="text-xs text-neutral-500">
{formatDateTime(deployment.initiated_at)}
</span>
</div>
<p className="text-sm text-neutral-300">{deployment.config_name}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-neutral-500">
<span>By {deployment.initiated_by}</span>
{deployment.completed_at && (
<span>Completed: {formatDateTime(deployment.completed_at)}</span>
)}
</div>
{deployment.error && (
<div className="mt-2 text-sm text-red-400 bg-red-500/10 px-3 py-2 rounded">
Error: {deployment.error}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</>
) : null}
</div>
<div className="p-6 border-t border-neutral-800 flex justify-end">
<button
onClick={() => setHistoryModal(null)}
className="px-4 py-2 text-neutral-400 hover:text-white transition-colors"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

70
frontend/src/store.ts Normal file
View File

@@ -0,0 +1,70 @@
import { create } from 'zustand';
import type { User } from './types';
import { authApi } from './api';
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: authApi.getStoredUser(),
isLoading: false,
error: null,
login: async (username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authApi.login(username, password);
set({ user: result.user, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
logout: async () => {
await authApi.logout();
set({ user: null });
},
checkAuth: () => {
const user = authApi.getStoredUser();
set({ user });
},
}));
// Toast notifications store
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
interface ToastState {
toasts: Toast[];
addToast: (message: string, type: Toast['type']) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type) => {
const id = Math.random().toString(36).slice(2);
set((state) => ({ toasts: [...state.toasts, { id, message, type }] }));
// Auto remove after 4 seconds
setTimeout(() => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
}, 4000);
},
removeToast: (id) => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
},
}));

82
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,82 @@
// TypeScript types for TBM Config Manager
export interface User {
username: string;
name: string;
role: string;
}
export interface Machine {
id: string;
name: string;
machine_type: string;
status: 'online' | 'offline'; // Computed from last_seen
operational_mode: 'active' | 'standby' | 'maintenance' | 'decommissioned';
current_version: string | null;
last_seen: string | null;
last_seen_formatted?: string; // Human-readable "5s ago", "2h ago", etc.
}
export interface ConfigParams {
excavation: {
thrust_kn: number;
cutterhead_rpm: number;
penetration_mm_rev: number;
advance_rate_mm_min: number;
};
pressure: {
face_pressure_bar: number;
min_pressure_bar: number;
max_pressure_bar: number;
};
safety: {
max_thrust_kn: number;
torque_limit_pct: number;
emergency_stop_enabled: boolean;
};
navigation: {
horizontal_tolerance_mm: number;
vertical_tolerance_mm: number;
steering_mode: string;
};
}
export interface Configuration {
id: string;
version: string;
name: string;
machine_type: string;
status: 'draft' | 'approved' | 'archived';
config: ConfigParams;
created_by: string;
created_at: string;
}
export interface Deployment {
id: string;
config_id: string;
config_version: string;
machine_id: string;
machine_name?: string;
status: 'pending' | 'success' | 'failed';
initiated_by: string;
initiated_at: string;
completed_at: string | null;
error: string | null;
}
export interface AuditLog {
id: string;
timestamp: string;
user: string;
action: string;
resource: string;
details: Record<string, unknown>;
}
export interface Stats {
machines: { total: number; online: number };
configs: { total: number; approved: number };
deployments: { total: number; success: number; pending: number; failed: number };
recent_deployments: Deployment[];
}

View File

@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// The Boring Company inspired palette
'tbc': {
'black': '#0A0A0A',
'white': '#FAFAFA',
'orange': '#FF5722',
'orange-dark': '#E64A19',
},
},
fontFamily: {
'mono': ['JetBrains Mono', 'Fira Code', 'monospace'],
'sans': ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000'
}
}
})