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:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal 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
409
README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### Configuration Deployment
|
||||
Deploy approved configurations to machines with status tracking.
|
||||
|
||||

|
||||
|
||||
### Audit Log
|
||||
Complete audit trail of all system actions for compliance.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
21
backend/.env.example
Normal 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
1157
backend/main.py
Normal file
File diff suppressed because it is too large
Load Diff
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal 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
343
backend/seed_data.py
Normal 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()
|
||||
1153
docs/CONFIGURATION_EXAMPLES.md
Normal file
1153
docs/CONFIGURATION_EXAMPLES.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/images/TBM_Config_Audit.png
Normal file
BIN
docs/images/TBM_Config_Audit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/images/TBM_Config_Dashboard.png
Normal file
BIN
docs/images/TBM_Config_Dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
BIN
docs/images/TBM_Config_Deploy.png
Normal file
BIN
docs/images/TBM_Config_Deploy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
15
frontend/index.html
Normal file
15
frontend/index.html
Normal 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
2719
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
70
frontend/src/App.tsx
Normal file
70
frontend/src/App.tsx
Normal 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
149
frontend/src/api.ts
Normal 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'),
|
||||
};
|
||||
131
frontend/src/components/Layout.tsx
Normal file
131
frontend/src/components/Layout.tsx
Normal 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
59
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
226
frontend/src/pages/AuditLog.tsx
Normal file
226
frontend/src/pages/AuditLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1057
frontend/src/pages/Configurations.tsx
Normal file
1057
frontend/src/pages/Configurations.tsx
Normal file
File diff suppressed because it is too large
Load Diff
488
frontend/src/pages/Dashboard.tsx
Normal file
488
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
frontend/src/pages/Login.tsx
Normal file
147
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
621
frontend/src/pages/Machines.tsx
Normal file
621
frontend/src/pages/Machines.tsx
Normal 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
70
frontend/src/store.ts
Normal 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
82
frontend/src/types.ts
Normal 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[];
|
||||
}
|
||||
25
frontend/tailwind.config.js
Normal file
25
frontend/tailwind.config.js
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
11
frontend/vite.config.ts
Normal 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
370
simulator/machine.py
Normal 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()
|
||||
1
simulator/requirements.txt
Normal file
1
simulator/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
httpx==0.26.0
|
||||
Reference in New Issue
Block a user