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

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Environment variables
.env
backend/.env
# Database
*.db
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
ENV/
env/
.venv/
# Node
node_modules/
dist/
.cache/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log

409
README.md Normal file
View File

@@ -0,0 +1,409 @@
# TBM Configuration Management System
A prototype for managing Tunnel Boring Machine configurations at remote locations. This system handles configuration versioning, approval workflows, and deployment tracking for TBM fleets.
![Status](https://img.shields.io/badge/status-prototype-orange)
![Python](https://img.shields.io/badge/python-3.10+-blue)
![React](https://img.shields.io/badge/react-18-blue)
---
## Table of Contents
- [Features](#features)
- [Screenshots](#screenshots)
- [Quick Start](#quick-start)
- [Usage](#usage)
- [Architecture](#architecture)
- [API Reference](#api-reference)
- [Configuration Format](#configuration-format)
- [Troubleshooting](#troubleshooting)
- [Known Limitations](#known-limitations)
- [TODO](#todo)
---
## Features
### Configuration Management
- Create, edit, and version configurations
- Approval workflow (Draft -> Approved -> Archived)
- Clone existing configs as starting point
- Compare any two configs side-by-side
- Export as JSON or YAML
### Machine Fleet Management
- Real-time online/offline status
- Operational modes: Active, Standby, Maintenance, Decommissioned
- Current configuration version per machine
- Deployment history per machine
### Deployment System
- Deploy approved configs to compatible machines
- Real-time deployment status tracking
- Success/failure reporting from machines
### Audit and Compliance
- Every action logged with timestamp, user, and details
- Searchable audit log
- Deployment history preserved
---
## Screenshots
### Dashboard
Fleet overview with real-time machine status, deployment metrics, and recent activity.
![Dashboard](docs/images/TBM_Config_Dashboard.png)
### Configuration Deployment
Deploy approved configurations to machines with status tracking.
![Deploy Configuration](docs/images/TBM_Config_Deploy.png)
### Audit Log
Complete audit trail of all system actions for compliance.
![Audit Log](docs/images/TBM_Config_Audit.png)
---
## Quick Start
### Prerequisites
- Python 3.10+
- Node.js 18+
- npm or yarn
### Installation
```bash
# Clone the repository
cd tbm-config
# Backend setup
cd backend
pip install -r requirements.txt
cp .env.example .env # Configure credentials
python seed_data.py # Creates database with sample data
# Frontend setup (new terminal)
cd frontend
npm install
```
### Running
```bash
# Terminal 1: Backend API
cd backend
uvicorn main:app --reload
# -> http://localhost:8000
# Terminal 2: Frontend UI
cd frontend
npm run dev
# -> http://localhost:5173
# Terminal 3+: Machine Simulators (optional)
cd simulator
python machine.py PRF4-001
python machine.py PRF4-002 # In another terminal
```
### Test Credentials
Demo credentials are configured in the `.env` file. A credential helper is available in the Dashboard login screen for testing purposes until production deployment.
---
## Usage
### Dashboard
The main dashboard shows:
- Fleet Overview - All machines with status and current config
- Summary Stats - Online count, pending deployments, weekly activity
- Alerts Banner - Active machines that are offline
- Recent Activity - Latest actions from audit log
### Managing Configurations
1. **Create**: Click "New Configuration", fill in parameters, save as draft
2. **Review**: Expand a draft config to see all parameters
3. **Approve**: Click Approve to make it deployable
4. **Reject**: Delete drafts you don't want
5. **Archive**: Retire approved configs (preserves history)
6. **Clone**: Copy an existing config as starting point
7. **Compare**: See differences between two configs
8. **Export**: Download as JSON or YAML
### Deploying to Machines
1. Go to Machines page
2. Click Deploy Config on target machine
3. Select an approved configuration
4. Click Deploy
5. Watch for success notification (machine must be online)
### Machine Modes
| Mode | Meaning | Alerts if Offline? |
|------|---------|-------------------|
| Active | Should be running | Yes |
| Standby | Intentionally idle | No |
| Maintenance | Under repair | No |
| Decommissioned | Retired | No |
---
## Architecture
```
+------------------+ +------------------+ +------------------+
| Frontend |---->| Backend |---->| Database |
| React + Vite | | FastAPI | | SQLite |
| localhost:5173 | | localhost:8000 | | tbm_config.db |
+------------------+ +------------------+ +------------------+
^
|
+-----+-----+
| Simulator |
| Python |
| (per TBM) |
+-----------+
```
### Data Flow: Deploying a Config
```
1. User clicks Deploy -> Frontend POST /api/deployments
2. Backend creates deployment (pending), adds to pending_configs
3. Simulator polls -> Backend returns pending config
4. Simulator applies config -> Sends ACK to backend
5. Backend updates deployment (success), updates machine version
6. Frontend polls -> Shows updated status
```
### Project Structure
```
tbm-config/
├── backend/
│ ├── main.py # FastAPI application
│ ├── seed_data.py # Database seeder
│ ├── requirements.txt # Python dependencies
│ ├── .env.example # Environment template
│ └── .env # Local credentials (git-ignored)
├── frontend/
│ ├── src/
│ │ ├── pages/ # React page components
│ │ ├── components/ # Shared components
│ │ ├── api.ts # API client
│ │ ├── types.ts # TypeScript interfaces
│ │ └── store.ts # Zustand state management
│ ├── package.json
│ └── vite.config.ts
├── simulator/
│ └── machine.py # Machine simulator
└── docs/
└── CONFIGURATION_EXAMPLES.md
```
### Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | FastAPI, SQLite, Python 3.10+ |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS |
| State | Zustand |
| Simulator | Python asyncio, httpx |
### Database Schema
```sql
machines (id, name, machine_type, operational_mode, current_version, last_seen)
configurations (id, version, name, machine_type, status, config, created_by, created_at)
deployments (id, config_id, config_version, machine_id, status, initiated_by, initiated_at, completed_at, error)
pending_configs (machine_id, config_id, deployment_id)
audit_logs (id, timestamp, user, action, resource, details)
```
---
## API Reference
### Authentication
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/auth/login` | Get auth token |
| GET | `/api/auth/me` | Current user info |
### Machines
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/machines` | List all machines |
| GET | `/api/machines/{id}` | Get single machine |
| PUT | `/api/machines/{id}` | Update operational mode |
| GET | `/api/machines/{id}/history` | Deployment history |
### Configurations
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/configs` | List (filter by status, type) |
| POST | `/api/configs` | Create draft |
| PUT | `/api/configs/{id}` | Update draft |
| PUT | `/api/configs/{id}/approve` | Approve draft |
| PUT | `/api/configs/{id}/reject` | Reject (delete) draft |
| PUT | `/api/configs/{id}/archive` | Archive approved |
| PUT | `/api/configs/{id}/unarchive` | Restore archived |
| POST | `/api/configs/{id}/clone` | Clone to new draft |
| GET | `/api/configs/{id}/export` | Export JSON/YAML |
| GET | `/api/configs/diff/{a}/{b}` | Compare two configs |
### Deployments
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/deployments` | List all |
| POST | `/api/deployments` | Create (initiate) |
### Dashboard
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/dashboard` | Fleet overview + metrics |
### Machine API (Simulator)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/machine-api/poll` | Machine checks for pending config |
| POST | `/api/machine-api/ack` | Machine reports deployment result |
---
## Configuration Format
See `docs/CONFIGURATION_EXAMPLES.md` for detailed examples.
Basic structure:
```json
{
"excavation": {
"thrust_kn": 18500,
"cutterhead_rpm": 2.4,
"penetration_mm_rev": 8,
"advance_rate_mm_min": 45
},
"pressure": {
"face_pressure_bar": 2.8,
"min_pressure_bar": 2.4,
"max_pressure_bar": 3.4
},
"safety": {
"max_thrust_kn": 26000,
"torque_limit_pct": 85,
"emergency_stop_enabled": true
},
"navigation": {
"horizontal_tolerance_mm": 50,
"vertical_tolerance_mm": 40,
"steering_mode": "semi_auto"
}
}
```
Note: Configuration parameters are placeholders. Actual machine parameters will be defined during production integration.
---
## Troubleshooting
### "Database is locked" error
Multiple simultaneous write operations to SQLite.
```bash
# Stop all processes, delete database, restart
del tbm_config.db # Windows
rm tbm_config.db # Mac/Linux
python seed_data.py
uvicorn main:app --reload
```
### Machine stuck on "offline"
Is the simulator running for that machine?
```bash
python machine.py PRF4-001
```
Machines go offline 30 seconds after simulator stops.
### Deployment stuck on "pending"
Backend terminal should show `[ACK]` messages when simulator reports success.
Debug:
```bash
sqlite3 tbm_config.db "SELECT status FROM deployments ORDER BY initiated_at DESC LIMIT 1;"
```
### Frontend not updating
Check browser console for errors. Network tab should show `/api/machines` and `/api/dashboard` requests every 5 seconds.
---
## Known Limitations
1. No persistent sessions - Restart server = re-login required
2. No token expiry - Tokens valid until server restart
3. No audit log cleanup - Grows indefinitely
4. No database backup system
5. No HTTPS - Development only
6. No user management UI - Users configured via environment variables
---
## TODO
### High Priority - Security
- [ ] Implement password hashing (bcrypt/Argon2)
- [ ] Add JWT tokens with expiration
- [ ] Authenticate machine API endpoints (certificates or API keys)
- [ ] Implement role-based access control (RBAC) - roles exist but not enforced
- [ ] Enable HTTPS/TLS for production
### Medium Priority - Security
- [ ] Encrypt database at rest
- [ ] Move token storage from localStorage (XSS vulnerable) to httpOnly cookies
- [ ] Add input validation/sanitization on all endpoints
- [ ] Implement rate limiting
- [ ] Restrict CORS methods/headers (currently wildcards)
- [ ] Add audit log retention policy
### Features
- [ ] Switch from polling to WebSocket for real-time updates
- [ ] Config import (upload JSON/YAML)
- [ ] User management UI
- [ ] Database migrations system
- [ ] Docker containerization
- [ ] Production deployment guide
### Technical Debt
- [ ] Modularize backend (currently single file)
- [ ] Remove technical internals from documentation for production
- [ ] Database backup strategy
- [ ] Comprehensive error handling
---
## Author
Zack

21
backend/.env.example Normal file
View File

@@ -0,0 +1,21 @@
# TBM Config Manager - Environment Variables
# Copy this file to .env and update values for your environment
# Demo credentials - CHANGE THESE FOR PRODUCTION
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
ADMIN_NAME=Admin User
ENGINEER_USERNAME=engineer
ENGINEER_PASSWORD=changeme
ENGINEER_NAME=Engineer User
OPERATOR_USERNAME=operator
OPERATOR_PASSWORD=changeme
OPERATOR_NAME=Operator User
# Database path (relative to backend directory)
DATABASE_PATH=tbm_config.db
# Online threshold in seconds
ONLINE_THRESHOLD_SECONDS=30

1157
backend/main.py Normal file

File diff suppressed because it is too large Load Diff

4
backend/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
python-dotenv==1.0.0

343
backend/seed_data.py Normal file
View File

@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""
Seed the database with mock data for MVP testing.
Usage:
python seed_data.py # Reset database and seed with mock data
python seed_data.py --keep # Keep existing data, only add if empty
"""
import sqlite3
import json
import sys
import os
from uuid import uuid4
from datetime import datetime, timedelta
DB_PATH = "tbm_config.db"
def create_tables(cursor):
"""Create all tables with current schema"""
cursor.executescript("""
DROP TABLE IF EXISTS pending_configs;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS deployments;
DROP TABLE IF EXISTS configurations;
DROP TABLE IF EXISTS machines;
CREATE TABLE machines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
machine_type TEXT NOT NULL,
operational_mode TEXT DEFAULT 'active',
current_version TEXT,
last_seen TEXT
);
CREATE TABLE configurations (
id TEXT PRIMARY KEY,
version TEXT NOT NULL,
name TEXT NOT NULL,
machine_type TEXT NOT NULL,
status TEXT DEFAULT 'draft',
config TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE deployments (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
config_version TEXT NOT NULL,
machine_id TEXT NOT NULL,
status TEXT DEFAULT 'pending',
initiated_by TEXT NOT NULL,
initiated_at TEXT NOT NULL,
completed_at TEXT,
error TEXT,
FOREIGN KEY (config_id) REFERENCES configurations(id),
FOREIGN KEY (machine_id) REFERENCES machines(id)
);
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
user TEXT NOT NULL,
action TEXT NOT NULL,
resource TEXT NOT NULL,
details TEXT NOT NULL
);
CREATE TABLE pending_configs (
machine_id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
deployment_id TEXT NOT NULL,
FOREIGN KEY (machine_id) REFERENCES machines(id),
FOREIGN KEY (config_id) REFERENCES configurations(id)
);
""")
print("✓ Database schema created")
def seed():
# Delete existing database file to ensure clean schema
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
print(f"✓ Removed old database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Create tables with current schema
create_tables(cursor)
conn.commit()
now = datetime.utcnow()
# ========================================================================
# MACHINES
# ========================================================================
# operational_mode: active (should be online), standby (intentionally idle),
# maintenance (under repair), decommissioned (not in use)
machines = [
# (id, name, machine_type, operational_mode, current_version, last_seen)
("PRF4-001", "Prufrock 4 - Alpha", "PRUFROCK_4", "active", "2.4.1", (now - timedelta(minutes=2)).isoformat()),
("PRF4-002", "Prufrock 4 - Beta", "PRUFROCK_4", "active", "2.4.0", (now - timedelta(minutes=5)).isoformat()),
("PRF4-003", "Prufrock 4 - Gamma", "PRUFROCK_4", "maintenance", "2.4.1", (now - timedelta(hours=12)).isoformat()),
("PRF5-001", "Prufrock 5 - Prototype", "PRUFROCK_5", "standby", "3.0.0", (now - timedelta(hours=48)).isoformat()),
("PRF5-002", "Prufrock 5 - Test Unit", "PRUFROCK_5", "decommissioned", "3.0.0", (now - timedelta(days=7)).isoformat()),
]
for m in machines:
cursor.execute(
"INSERT INTO machines (id, name, machine_type, operational_mode, current_version, last_seen) VALUES (?, ?, ?, ?, ?, ?)",
m
)
print(f"✓ Created {len(machines)} machines")
# ========================================================================
# CONFIGURATIONS
# ========================================================================
# Default config template
default_config = {
"excavation": {
"thrust_kn": 18500,
"cutterhead_rpm": 2.4,
"penetration_mm_rev": 8,
"advance_rate_mm_min": 45
},
"pressure": {
"face_pressure_bar": 2.8,
"min_pressure_bar": 2.4,
"max_pressure_bar": 3.4
},
"safety": {
"max_thrust_kn": 26000,
"torque_limit_pct": 85,
"emergency_stop_enabled": True
},
"navigation": {
"horizontal_tolerance_mm": 50,
"vertical_tolerance_mm": 40,
"steering_mode": "semi_auto"
}
}
# High performance config
high_perf_config = {
"excavation": {
"thrust_kn": 22000,
"cutterhead_rpm": 2.8,
"penetration_mm_rev": 12,
"advance_rate_mm_min": 65
},
"pressure": {
"face_pressure_bar": 3.2,
"min_pressure_bar": 2.8,
"max_pressure_bar": 3.8
},
"safety": {
"max_thrust_kn": 26000,
"torque_limit_pct": 90,
"emergency_stop_enabled": True
},
"navigation": {
"horizontal_tolerance_mm": 40,
"vertical_tolerance_mm": 30,
"steering_mode": "full_auto"
}
}
# Soft soil config
soft_soil_config = {
"excavation": {
"thrust_kn": 15000,
"cutterhead_rpm": 2.0,
"penetration_mm_rev": 6,
"advance_rate_mm_min": 35
},
"pressure": {
"face_pressure_bar": 2.4,
"min_pressure_bar": 2.0,
"max_pressure_bar": 3.0
},
"safety": {
"max_thrust_kn": 26000,
"torque_limit_pct": 75,
"emergency_stop_enabled": True
},
"navigation": {
"horizontal_tolerance_mm": 60,
"vertical_tolerance_mm": 50,
"steering_mode": "semi_auto"
}
}
configs = [
(str(uuid4()), "2.4.0", "Vegas Loop - Standard", "PRUFROCK_4", "approved", json.dumps(default_config), "engineer", (now - timedelta(days=30)).isoformat()),
(str(uuid4()), "2.4.1", "Vegas Loop - Optimized", "PRUFROCK_4", "approved", json.dumps(high_perf_config), "engineer", (now - timedelta(days=14)).isoformat()),
(str(uuid4()), "2.4.2", "Vegas Loop - Soft Soil Zone", "PRUFROCK_4", "approved", json.dumps(soft_soil_config), "admin", (now - timedelta(days=7)).isoformat()),
(str(uuid4()), "2.5.0", "Vegas Loop - Phase 2 Draft", "PRUFROCK_4", "draft", json.dumps(high_perf_config), "engineer", (now - timedelta(days=1)).isoformat()),
(str(uuid4()), "3.0.0", "Prufrock 5 - Initial", "PRUFROCK_5", "approved", json.dumps(default_config), "admin", (now - timedelta(days=60)).isoformat()),
]
for c in configs:
cursor.execute(
"INSERT INTO configurations (id, version, name, machine_type, status, config, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
c
)
print(f"✓ Created {len(configs)} configurations")
# ========================================================================
# DEPLOYMENTS (historical)
# ========================================================================
# Get config IDs
cursor.execute("SELECT id, version, machine_type FROM configurations")
config_map = {(row[1], row[2]): row[0] for row in cursor.fetchall()}
deployments = [
# PRF4-001 deployments
(str(uuid4()), config_map[("2.4.0", "PRUFROCK_4")], "2.4.0", "PRF4-001", "success", "engineer",
(now - timedelta(days=25)).isoformat(), (now - timedelta(days=25, minutes=-1)).isoformat(), None),
(str(uuid4()), config_map[("2.4.1", "PRUFROCK_4")], "2.4.1", "PRF4-001", "success", "admin",
(now - timedelta(days=10)).isoformat(), (now - timedelta(days=10, minutes=-1)).isoformat(), None),
# PRF4-002 deployments
(str(uuid4()), config_map[("2.4.0", "PRUFROCK_4")], "2.4.0", "PRF4-002", "success", "engineer",
(now - timedelta(days=28)).isoformat(), (now - timedelta(days=28, minutes=-1)).isoformat(), None),
# Failed deployment example
(str(uuid4()), config_map[("2.4.2", "PRUFROCK_4")], "2.4.2", "PRF4-002", "failed", "operator",
(now - timedelta(days=3)).isoformat(), (now - timedelta(days=3, minutes=-1)).isoformat(), "PLC communication timeout"),
# PRF5-001 deployment
(str(uuid4()), config_map[("3.0.0", "PRUFROCK_5")], "3.0.0", "PRF5-001", "success", "admin",
(now - timedelta(days=55)).isoformat(), (now - timedelta(days=55, minutes=-1)).isoformat(), None),
]
for d in deployments:
cursor.execute(
"""INSERT INTO deployments (id, config_id, config_version, machine_id, status, initiated_by, initiated_at, completed_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
d
)
print(f"✓ Created {len(deployments)} deployments")
# ========================================================================
# AUDIT LOGS
# ========================================================================
audit_logs = [
(str(uuid4()), (now - timedelta(days=30)).isoformat(), "engineer", "config.created", "config:2.4.0", json.dumps({"name": "Vegas Loop - Standard"})),
(str(uuid4()), (now - timedelta(days=30)).isoformat(), "engineer", "config.approved", "config:2.4.0", json.dumps({})),
(str(uuid4()), (now - timedelta(days=25)).isoformat(), "engineer", "deployment.initiated", "deployment:PRF4-001", json.dumps({"version": "2.4.0"})),
(str(uuid4()), (now - timedelta(days=25)).isoformat(), "PRF4-001", "deployment.success", "deployment:PRF4-001", json.dumps({"version": "2.4.0"})),
(str(uuid4()), (now - timedelta(days=14)).isoformat(), "engineer", "config.created", "config:2.4.1", json.dumps({"name": "Vegas Loop - Optimized"})),
(str(uuid4()), (now - timedelta(days=14)).isoformat(), "admin", "config.approved", "config:2.4.1", json.dumps({})),
(str(uuid4()), (now - timedelta(days=10)).isoformat(), "admin", "deployment.initiated", "deployment:PRF4-001", json.dumps({"version": "2.4.1"})),
(str(uuid4()), (now - timedelta(days=10)).isoformat(), "PRF4-001", "deployment.success", "deployment:PRF4-001", json.dumps({"version": "2.4.1"})),
(str(uuid4()), (now - timedelta(days=7)).isoformat(), "admin", "config.created", "config:2.4.2", json.dumps({"name": "Vegas Loop - Soft Soil Zone"})),
(str(uuid4()), (now - timedelta(days=7)).isoformat(), "admin", "config.approved", "config:2.4.2", json.dumps({})),
(str(uuid4()), (now - timedelta(days=3)).isoformat(), "operator", "deployment.initiated", "deployment:PRF4-002", json.dumps({"version": "2.4.2"})),
(str(uuid4()), (now - timedelta(days=3)).isoformat(), "PRF4-002", "deployment.failed", "deployment:PRF4-002", json.dumps({"error": "PLC communication timeout"})),
(str(uuid4()), (now - timedelta(days=1)).isoformat(), "engineer", "config.created", "config:2.5.0", json.dumps({"name": "Vegas Loop - Phase 2 Draft"})),
(str(uuid4()), (now - timedelta(hours=2)).isoformat(), "admin", "auth.login", "user:admin", json.dumps({})),
]
for a in audit_logs:
cursor.execute(
"INSERT INTO audit_logs (id, timestamp, user, action, resource, details) VALUES (?, ?, ?, ?, ?, ?)",
a
)
print(f"✓ Created {len(audit_logs)} audit log entries")
conn.commit()
conn.close()
print("\n✅ Database seeded successfully!")
print("\nTest credentials:")
print(" admin / admin123 (full access)")
print(" engineer / eng123 (engineer)")
print(" operator / op123 (operator)")
if __name__ == "__main__":
# Initialize tables first
conn = sqlite3.connect(DB_PATH)
conn.executescript("""
CREATE TABLE IF NOT EXISTS machines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
machine_type TEXT NOT NULL,
status TEXT DEFAULT 'offline',
current_version TEXT,
last_seen TEXT
);
CREATE TABLE IF NOT EXISTS configurations (
id TEXT PRIMARY KEY,
version TEXT NOT NULL,
name TEXT NOT NULL,
machine_type TEXT NOT NULL,
status TEXT DEFAULT 'draft',
config TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS deployments (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
config_version TEXT NOT NULL,
machine_id TEXT NOT NULL,
status TEXT DEFAULT 'pending',
initiated_by TEXT NOT NULL,
initiated_at TEXT NOT NULL,
completed_at TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
user TEXT NOT NULL,
action TEXT NOT NULL,
resource TEXT NOT NULL,
details TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pending_configs (
machine_id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
deployment_id TEXT NOT NULL
);
""")
conn.commit()
conn.close()
seed()

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

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'
}
}
})

370
simulator/machine.py Normal file
View File

@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""
TBM Individual Machine Simulator
Run one machine per terminal for realistic demos:
python machine.py PRF4-001
python machine.py PRF4-002
python machine.py PRF5-001
Or run with custom settings:
python machine.py PRF4-001 --poll-interval 5 --server http://localhost:8000
"""
import asyncio
import argparse
import httpx
import json
import random
from datetime import datetime
from dataclasses import dataclass, field
from typing import Optional
import sys
API_BASE = "http://localhost:8000/api/machine-api"
# ANSI colors for terminal output
class Colors:
HEADER = '\033[95m'
BLUE = '\033[94m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
DIM = '\033[2m'
RESET = '\033[0m'
# Machine registry - add new machines here
MACHINE_REGISTRY = {
"PRF4-001": {"name": "Prufrock 4 - Alpha", "type": "PRUFROCK_4", "default_version": "2.4.1"},
"PRF4-002": {"name": "Prufrock 4 - Beta", "type": "PRUFROCK_4", "default_version": "2.4.0"},
"PRF4-003": {"name": "Prufrock 4 - Gamma", "type": "PRUFROCK_4", "default_version": "2.4.0"},
"PRF5-001": {"name": "Prufrock 5 - Prototype", "type": "PRUFROCK_5", "default_version": "3.0.0"},
"PRF5-002": {"name": "Prufrock 5 - Test Unit", "type": "PRUFROCK_5", "default_version": "3.0.0"},
}
@dataclass
class MachineState:
"""Simulated machine state"""
machine_id: str
name: str
machine_type: str
current_version: Optional[str] = None
config: Optional[dict] = None
status: str = "idle" # idle, drilling, maintenance
uptime_seconds: int = 0
configs_applied: int = 0
last_config_at: Optional[str] = None
# Simulated operational metrics
thrust_kn: float = 0.0
rpm: float = 0.0
advance_rate: float = 0.0
face_pressure: float = 0.0
def log(machine_id: str, level: str, message: str):
"""Pretty print log messages"""
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
"INFO": Colors.CYAN,
"CONFIG": Colors.GREEN,
"DEPLOY": Colors.YELLOW,
"ERROR": Colors.RED,
"STATUS": Colors.BLUE,
}
color = level_colors.get(level, Colors.RESET)
print(f"{Colors.DIM}[{timestamp}]{Colors.RESET} {Colors.BOLD}[{machine_id}]{Colors.RESET} {color}[{level}]{Colors.RESET} {message}")
def print_banner(machine: MachineState):
"""Print startup banner"""
print(f"""
{Colors.BOLD}╔══════════════════════════════════════════════════════════════╗
║ TBM MACHINE SIMULATOR ║
╠══════════════════════════════════════════════════════════════╣
║ Machine ID: {machine.machine_id:<45} ║
║ Name: {machine.name:<45} ║
║ Type: {machine.machine_type:<45} ║
║ Config: v{machine.current_version or 'None':<44} ║
╚══════════════════════════════════════════════════════════════╝{Colors.RESET}
""")
def print_config_details(config: dict):
"""Pretty print configuration parameters"""
print(f"\n{Colors.CYAN} ┌─ Configuration Parameters ─────────────────────────┐{Colors.RESET}")
if "excavation" in config:
exc = config["excavation"]
print(f" │ {Colors.BOLD}Excavation:{Colors.RESET}")
print(f" │ Thrust: {exc.get('thrust_kn', 'N/A')} kN")
print(f" │ RPM: {exc.get('cutterhead_rpm', 'N/A')}")
print(f" │ Advance Rate: {exc.get('advance_rate_mm_min', 'N/A')} mm/min")
if "pressure" in config:
prs = config["pressure"]
print(f" │ {Colors.BOLD}Pressure:{Colors.RESET}")
print(f" │ Face: {prs.get('face_pressure_bar', 'N/A')} bar")
print(f" │ Range: {prs.get('min_pressure_bar', 'N/A')} - {prs.get('max_pressure_bar', 'N/A')} bar")
if "safety" in config:
saf = config["safety"]
print(f" │ {Colors.BOLD}Safety:{Colors.RESET}")
print(f" │ Max Thrust: {saf.get('max_thrust_kn', 'N/A')} kN")
print(f" │ E-Stop: {'Enabled' if saf.get('emergency_stop_enabled') else 'Disabled'}")
if "navigation" in config:
nav = config["navigation"]
print(f" │ {Colors.BOLD}Navigation:{Colors.RESET}")
print(f" │ Steering: {nav.get('steering_mode', 'N/A')}")
print(f" │ Tolerance: ±{nav.get('horizontal_tolerance_mm', 'N/A')}mm H, ±{nav.get('vertical_tolerance_mm', 'N/A')}mm V")
print(f"{Colors.CYAN} └─────────────────────────────────────────────────────┘{Colors.RESET}\n")
async def apply_config(machine: MachineState, config: dict, version: str) -> tuple[bool, str]:
"""
Simulate applying a configuration to the machine.
Returns (success, error_message)
"""
log(machine.machine_id, "DEPLOY", f"Received config v{version}")
log(machine.machine_id, "DEPLOY", "Starting configuration apply sequence...")
# Step 1: Backup current config
await asyncio.sleep(0.3)
log(machine.machine_id, "DEPLOY", " [1/5] Backing up current configuration...")
# Step 2: Validate schema
await asyncio.sleep(0.2)
log(machine.machine_id, "DEPLOY", " [2/5] Validating configuration schema...")
# Step 3: Validate safety bounds
await asyncio.sleep(0.2)
thrust = config.get("excavation", {}).get("thrust_kn", 0)
max_thrust = config.get("safety", {}).get("max_thrust_kn", 26000)
if thrust > max_thrust:
return False, f"Safety validation failed: thrust {thrust} exceeds max {max_thrust}"
log(machine.machine_id, "DEPLOY", " [3/5] Safety bounds validated ✓")
# Step 4: Apply to PLC (simulated)
await asyncio.sleep(0.5)
# 5% random failure rate for realism
if random.random() < 0.05:
return False, "PLC communication timeout"
log(machine.machine_id, "DEPLOY", " [4/5] Writing to PLC...")
# Step 5: Verify
await asyncio.sleep(0.3)
log(machine.machine_id, "DEPLOY", " [5/5] Verifying configuration...")
# Success - update machine state
machine.current_version = version
machine.config = config
machine.configs_applied += 1
machine.last_config_at = datetime.now().isoformat()
# Update simulated operational params from config
if "excavation" in config:
machine.thrust_kn = config["excavation"].get("thrust_kn", 0)
machine.rpm = config["excavation"].get("cutterhead_rpm", 0)
machine.advance_rate = config["excavation"].get("advance_rate_mm_min", 0)
if "pressure" in config:
machine.face_pressure = config["pressure"].get("face_pressure_bar", 0)
return True, ""
async def poll_server(client: httpx.AsyncClient, machine: MachineState) -> dict:
"""Poll server for new configuration"""
try:
response = await client.post(
f"{API_BASE}/poll",
json={
"machine_id": machine.machine_id,
"current_version": machine.current_version
},
timeout=10.0
)
if response.status_code == 200:
return response.json()
else:
log(machine.machine_id, "ERROR", f"Poll failed: HTTP {response.status_code}")
return {"has_update": False, "error": True}
except httpx.ConnectError:
log(machine.machine_id, "ERROR", "Cannot reach server - is backend running?")
return {"has_update": False, "error": True}
except Exception as e:
log(machine.machine_id, "ERROR", f"Poll error: {e}")
return {"has_update": False, "error": True}
async def send_ack(client: httpx.AsyncClient, machine: MachineState,
deployment_id: str, success: bool, error: str = None):
"""Send acknowledgment back to server"""
try:
log(machine.machine_id, "ACK", f"Sending ACK: deployment={deployment_id[:8]}..., success={success}")
response = await client.post(
f"{API_BASE}/ack",
json={
"machine_id": machine.machine_id,
"deployment_id": deployment_id,
"success": success,
"error": error
},
timeout=10.0
)
if response.status_code == 200:
log(machine.machine_id, "ACK", f"ACK sent successfully")
else:
log(machine.machine_id, "ERROR", f"ACK failed: HTTP {response.status_code} - {response.text}")
except Exception as e:
log(machine.machine_id, "ERROR", f"Failed to send ACK: {e}")
async def run_machine(machine: MachineState, poll_interval: int, server_url: str):
"""Main machine loop"""
global API_BASE
API_BASE = f"{server_url}/api/machine-api"
print_banner(machine)
log(machine.machine_id, "INFO", f"Starting machine simulation")
log(machine.machine_id, "INFO", f"Server: {server_url}")
log(machine.machine_id, "INFO", f"Poll interval: {poll_interval}s")
log(machine.machine_id, "INFO", "Press Ctrl+C to stop\n")
async with httpx.AsyncClient() as client:
poll_count = 0
consecutive_errors = 0
while True:
poll_count += 1
machine.uptime_seconds += poll_interval
# Poll server
log(machine.machine_id, "STATUS", f"Polling server... (#{poll_count})")
data = await poll_server(client, machine)
if data.get("error"):
consecutive_errors += 1
if consecutive_errors >= 3:
log(machine.machine_id, "ERROR", "Multiple failures - check server connection")
await asyncio.sleep(poll_interval)
continue
consecutive_errors = 0
if data.get("has_update"):
# New config available
version = data["version"]
config = data["config"]
deployment_id = data["deployment_id"]
# Skip if we already have this version
if version == machine.current_version:
log(machine.machine_id, "STATUS", f"Already on v{version}, skipping")
await send_ack(client, machine, deployment_id, True)
await asyncio.sleep(poll_interval)
continue
log(machine.machine_id, "CONFIG", f"New configuration available: v{version}")
print_config_details(config)
# Apply the config
success, error = await apply_config(machine, config, version)
if success:
log(machine.machine_id, "CONFIG", f"✅ Configuration v{version} applied successfully!")
await send_ack(client, machine, deployment_id, True)
else:
log(machine.machine_id, "ERROR", f"❌ Configuration failed: {error}")
await send_ack(client, machine, deployment_id, False, error)
else:
log(machine.machine_id, "STATUS", f"Up to date (v{machine.current_version})")
# Wait for next poll
await asyncio.sleep(poll_interval)
def main():
parser = argparse.ArgumentParser(
description="TBM Individual Machine Simulator",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python machine.py PRF4-001 # Run PRF4-001 with defaults
python machine.py PRF4-002 --poll-interval 5 # Poll every 5 seconds
python machine.py PRF5-001 --version 3.1.0 # Start with specific version
python machine.py --list # List available machines
Available machines:
PRF4-001 Prufrock 4 - Alpha
PRF4-002 Prufrock 4 - Beta
PRF4-003 Prufrock 4 - Gamma
PRF5-001 Prufrock 5 - Prototype
PRF5-002 Prufrock 5 - Test Unit
"""
)
parser.add_argument("machine_id", nargs="?", help="Machine ID (e.g., PRF4-001)")
parser.add_argument("--poll-interval", "-p", type=int, default=10,
help="Polling interval in seconds (default: 10)")
parser.add_argument("--server", "-s", default="http://localhost:8000",
help="Server URL (default: http://localhost:8000)")
parser.add_argument("--version", "-v", help="Override starting config version")
parser.add_argument("--list", "-l", action="store_true", help="List available machines")
args = parser.parse_args()
# List machines
if args.list:
print("\nAvailable Machines:")
print("-" * 60)
for mid, info in MACHINE_REGISTRY.items():
print(f" {mid:<10} {info['name']:<30} ({info['type']})")
print()
return
# Require machine_id
if not args.machine_id:
parser.print_help()
print(f"\n{Colors.RED}Error: machine_id is required{Colors.RESET}")
print(f"Example: python machine.py PRF4-001\n")
sys.exit(1)
# Validate machine_id
if args.machine_id not in MACHINE_REGISTRY:
print(f"{Colors.RED}Error: Unknown machine '{args.machine_id}'{Colors.RESET}")
print(f"Use --list to see available machines")
sys.exit(1)
# Create machine state
info = MACHINE_REGISTRY[args.machine_id]
machine = MachineState(
machine_id=args.machine_id,
name=info["name"],
machine_type=info["type"],
current_version=args.version or info["default_version"]
)
# Run
try:
asyncio.run(run_machine(machine, args.poll_interval, args.server))
except KeyboardInterrupt:
print(f"\n\n{Colors.YELLOW}Machine {args.machine_id} shutting down...{Colors.RESET}")
print(f" Uptime: {machine.uptime_seconds}s")
print(f" Configs applied: {machine.configs_applied}")
print(f" Final version: v{machine.current_version}\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
httpx==0.26.0