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