Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor

This commit is contained in:
ske087
2026-05-10 21:07:50 +03:00
commit 8d9df56b0b
364 changed files with 73655 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
31614
31616
31618
31620
31622
+29
View File
@@ -0,0 +1,29 @@
# ─────────────────────────────────────────────────────────────────────────────
# Enterprise Digital Platform — Environment Variables
# Copy to .env and fill in the values before running docker-compose up
# ─────────────────────────────────────────────────────────────────────────────
# ── Shared secrets (MUST be changed in production) ───────────────────────────
# This secret signs the platform-wide JWT token for SSO.
# All services must share the same value.
PORTAL_JWT_SECRET=change-this-to-a-random-256-bit-secret
# ── Portal service ────────────────────────────────────────────────────────────
PORTAL_SECRET_KEY=change-this-portal-flask-secret
# Initial admin account created on first startup
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_EMAIL=admin@yourdomain.com
# ── DigiServer v2 ─────────────────────────────────────────────────────────────
DIGISERVER_SECRET_KEY=change-digiserver-flask-secret
# ── IT Asset Management ───────────────────────────────────────────────────────
ITASSETS_SECRET_KEY=change-itassets-flask-secret
# Optional: override SQLite with MySQL for IT Assets
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@host:3306/itassets?charset=utf8mb4
# ── Domain (used for HTTPS / cookies in production) ──────────────────────────
DOMAIN=localhost
+51
View File
@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
# Virtual environments
.venv/
venv/
env/
# Databases — never commit live data
*.db
*.sqlite
*.sqlite3
instance/
# Data directories with generated content
data/
uploads/
pdfs/
logs/
*.log
# Environment / secrets
.env
# .env.example is intentionally kept
# Generated nginx dev config
nginx/.dev-nginx.conf
# Dev runtime files
.dev-logs/
*.pid
# Node
node_modules/
dist/
build/
.npm/
# OS / editor
.DS_Store
Thumbs.db
.vscode/
*.swp
*.swo
# Flask / Werkzeug
flask_session/
+37
View File
@@ -0,0 +1,37 @@
# Copy this file to .env and fill in the values
# Flask
SECRET_KEY=change-this-to-a-long-random-string
FLASK_ENV=development
# MySQL
MYSQL_HOST=db
MYSQL_PORT=3306
MYSQL_USER=itasset_user
MYSQL_PASSWORD=itasset_pass
MYSQL_DB=itasset_db
MYSQL_ROOT_PASSWORD=rootpassword
# LDAP / Active Directory (leave blank to disable LDAP sync)
LDAP_SERVER=ldap://your-dc.company.local
LDAP_PORT=389
LDAP_USE_SSL=false
LDAP_BIND_USER=CN=svc-itasset,OU=Service Accounts,DC=company,DC=local
LDAP_BIND_PASSWORD=service-account-password
LDAP_BASE_DN=OU=Users,DC=company,DC=local
LDAP_USER_SEARCH_FILTER=(&(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))
# Attribute in AD that stores the numeric Windows ID (employeeID is common)
LDAP_WINDOWS_ID_ATTR=employeeID
# Company info for PDF generation
COMPANY_NAME=Your Company Name
COMPANY_ADDRESS=123 Street, City, Country
# Dell TechDirect API (for automatic service-tag lookup)
# Register at https://tdm.dell.com → API Services → Create an API key pair
DELL_CLIENT_ID=
DELL_CLIENT_SECRET=
# File storage (relative to project root)
UPLOAD_FOLDER=uploads
PDF_FOLDER=pdfs
+14
View File
@@ -0,0 +1,14 @@
.env
*.pyc
__pycache__/
*.egg-info/
dist/
build/
.venv/
venv/
uploads/
pdfs/
instance/
migrations/
*.log
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.12-slim
WORKDIR /app
# System dependencies (none needed for reportlab + PyMySQL)
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create storage directories
RUN mkdir -p uploads pdfs
ENV FLASK_ENV=production
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
EXPOSE 5000
CMD ["python", "run.py"]
+132
View File
@@ -0,0 +1,132 @@
# IT Hardware Asset Management System
A full-featured web application built with **Python / Flask + MySQL**, containerised with **Docker Compose**.
---
## Features
| Module | Capabilities |
|---|---|
| **Users** | Manual, CSV, and LDAP/AD import · search · GDPR masking |
| **Assets** | Track by Serial Number + Service Tag · full history |
| **Assignments** | Assign/return assets · complete audit trail |
| **Paperwork** | Generate PDF documents (handover, assignment, return, custom) |
| **Audit Log** | Immutable log of every create / update / delete / mask action |
| **Settings** | Admin user management · LDAP config view |
### User masking (GDPR / off-boarding)
When an employee leaves, press **Mask User** to permanently erase PII (name, email, phone).
The record is kept — linked by the permanent **Windows ID** — so full asset history is preserved for audits, without exposing personal data.
---
## Quick Start (Docker Compose)
```bash
# 1. Copy the example env file and fill in your values
cp .env.example .env
nano .env
# 2. Build and start
docker compose up -d --build
# 3. Open the app
http://localhost:5000
# Default credentials (change immediately in Settings!)
Username: admin
Password: ChangeMe123!
```
---
## Running locally (without Docker)
```bash
# 1. Create a virtual environment
python -m venv venv
source venv/bin/activate # Linux/macOS
# venv\Scripts\activate # Windows
# 2. Install dependencies
pip install -r requirements.txt
# 3. Configure environment
cp .env.example .env
# Edit .env — set MYSQL_HOST=localhost and your DB credentials
# 4. Initialise the database
flask db init
flask db migrate -m "initial"
flask db upgrade
python init_db.py
# 5. Run
python run.py
```
---
## Environment Variables (`.env`)
| Variable | Description | Default |
|---|---|---|
| `SECRET_KEY` | Flask secret key | *(must change)* |
| `MYSQL_HOST` | MySQL host | `db` (Docker) / `localhost` |
| `MYSQL_USER` | MySQL user | `itasset_user` |
| `MYSQL_PASSWORD` | MySQL password | `itasset_pass` |
| `MYSQL_DB` | Database name | `itasset_db` |
| `LDAP_SERVER` | LDAP server URL | *(blank = disabled)* |
| `LDAP_BIND_USER` | Service account DN | — |
| `LDAP_BIND_PASSWORD` | Service account password | — |
| `LDAP_BASE_DN` | Search base | — |
| `LDAP_WINDOWS_ID_ATTR` | AD attribute for numeric ID | `employeeID` |
| `COMPANY_NAME` | Shown on PDF headers | `Your Company Name` |
| `COMPANY_ADDRESS` | Shown on PDF headers | — |
| `DEFAULT_ADMIN_USER` | First-run admin username | `admin` |
| `DEFAULT_ADMIN_PASS` | First-run admin password | `ChangeMe123!` |
---
## CSV Import Format
```csv
windows_id,first_name,last_name,email,department,job_title,location
408525,John,Doe,john.doe@company.com,IT,Engineer,HQ
408526,Jane,Smith,jane.smith@company.com,HR,Manager,HQ
```
Column names are matched case-insensitively. `windows_id` is required; all others are optional.
---
## Project Structure
```
IT_asset_management/
├── app/
│ ├── __init__.py # App factory
│ ├── extensions.py # SQLAlchemy, Flask-Login, Migrate
│ ├── models/ # User, Asset, Assignment, Paperwork, AuditLog, AdminUser
│ ├── routes/ # auth, dashboard, users, assets, assignments, paperwork, audit, settings
│ ├── services/ # csv_service, ldap_service, pdf_service
│ └── templates/ # Jinja2 + Bootstrap 5
├── config.py
├── run.py
├── init_db.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .env.example
```
---
## Security Notes
- Change `DEFAULT_ADMIN_PASS` immediately after first login.
- Set a strong random `SECRET_KEY` in `.env`.
- The LDAP bind password is read from the environment — never commit `.env` to source control.
- PDF files are stored server-side in the `pdfs/` directory (Docker volume). Restrict access as needed.
- Masked user records permanently destroy PII and cannot be reversed.
+71
View File
@@ -0,0 +1,71 @@
import os
from datetime import datetime, date
from flask import Flask
from werkzeug.middleware.proxy_fix import ProxyFix
from config import config
from app.extensions import db, migrate, login_manager
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# Ensure storage directories exist
for folder_key in ('UPLOAD_FOLDER', 'PDF_FOLDER', 'TEMPLATE_FOLDER', 'DOCX_FOLDER'):
folder = os.path.join(app.root_path, '..', app.config[folder_key])
os.makedirs(folder, exist_ok=True)
# Proxy and script-name middleware for umbrella nginx
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
from app.utils.script_name_fix import ScriptNameFix
app.wsgi_app = ScriptNameFix(app.wsgi_app)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
# Import models so Flask-Migrate detects them
from app.models import admin_user, user, asset, assignment, paperwork, audit_log, document_template # noqa: F401
# Register blueprints
from app.routes.auth import bp as auth_bp
from app.routes.dashboard import bp as dashboard_bp
from app.routes.users import bp as users_bp
from app.routes.assets import bp as assets_bp
from app.routes.assignments import bp as assignments_bp
from app.routes.paperwork import bp as paperwork_bp
from app.routes.audit import bp as audit_bp
from app.routes.settings import bp as settings_bp
from app.routes.doc_templates import bp as doc_templates_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(users_bp)
app.register_blueprint(assets_bp)
app.register_blueprint(assignments_bp)
app.register_blueprint(paperwork_bp)
app.register_blueprint(audit_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(doc_templates_bp)
# Inject common template variables
from datetime import datetime, date
@app.context_processor
def inject_globals():
return {
'now': datetime.utcnow(),
'today': date.today(),
'today_date': date.today().isoformat(),
}
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
from app.utils.portal_sso import init_portal_sso
init_portal_sso(app)
# Ensure DB schema exists (idempotent)
with app.app_context():
db.create_all()
return app
+10
View File
@@ -0,0 +1,10 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'warning'
@@ -0,0 +1,9 @@
from app.models.admin_user import AdminUser
from app.models.user import User
from app.models.asset import Asset
from app.models.assignment import Assignment
from app.models.paperwork import Paperwork
from app.models.audit_log import AuditLog
from app.models.compliance_check import ComplianceCheck
__all__ = ['AdminUser', 'User', 'Asset', 'Assignment', 'Paperwork', 'AuditLog', 'ComplianceCheck']
@@ -0,0 +1,33 @@
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.extensions import db, login_manager
class AdminUser(UserMixin, db.Model):
"""IT staff accounts that manage this application."""
__tablename__ = 'admin_users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
full_name = db.Column(db.String(200), nullable=True)
email = db.Column(db.String(200), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(30), default='admin') # admin, readonly
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_login = db.Column(db.DateTime, nullable=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<AdminUser {self.username}>'
@login_manager.user_loader
def load_user(user_id):
return AdminUser.query.get(int(user_id))
+114
View File
@@ -0,0 +1,114 @@
from datetime import datetime
from app.extensions import db
ASSET_TYPES = [
'Laptop', 'Desktop', 'Monitor', 'Keyboard', 'Mouse',
'Headset', 'Docking Station', 'Printer', 'Scanner',
'Tablet', 'Phone', 'Server', 'Network Equipment', 'Other',
]
ASSET_STATUSES = [
('available', 'Available'),
('assigned', 'Assigned'),
('maintenance', 'In Maintenance'),
('retired', 'Retired'),
('lost', 'Lost / Stolen'),
]
class Asset(db.Model):
"""Hardware asset tracked by serial number and/or service tag."""
__tablename__ = 'assets'
id = db.Column(db.Integer, primary_key=True)
# Primary identifiers
serial_number = db.Column(db.String(200), unique=True, nullable=False, index=True)
service_tag = db.Column(db.String(200), unique=True, nullable=True, index=True)
asset_tag = db.Column(db.String(100), nullable=True) # internal barcode / tag
# Classification
asset_type = db.Column(db.String(50), nullable=False)
brand = db.Column(db.String(100), nullable=True)
model = db.Column(db.String(150), nullable=True)
# Technical specs (optional)
processor = db.Column(db.String(200), nullable=True)
ram_gb = db.Column(db.Integer, nullable=True)
storage_gb = db.Column(db.Integer, nullable=True)
operating_system = db.Column(db.String(100), nullable=True)
mac_address = db.Column(db.String(50), nullable=True)
# Procurement
purchase_date = db.Column(db.Date, nullable=True)
warranty_expiry = db.Column(db.Date, nullable=True)
purchase_price = db.Column(db.Numeric(10, 2), nullable=True)
supplier = db.Column(db.String(200), nullable=True)
po_number = db.Column(db.String(100), nullable=True)
# Current state
status = db.Column(db.String(30), default='available', nullable=False)
location = db.Column(db.String(200), nullable=True)
notes = db.Column(db.Text, nullable=True)
# Compliance / IT checks — Desktop & Laptop only
inventory_number = db.Column(db.String(100), nullable=True)
ad_device_name = db.Column(db.String(150), nullable=True)
location_note = db.Column(db.Text, nullable=True) # free-text location note
# Current boolean state
encryption_checked = db.Column(db.Boolean, default=False, nullable=False)
backup_checked = db.Column(db.Boolean, default=False, nullable=False)
hr_notified = db.Column(db.Boolean, default=False, nullable=False)
# Who last changed each check and when
encryption_checked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
encryption_checked_at = db.Column(db.DateTime, nullable=True)
backup_checked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
backup_checked_at = db.Column(db.DateTime, nullable=True)
hr_notified_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
hr_notified_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
# Relationships
assignments = db.relationship(
'Assignment', backref='asset', lazy='dynamic', cascade='all, delete-orphan'
)
paperwork_docs = db.relationship(
'Paperwork', backref='asset', lazy='dynamic'
)
compliance_checks = db.relationship(
'ComplianceCheck', back_populates='asset', lazy='dynamic',
cascade='all, delete-orphan',
order_by='ComplianceCheck.performed_at.desc()',
)
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
encryption_checked_by = db.relationship('AdminUser', foreign_keys=[encryption_checked_by_id])
backup_checked_by = db.relationship('AdminUser', foreign_keys=[backup_checked_by_id])
hr_notified_by = db.relationship('AdminUser', foreign_keys=[hr_notified_by_id])
@property
def current_assignment(self):
return self.assignments.filter_by(is_active=True).first()
@property
def current_user(self):
a = self.current_assignment
return a.user if a else None
@property
def status_badge(self):
colours = {
'available': 'success',
'assigned': 'primary',
'maintenance': 'warning',
'retired': 'secondary',
'lost': 'danger',
}
return colours.get(self.status, 'secondary')
def __repr__(self):
return f'<Asset sn={self.serial_number} type={self.asset_type}>'
@@ -0,0 +1,37 @@
from datetime import datetime
from app.extensions import db
class Assignment(db.Model):
"""Records the assignment of an asset to a user.
Every assignment (including past ones) is kept permanently so that
asset history is preserved even after a user record is masked.
"""
__tablename__ = 'assignments'
id = db.Column(db.Integer, primary_key=True)
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
assigned_date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
returned_date = db.Column(db.Date, nullable=True)
assigned_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
returned_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
notes = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
assigned_by = db.relationship('AdminUser', foreign_keys=[assigned_by_id])
returned_by = db.relationship('AdminUser', foreign_keys=[returned_by_id])
# Paperwork linked to this assignment
paperwork_docs = db.relationship('Paperwork', backref='assignment', lazy='dynamic')
def __repr__(self):
return f'<Assignment asset={self.asset_id} user={self.user_id} active={self.is_active}>'
@@ -0,0 +1,27 @@
from datetime import datetime
from app.extensions import db
class AuditLog(db.Model):
"""Immutable audit trail for all sensitive operations."""
__tablename__ = 'audit_log'
id = db.Column(db.Integer, primary_key=True)
table_name = db.Column(db.String(100), nullable=False)
record_id = db.Column(db.Integer, nullable=True)
action = db.Column(db.String(50), nullable=False) # create | update | delete | mask | assign | return | import
# JSON snapshots
old_values = db.Column(db.Text, nullable=True)
new_values = db.Column(db.Text, nullable=True)
performed_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
performed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
ip_address = db.Column(db.String(50), nullable=True)
description = db.Column(db.String(500), nullable=True)
performed_by = db.relationship('AdminUser', foreign_keys=[performed_by_id])
def __repr__(self):
return f'<AuditLog {self.action} on {self.table_name}#{self.record_id}>'
@@ -0,0 +1,56 @@
from datetime import datetime
from app.extensions import db
CHECK_TYPES = [
('encryption', 'Encryption Verified'),
('backup', 'Backup Configured'),
('hr', 'HR Notified'),
]
class ComplianceCheck(db.Model):
"""
Audit log for every compliance check/uncheck event on an asset.
One row is created each time a check field changes state, recording
who changed it, when, the new state, and an optional note explaining
the action (e.g. "Unverified BitLocker disabled by user").
"""
__tablename__ = 'compliance_checks'
id = db.Column(db.Integer, primary_key=True)
asset_id = db.Column(
db.Integer, db.ForeignKey('assets.id', ondelete='CASCADE'),
nullable=False, index=True
)
# 'encryption' | 'backup' | 'hr'
check_type = db.Column(db.String(30), nullable=False)
# True = checked/verified, False = unchecked/cleared
checked = db.Column(db.Boolean, nullable=False)
performed_by_id = db.Column(
db.Integer, db.ForeignKey('admin_users.id'),
nullable=True
)
performed_at = db.Column(
db.DateTime, default=datetime.utcnow, nullable=False
)
# Free-text reason / note supplied at the time of check or uncheck
notes = db.Column(db.Text, nullable=True)
# Relationships
asset = db.relationship('Asset', back_populates='compliance_checks')
performed_by = db.relationship('AdminUser', foreign_keys=[performed_by_id])
@property
def check_type_label(self):
return dict(CHECK_TYPES).get(self.check_type, self.check_type)
def __repr__(self):
return (
f'<ComplianceCheck asset={self.asset_id} type={self.check_type} '
f'checked={self.checked} by={self.performed_by_id}>'
)
@@ -0,0 +1,53 @@
import json
from datetime import datetime
from app.extensions import db
class DocumentTemplate(db.Model):
"""
Uploaded Word (.docx) templates with Jinja2-style placeholders.
Template authors write {{ variable_name }} in their .docx file.
When a document is generated the placeholders are replaced with actual
context values (user, asset, assignment data).
PII variables (those prefixed user_name, user_email, user_phone) are
re-rendered with masked values when a user's record is masked.
"""
__tablename__ = 'document_templates'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Category maps to Paperwork.document_type so the UI can pre-filter
category = db.Column(db.String(30), nullable=True) # handover|assignment|return|offboarding|custom
# Stored filename relative to TEMPLATE_FOLDER
filename = db.Column(db.String(300), nullable=False)
# JSON list of placeholder names detected at upload time, e.g. ["user_name","asset_serial"]
variables_json = db.Column(db.Text, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
paperwork_docs = db.relationship('Paperwork', back_populates='template', lazy='dynamic')
@property
def variables(self):
if self.variables_json:
try:
return json.loads(self.variables_json)
except (json.JSONDecodeError, TypeError):
return []
return []
@variables.setter
def variables(self, val):
self.variables_json = json.dumps(val)
def __repr__(self):
return f'<DocumentTemplate id={self.id} name={self.name!r}>'
@@ -0,0 +1,72 @@
from datetime import datetime
from app.extensions import db
DOC_TYPES = [
('handover', 'Equipment Handover Receipt'),
('assignment', 'Asset Assignment Agreement'),
('return', 'Equipment Return Form'),
('offboarding', 'Off-Boarding Checklist'),
('custom', 'Custom Document'),
]
class Paperwork(db.Model):
"""Generated paperwork documents tied to a user and optionally an asset."""
__tablename__ = 'paperwork'
id = db.Column(db.Integer, primary_key=True)
document_type = db.Column(db.String(30), nullable=False) # see DOC_TYPES
title = db.Column(db.String(200), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=True)
assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=True)
# FK to DocumentTemplate — null for legacy ReportLab-generated docs
template_id = db.Column(db.Integer, db.ForeignKey('document_templates.id'), nullable=True)
# JSON snapshot of merge variables used at generation time.
# Kept forever so the document can be re-rendered (e.g. after PII masking).
merge_vars = db.Column(db.Text, nullable=True)
# Legacy free-text / JSON snapshot (kept for backwards compat)
template_data = db.Column(db.Text, nullable=True)
# Generated output files (relative to their respective folders)
pdf_filename = db.Column(db.String(300), nullable=True)
docx_filename = db.Column(db.String(300), nullable=True)
notes = db.Column(db.Text, nullable=True)
# Signature
signed_at = db.Column(db.DateTime, nullable=True)
signed_by_name = db.Column(db.String(200), nullable=True) # printed name
signature_data = db.Column(db.Text, nullable=True) # base64 PNG
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
template = db.relationship('DocumentTemplate', back_populates='paperwork_docs',
foreign_keys=[template_id])
@property
def doc_type_label(self):
return dict(DOC_TYPES).get(self.document_type, self.document_type)
@property
def is_signed(self):
return self.signed_at is not None
def get_merge_vars(self):
if self.merge_vars:
try:
import json
return json.loads(self.merge_vars)
except (ValueError, TypeError):
return {}
return {}
def __repr__(self):
return f'<Paperwork id={self.id} type={self.document_type}>'
+96
View File
@@ -0,0 +1,96 @@
from datetime import datetime
from app.extensions import db
class User(db.Model):
"""
Tracked employees / users of IT assets.
Privacy / GDPR masking:
When a user leaves the company, an admin can mask the record.
All PII fields are cleared and replaced with a reference to the
permanent windows_id so asset history is preserved without
exposing personal data during audits.
"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
# Permanent, non-PII identifier — used as the anchor for history after masking
windows_id = db.Column(db.String(50), unique=True, nullable=False, index=True)
# PII fields — nulled out when masked
first_name = db.Column(db.String(100), nullable=True)
last_name = db.Column(db.String(100), nullable=True)
email = db.Column(db.String(200), nullable=True)
phone = db.Column(db.String(50), nullable=True)
# Non-PII organisational data — retained after masking
department = db.Column(db.String(100), nullable=True)
job_title = db.Column(db.String(100), nullable=True)
location = db.Column(db.String(100), nullable=True)
manager_windows_id = db.Column(db.String(50), nullable=True)
# Status
is_active = db.Column(db.Boolean, default=True) # employed / active in company
is_masked = db.Column(db.Boolean, default=False) # PII erased
masked_at = db.Column(db.DateTime, nullable=True)
masked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
# Import metadata
import_source = db.Column(db.String(20), default='manual') # manual | ldap | csv
ldap_dn = db.Column(db.String(500), nullable=True) # AD Distinguished Name
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
assignments = db.relationship(
'Assignment', foreign_keys='Assignment.user_id',
backref='user', lazy='dynamic', cascade='all, delete-orphan'
)
paperwork_docs = db.relationship(
'Paperwork', foreign_keys='Paperwork.user_id',
backref='user', lazy='dynamic', cascade='all, delete-orphan'
)
masked_by = db.relationship('AdminUser', foreign_keys=[masked_by_id])
# ------------------------------------------------------------------
# Display helpers
# ------------------------------------------------------------------
@property
def display_name(self):
if self.is_masked:
return f'[MASKED WID: {self.windows_id}]'
parts = [self.first_name, self.last_name]
full = ' '.join(p for p in parts if p)
return full or self.windows_id
@property
def display_email(self):
return '[MASKED]' if self.is_masked else (self.email or '')
@property
def display_phone(self):
return '[MASKED]' if self.is_masked else (self.phone or '')
@property
def current_assets(self):
"""Returns active assignments."""
return self.assignments.filter_by(is_active=True).all()
def mask(self, admin_user_id):
"""Erase PII while preserving the record for asset-history purposes."""
self.first_name = None
self.last_name = None
self.email = None
self.phone = None
self.is_active = False
self.is_masked = True
self.masked_at = datetime.utcnow()
self.masked_by_id = admin_user_id
def __repr__(self):
return f'<User wid={self.windows_id} masked={self.is_masked}>'
@@ -0,0 +1,13 @@
from app.routes.auth import bp as auth_bp
from app.routes.dashboard import bp as dashboard_bp
from app.routes.users import bp as users_bp
from app.routes.assets import bp as assets_bp
from app.routes.assignments import bp as assignments_bp
from app.routes.paperwork import bp as paperwork_bp
from app.routes.audit import bp as audit_bp
from app.routes.settings import bp as settings_bp
__all__ = [
'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp',
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp',
]
+390
View File
@@ -0,0 +1,390 @@
import json
from datetime import date, datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify)
from flask_login import login_required, current_user
from app.extensions import db
from app.models.asset import Asset, ASSET_TYPES, ASSET_STATUSES
from app.models.audit_log import AuditLog
from app.models.compliance_check import ComplianceCheck
from app.services.dell_service import lookup_service_tag
bp = Blueprint('assets', __name__, url_prefix='/assets')
def _parse_date(value):
"""Convert a 'YYYY-MM-DD' string to a date object; return None if blank."""
if not value:
return None
if isinstance(value, date):
return value
try:
return date.fromisoformat(value.strip())
except (ValueError, AttributeError):
return None
def _log(action, record_id, description, old=None, new=None):
entry = AuditLog(
table_name='assets',
record_id=record_id,
action=action,
old_values=json.dumps(old) if old else None,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
# ------------------------------------------------------------------
# List
# ------------------------------------------------------------------
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '').strip()
status_filter = request.args.get('status', '')
type_filter = request.args.get('asset_type', '')
query = Asset.query
if q:
like = f'%{q}%'
query = query.filter(
db.or_(
Asset.serial_number.like(like),
Asset.service_tag.like(like),
Asset.asset_tag.like(like),
Asset.brand.like(like),
Asset.model.like(like),
)
)
if status_filter:
query = query.filter_by(status=status_filter)
if type_filter:
query = query.filter_by(asset_type=type_filter)
pagination = query.order_by(Asset.created_at.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
return render_template(
'assets/index.html',
pagination=pagination, q=q,
status_filter=status_filter, type_filter=type_filter,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES,
)
# ------------------------------------------------------------------
# Create
# ------------------------------------------------------------------
@bp.route('/new', methods=['GET', 'POST'])
@login_required
def create():
if request.method == 'POST':
sn = request.form.get('serial_number', '').strip()
if not sn:
flash('Serial Number is required.', 'danger')
return render_template('assets/form.html', asset=None,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
if Asset.query.filter_by(serial_number=sn).first():
flash(f'An asset with serial number {sn} already exists.', 'danger')
return render_template('assets/form.html', asset=None,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
# Service tag mirrors the serial number (same identifier)
service_tag = sn
if Asset.query.filter(Asset.service_tag == service_tag, Asset.serial_number != sn).first():
flash(f'An asset with service tag {service_tag} already exists.', 'danger')
return render_template('assets/form.html', asset=None,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
asset = Asset(
serial_number=sn,
service_tag=service_tag,
asset_tag=request.form.get('asset_tag', '').strip() or None,
asset_type=request.form.get('asset_type', 'Laptop'),
brand=request.form.get('brand', '').strip() or None,
model=request.form.get('model', '').strip() or None,
processor=request.form.get('processor', '').strip() or None,
ram_gb=request.form.get('ram_gb', type=int),
storage_gb=request.form.get('storage_gb', type=int),
operating_system=request.form.get('operating_system', '').strip() or None,
mac_address=request.form.get('mac_address', '').strip() or None,
purchase_date=_parse_date(request.form.get('purchase_date')),
warranty_expiry=_parse_date(request.form.get('warranty_expiry')),
purchase_price=request.form.get('purchase_price', type=float),
supplier=request.form.get('supplier', '').strip() or None,
po_number=request.form.get('po_number', '').strip() or None,
status=request.form.get('status', 'available'),
location=request.form.get('location', '').strip() or None,
notes=request.form.get('notes', '').strip() or None,
created_by_id=current_user.id,
)
db.session.add(asset)
db.session.flush()
_log('create', asset.id, f'Created asset SN={sn}',
new={'serial_number': sn, 'asset_type': asset.asset_type})
db.session.commit()
flash(f'Asset {sn} created.', 'success')
return redirect(url_for('assets.detail', asset_id=asset.id))
# Pre-fill values from Dell lookup (passed as query string params)
prefill = {
'service_tag': request.args.get('service_tag', ''),
'serial_number': request.args.get('serial_number', ''),
'brand': request.args.get('brand', ''),
'model': request.args.get('model', ''),
'asset_type': request.args.get('asset_type', ''),
'operating_system': request.args.get('operating_system', ''),
'warranty_expiry': request.args.get('warranty_expiry', ''),
'purchase_date': request.args.get('purchase_date', ''),
}
return render_template('assets/form.html', asset=None,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES,
prefill=prefill)
# ------------------------------------------------------------------
# Dell service-tag lookup (AJAX)
# ------------------------------------------------------------------
@bp.route('/dell-lookup')
@login_required
def dell_lookup():
tag = request.args.get('tag', '').strip()
if not tag:
return jsonify({'error': 'Service tag is required.'}), 400
# Check for duplicates first
existing = Asset.query.filter_by(service_tag=tag.upper()).first()
if existing:
return jsonify({
'error': f'An asset with service tag {tag.upper()} already exists.',
'existing_id': existing.id,
}), 409
try:
data = lookup_service_tag(tag)
return jsonify(data)
except RuntimeError as exc:
return jsonify({'error': str(exc)}), 502
# ------------------------------------------------------------------
# Detail
# ------------------------------------------------------------------
@bp.route('/<int:asset_id>')
@login_required
def detail(asset_id):
asset = Asset.query.get_or_404(asset_id)
history = asset.assignments.order_by(db.text('assigned_date DESC')).all()
docs = asset.paperwork_docs.order_by(db.text('created_at DESC')).all()
compliance_log = (
AuditLog.query
.filter_by(table_name='assets', record_id=asset_id, action='compliance_update')
.order_by(AuditLog.performed_at.desc())
.all()
)
check_history = (
ComplianceCheck.query
.filter_by(asset_id=asset_id)
.order_by(ComplianceCheck.performed_at.desc())
.all()
)
return render_template('assets/detail.html', asset=asset, history=history,
docs=docs, compliance_log=compliance_log,
check_history=check_history)
# ------------------------------------------------------------------
# Edit
# ------------------------------------------------------------------
@bp.route('/<int:asset_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(asset_id):
asset = Asset.query.get_or_404(asset_id)
if request.method == 'POST':
old = {'serial_number': asset.serial_number, 'status': asset.status}
new_sn = request.form.get('serial_number', '').strip()
if not new_sn:
flash('Serial Number is required.', 'danger')
return render_template('assets/form.html', asset=asset,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
# Check uniqueness only if SN changed
if new_sn != asset.serial_number:
if Asset.query.filter(Asset.serial_number == new_sn, Asset.id != asset_id).first():
flash(f'Serial number {new_sn} is already used by another asset.', 'danger')
return render_template('assets/form.html', asset=asset,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
asset.serial_number = new_sn
asset.service_tag = new_sn # service tag mirrors serial number
asset.asset_tag = request.form.get('asset_tag', '').strip() or None
asset.asset_type = request.form.get('asset_type', asset.asset_type)
asset.brand = request.form.get('brand', '').strip() or None
asset.model = request.form.get('model', '').strip() or None
asset.processor = request.form.get('processor', '').strip() or None
asset.ram_gb = request.form.get('ram_gb', type=int)
asset.storage_gb = request.form.get('storage_gb', type=int)
asset.operating_system = request.form.get('operating_system', '').strip() or None
asset.mac_address = request.form.get('mac_address', '').strip() or None
asset.purchase_date = _parse_date(request.form.get('purchase_date'))
asset.warranty_expiry = _parse_date(request.form.get('warranty_expiry'))
asset.purchase_price = request.form.get('purchase_price', type=float)
asset.supplier = request.form.get('supplier', '').strip() or None
asset.po_number = request.form.get('po_number', '').strip() or None
asset.status = request.form.get('status', asset.status)
asset.location = request.form.get('location', '').strip() or None
asset.notes = request.form.get('notes', '').strip() or None
_log('update', asset.id, f'Updated asset SN={asset.serial_number}',
old=old, new={'serial_number': asset.serial_number, 'status': asset.status})
db.session.commit()
flash('Asset updated.', 'success')
return redirect(url_for('assets.detail', asset_id=asset_id))
return render_template('assets/form.html', asset=asset,
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
# ------------------------------------------------------------------
# Compliance update (Desktop / Laptop specific fields)
# ------------------------------------------------------------------
_COMPLIANCE_FIELDS = {
'inventory_number': 'Inventory Number',
'ad_device_name': 'AD Device Name',
'location_note': 'Location Note',
'encryption_checked': 'Encryption Checked',
'backup_checked': 'Backup Checked',
'hr_notified': 'HR Notified',
}
@bp.route('/<int:asset_id>/compliance', methods=['POST'])
@login_required
def update_compliance(asset_id):
asset = Asset.query.get_or_404(asset_id)
old = {
'inventory_number': asset.inventory_number,
'ad_device_name': asset.ad_device_name,
'location_note': asset.location_note,
'encryption_checked': asset.encryption_checked,
'backup_checked': asset.backup_checked,
'hr_notified': asset.hr_notified,
}
asset.inventory_number = request.form.get('inventory_number', '').strip() or None
asset.ad_device_name = request.form.get('ad_device_name', '').strip() or None
asset.location_note = request.form.get('location_note', '').strip() or None
new_encryption = bool(request.form.get('encryption_checked'))
new_backup = bool(request.form.get('backup_checked'))
new_hr = bool(request.form.get('hr_notified'))
notes = request.form.get('compliance_notes', '').strip() or None
now = datetime.utcnow()
# Record a ComplianceCheck event for each boolean that changed
_check_map = [
('encryption', 'encryption_checked', new_encryption,
'encryption_checked_by_id', 'encryption_checked_at'),
('backup', 'backup_checked', new_backup,
'backup_checked_by_id', 'backup_checked_at'),
('hr', 'hr_notified', new_hr,
'hr_notified_by_id', 'hr_notified_at'),
]
for check_type, field, new_val, by_field, at_field in _check_map:
if old[field] != new_val:
setattr(asset, field, new_val)
setattr(asset, by_field, current_user.id)
setattr(asset, at_field, now)
db.session.add(ComplianceCheck(
asset_id=asset_id,
check_type=check_type,
checked=new_val,
performed_by_id=current_user.id,
performed_at=now,
notes=notes,
))
new = {
'inventory_number': asset.inventory_number,
'ad_device_name': asset.ad_device_name,
'location_note': asset.location_note,
'encryption_checked': asset.encryption_checked,
'backup_checked': asset.backup_checked,
'hr_notified': asset.hr_notified,
}
# Build human-readable description of what changed
changed = [
f'{_COMPLIANCE_FIELDS[k]}: {repr(old[k])}{repr(new[k])}'
for k in _COMPLIANCE_FIELDS if old[k] != new[k]
]
if changed:
_log('compliance_update', asset.id,
f'Compliance updated: {"; ".join(changed)}',
old=old, new=new)
db.session.commit()
flash('Compliance fields updated.', 'success')
else:
flash('No changes detected.', 'info')
return redirect(url_for('assets.detail', asset_id=asset_id))
# ------------------------------------------------------------------
# Quick lookup by SN or service tag (AJAX)
# ------------------------------------------------------------------
@bp.route('/lookup')
@login_required
def lookup():
q = request.args.get('q', '').strip()
if not q:
return jsonify(None)
asset = Asset.query.filter(
db.or_(Asset.serial_number == q, Asset.service_tag == q)
).first()
if not asset:
return jsonify(None)
return jsonify({
'id': asset.id,
'serial_number': asset.serial_number,
'service_tag': asset.service_tag,
'brand': asset.brand,
'model': asset.model,
'asset_type': asset.asset_type,
'status': asset.status,
})
# ------------------------------------------------------------------
# Search (AJAX dropdown)
# ------------------------------------------------------------------
@bp.route('/search')
@login_required
def search():
q = request.args.get('q', '').strip()
if len(q) < 2:
return jsonify([])
like = f'%{q}%'
assets = Asset.query.filter(
db.or_(
Asset.serial_number.like(like),
Asset.service_tag.like(like),
Asset.asset_tag.like(like),
)
).limit(15).all()
return jsonify([{
'id': a.id,
'text': f'{a.brand or ""} {a.model or ""} — SN: {a.serial_number}'.strip(''),
'serial_number': a.serial_number,
'service_tag': a.service_tag,
'status': a.status,
} for a in assets])
@@ -0,0 +1,145 @@
import json
from datetime import date
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app)
from flask_login import login_required, current_user
from app.extensions import db
from app.models.assignment import Assignment
from app.models.asset import Asset
from app.models.user import User
from app.models.audit_log import AuditLog
bp = Blueprint('assignments', __name__, url_prefix='/assignments')
def _log(action, record_id, description, old=None, new=None):
entry = AuditLog(
table_name='assignments',
record_id=record_id,
action=action,
old_values=json.dumps(old) if old else None,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
active_only = request.args.get('active', '1') == '1'
q = request.args.get('q', '').strip()
query = Assignment.query
if active_only:
query = query.filter_by(is_active=True)
pagination = query.order_by(Assignment.assigned_date.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
return render_template('assignments/index.html',
pagination=pagination, active_only=active_only, q=q)
@bp.route('/new', methods=['GET', 'POST'])
@login_required
def create():
# Pre-fill from query params (used from asset / user detail pages)
preselect_asset_id = request.args.get('asset_id', type=int)
preselect_user_id = request.args.get('user_id', type=int)
if request.method == 'POST':
user_id = request.form.get('user_id', type=int)
asset_id = request.form.get('asset_id', type=int)
assigned_date_str = request.form.get('assigned_date', '')
notes = request.form.get('notes', '').strip() or None
user = User.query.get(user_id) if user_id else None
asset = Asset.query.get(asset_id) if asset_id else None
errors = []
if not user:
errors.append('User is required.')
if not asset:
errors.append('Asset is required.')
if user and user.is_masked:
errors.append('Cannot assign assets to a masked user.')
if asset and asset.status == 'assigned':
errors.append(f'Asset {asset.serial_number} is already assigned.')
if asset and asset.status in ('retired', 'lost'):
errors.append(f'Asset {asset.serial_number} is {asset.status} and cannot be assigned.')
if errors:
for e in errors:
flash(e, 'danger')
return render_template('assignments/form.html',
preselect_asset_id=asset_id,
preselect_user_id=user_id)
try:
assigned_date = date.fromisoformat(assigned_date_str) if assigned_date_str else date.today()
except ValueError:
assigned_date = date.today()
assignment = Assignment(
asset_id=asset.id,
user_id=user.id,
assigned_date=assigned_date,
assigned_by_id=current_user.id,
notes=notes,
is_active=True,
)
asset.status = 'assigned'
db.session.add(assignment)
db.session.flush()
_log('assign', assignment.id,
f'Assigned asset SN={asset.serial_number} to WID={user.windows_id}',
new={'asset_sn': asset.serial_number, 'user_wid': user.windows_id,
'date': str(assigned_date)})
db.session.commit()
flash(f'Asset {asset.serial_number} assigned to {user.display_name}.', 'success')
return redirect(url_for('assignments.index'))
return render_template('assignments/form.html',
preselect_asset_id=preselect_asset_id,
preselect_user_id=preselect_user_id)
@bp.route('/<int:assignment_id>/return', methods=['POST'])
@login_required
def return_asset(assignment_id):
assignment = Assignment.query.get_or_404(assignment_id)
if not assignment.is_active:
flash('This assignment is already closed.', 'info')
return redirect(url_for('assignments.index'))
returned_date_str = request.form.get('returned_date', '')
try:
returned_date = date.fromisoformat(returned_date_str) if returned_date_str else date.today()
except ValueError:
returned_date = date.today()
assignment.returned_date = returned_date
assignment.returned_by_id = current_user.id
assignment.is_active = False
assignment.notes = (assignment.notes or '') + ('\n' + request.form.get('return_notes', '').strip() if request.form.get('return_notes') else '')
# Only set asset back to available if no other active assignment (safety check)
other_active = Assignment.query.filter(
Assignment.asset_id == assignment.asset_id,
Assignment.is_active == True, # noqa: E712
Assignment.id != assignment.id,
).first()
if not other_active:
assignment.asset.status = 'available'
_log('return', assignment.id,
f'Returned asset SN={assignment.asset.serial_number} from WID={assignment.user.windows_id}',
new={'returned_date': str(returned_date)})
db.session.commit()
flash(f'Asset {assignment.asset.serial_number} returned.', 'success')
return redirect(url_for('assignments.index'))
+30
View File
@@ -0,0 +1,30 @@
from flask import Blueprint, render_template, request, current_app
from flask_login import login_required
from app.models.audit_log import AuditLog
bp = Blueprint('audit', __name__, url_prefix='/audit')
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
table_filter = request.args.get('table', '')
action_filter = request.args.get('action', '')
query = AuditLog.query
if table_filter:
query = query.filter_by(table_name=table_filter)
if action_filter:
query = query.filter_by(action=action_filter)
pagination = query.order_by(AuditLog.performed_at.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
tables = ['users', 'assets', 'assignments', 'paperwork']
actions = ['create', 'update', 'delete', 'mask', 'assign', 'return', 'import']
return render_template('audit/index.html',
pagination=pagination,
table_filter=table_filter,
action_filter=action_filter,
tables=tables, actions=actions)
+18
View File
@@ -0,0 +1,18 @@
from flask import Blueprint, redirect, url_for, current_app
from flask_login import logout_user, login_required
bp = Blueprint('auth', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
# Authentication is handled by the EDP portal via nginx SSO.
# Redirect any direct login attempts to the portal.
return redirect(current_app.config.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login'))
@bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(current_app.config.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login'))
@@ -0,0 +1,35 @@
from flask import Blueprint, render_template
from flask_login import login_required
from app.models.user import User
from app.models.asset import Asset
from app.models.assignment import Assignment
from app.models.paperwork import Paperwork
bp = Blueprint('dashboard', __name__)
@bp.route('/')
@login_required
def index():
stats = {
'total_users': User.query.count(),
'active_users': User.query.filter_by(is_active=True, is_masked=False).count(),
'masked_users': User.query.filter_by(is_masked=True).count(),
'total_assets': Asset.query.count(),
'available_assets': Asset.query.filter_by(status='available').count(),
'assigned_assets': Asset.query.filter_by(status='assigned').count(),
'maintenance_assets': Asset.query.filter_by(status='maintenance').count(),
'active_assignments': Assignment.query.filter_by(is_active=True).count(),
'total_paperwork': Paperwork.query.count(),
}
# Recent assignments
recent_assignments = (
Assignment.query
.filter_by(is_active=True)
.order_by(Assignment.created_at.desc())
.limit(10)
.all()
)
return render_template('dashboard/index.html', stats=stats, recent_assignments=recent_assignments)
@@ -0,0 +1,202 @@
import json
import os
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, send_from_directory, jsonify)
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app.extensions import db
from app.models.document_template import DocumentTemplate
from app.models.paperwork import DOC_TYPES
from app.models.audit_log import AuditLog
from app.services.template_service import extract_variables
bp = Blueprint('doc_templates', __name__, url_prefix='/doc-templates')
ALLOWED_EXT = {'docx'}
def _log(action, record_id, description, new=None):
entry = AuditLog(
table_name='document_templates',
record_id=record_id,
action=action,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
def _template_folder(app):
folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates'))
os.makedirs(folder, exist_ok=True)
return folder
def _allowed(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
# ------------------------------------------------------------------
# List
# ------------------------------------------------------------------
@bp.route('/')
@login_required
def index():
templates = DocumentTemplate.query.order_by(DocumentTemplate.name).all()
return render_template('doc_templates/index.html',
templates=templates, doc_types=DOC_TYPES)
# ------------------------------------------------------------------
# Upload
# ------------------------------------------------------------------
@bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload():
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip() or None
category = request.form.get('category', '') or None
f = request.files.get('docx_file')
if not name:
flash('Template name is required.', 'danger')
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
if not f or not f.filename:
flash('Please select a .docx file.', 'danger')
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
if not _allowed(f.filename):
flash('Only .docx files are accepted.', 'danger')
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
folder = _template_folder(current_app)
safe_name = secure_filename(f.filename)
# Prefix with timestamp to avoid collisions
from datetime import datetime as _dt
prefix = _dt.utcnow().strftime('%Y%m%d_%H%M%S_')
filename = prefix + safe_name
save_path = os.path.join(folder, filename)
f.save(save_path)
# Extract variables from the uploaded template
try:
variables = extract_variables(save_path)
except Exception as exc:
current_app.logger.warning('Variable extraction failed: %s', exc)
variables = []
tpl = DocumentTemplate(
name=name,
description=description,
category=category,
filename=filename,
created_by_id=current_user.id,
)
tpl.variables = variables
db.session.add(tpl)
db.session.flush()
_log('create', tpl.id, f'Uploaded template "{name}"', new={'name': name, 'filename': filename})
db.session.commit()
flash(f'Template "{name}" uploaded. Found {len(variables)} variable(s).', 'success')
return redirect(url_for('doc_templates.detail', tpl_id=tpl.id))
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
# ------------------------------------------------------------------
# Detail / variable list
# ------------------------------------------------------------------
@bp.route('/<int:tpl_id>')
@login_required
def detail(tpl_id):
tpl = DocumentTemplate.query.get_or_404(tpl_id)
return render_template('doc_templates/detail.html', tpl=tpl, doc_types=DOC_TYPES)
# ------------------------------------------------------------------
# Download original template file
# ------------------------------------------------------------------
@bp.route('/<int:tpl_id>/download')
@login_required
def download(tpl_id):
tpl = DocumentTemplate.query.get_or_404(tpl_id)
folder = _template_folder(current_app)
return send_from_directory(folder, tpl.filename, as_attachment=True,
download_name=tpl.name + '.docx')
# ------------------------------------------------------------------
# Re-scan variables (after template is replaced / edited externally)
# ------------------------------------------------------------------
@bp.route('/<int:tpl_id>/rescan', methods=['POST'])
@login_required
def rescan(tpl_id):
tpl = DocumentTemplate.query.get_or_404(tpl_id)
folder = _template_folder(current_app)
path = os.path.join(folder, tpl.filename)
try:
variables = extract_variables(path)
tpl.variables = variables
db.session.commit()
flash(f'Rescanned: found {len(variables)} variable(s).', 'success')
except Exception as exc:
flash(f'Rescan failed: {exc}', 'danger')
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
# ------------------------------------------------------------------
# Edit metadata
# ------------------------------------------------------------------
@bp.route('/<int:tpl_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(tpl_id):
tpl = DocumentTemplate.query.get_or_404(tpl_id)
if request.method == 'POST':
tpl.name = request.form.get('name', tpl.name).strip()
tpl.description = request.form.get('description', '').strip() or None
tpl.category = request.form.get('category', '') or None
db.session.commit()
flash('Template updated.', 'success')
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
return render_template('doc_templates/edit.html', tpl=tpl, doc_types=DOC_TYPES)
# ------------------------------------------------------------------
# Delete
# ------------------------------------------------------------------
@bp.route('/<int:tpl_id>/delete', methods=['POST'])
@login_required
def delete(tpl_id):
tpl = DocumentTemplate.query.get_or_404(tpl_id)
# Check if any documents were generated from this template
if tpl.paperwork_docs.count() > 0:
flash(f'Cannot delete — {tpl.paperwork_docs.count()} document(s) were generated from this template.', 'danger')
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
folder = _template_folder(current_app)
file_path = os.path.join(folder, tpl.filename)
try:
if os.path.exists(file_path):
os.remove(file_path)
except OSError:
pass
_log('delete', tpl.id, f'Deleted template "{tpl.name}"')
db.session.delete(tpl)
db.session.commit()
flash(f'Template "{tpl.name}" deleted.', 'success')
return redirect(url_for('doc_templates.index'))
# ------------------------------------------------------------------
# AJAX: return variables for a template (used by paperwork form)
# ------------------------------------------------------------------
@bp.route('/<int:tpl_id>/variables.json')
@login_required
def variables_json(tpl_id):
tpl = DocumentTemplate.query.get_or_404(tpl_id)
return jsonify({'variables': tpl.variables, 'name': tpl.name})
+245
View File
@@ -0,0 +1,245 @@
import json
import os
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, send_from_directory, abort, jsonify)
from flask_login import login_required, current_user
from app.extensions import db
from app.models.paperwork import Paperwork, DOC_TYPES
from app.models.document_template import DocumentTemplate
from app.models.user import User
from app.models.asset import Asset
from app.models.assignment import Assignment
from app.models.audit_log import AuditLog
from app.services.pdf_service import generate_paperwork_pdf
from app.services.template_service import (
build_context, render_template_to_docx, _template_path
)
bp = Blueprint('paperwork', __name__, url_prefix='/paperwork')
def _log(action, record_id, description, new=None):
entry = AuditLog(
table_name='paperwork',
record_id=record_id,
action=action,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '').strip()
doc_type_filter = request.args.get('doc_type', '')
query = Paperwork.query
if doc_type_filter:
query = query.filter_by(document_type=doc_type_filter)
pagination = query.order_by(Paperwork.created_at.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
return render_template('paperwork/index.html',
pagination=pagination, q=q,
doc_type_filter=doc_type_filter,
doc_types=DOC_TYPES)
@bp.route('/new', methods=['GET', 'POST'])
@login_required
def create():
preselect_user_id = request.args.get('user_id', type=int)
preselect_asset_id = request.args.get('asset_id', type=int)
preselect_assignment_id = request.args.get('assignment_id', type=int)
all_templates = DocumentTemplate.query.order_by(DocumentTemplate.name).all()
if request.method == 'POST':
user_id = request.form.get('user_id', type=int)
asset_id = request.form.get('asset_id', type=int) or None
assignment_id = request.form.get('assignment_id', type=int) or None
doc_type = request.form.get('document_type', 'handover')
title = request.form.get('title', '').strip()
notes = request.form.get('notes', '').strip() or None
template_id = request.form.get('template_id', type=int) or None
user = User.query.get(user_id) if user_id else None
if not user:
flash('User is required.', 'danger')
return render_template('paperwork/form.html',
doc_types=DOC_TYPES, all_templates=all_templates,
preselect_user_id=preselect_user_id,
preselect_asset_id=preselect_asset_id)
asset = Asset.query.get(asset_id) if asset_id else None
assignment = Assignment.query.get(assignment_id) if assignment_id else None
if not title:
title = f'{dict(DOC_TYPES).get(doc_type, doc_type)}{user.display_name}'
doc = Paperwork(
document_type=doc_type,
title=title,
user_id=user.id,
asset_id=asset_id,
assignment_id=assignment_id,
template_id=template_id,
notes=notes,
created_by_id=current_user.id,
)
db.session.add(doc)
db.session.flush()
# Build and store merge variables
ctx = build_context(user, asset=asset, assignment=assignment,
paperwork=doc, app=current_app._get_current_object())
# Allow form overrides for any variable (textarea fields named var_*)
for k, v in request.form.items():
if k.startswith('var_'):
ctx[k[4:]] = v
ctx['admin_name'] = current_user.username
doc.merge_vars = json.dumps(ctx)
# Generate .docx from template if one is selected
if template_id:
tpl_obj = DocumentTemplate.query.get(template_id)
if tpl_obj:
tpl_file = _template_path(current_app._get_current_object(), tpl_obj.filename)
if os.path.exists(tpl_file):
out_name = f'doc_{doc.id}_{tpl_obj.id}.docx'
try:
render_template_to_docx(tpl_file, ctx, out_name)
doc.docx_filename = out_name
except Exception as exc:
current_app.logger.error('docx render failed: %s', exc)
flash(f'Word document generation failed: {exc}', 'warning')
# Always generate PDF
try:
pdf_filename = generate_paperwork_pdf(doc, current_app._get_current_object())
doc.pdf_filename = pdf_filename
except Exception as exc:
current_app.logger.error(f'PDF generation failed: {exc}')
flash(f'Document saved but PDF generation failed: {exc}', 'warning')
_log('create', doc.id, f'Created paperwork "{title}" type={doc_type}',
new={'type': doc_type, 'user_id': user_id, 'asset_id': asset_id,
'template_id': template_id})
db.session.commit()
flash(f'Document "{title}" created.', 'success')
return redirect(url_for('paperwork.detail', doc_id=doc.id))
return render_template('paperwork/form.html',
doc_types=DOC_TYPES,
all_templates=all_templates,
preselect_user_id=preselect_user_id,
preselect_asset_id=preselect_asset_id,
preselect_assignment_id=preselect_assignment_id)
@bp.route('/<int:doc_id>')
@login_required
def detail(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
return render_template('paperwork/detail.html', doc=doc,
merge_vars=doc.get_merge_vars())
@bp.route('/<int:doc_id>/download')
@login_required
def download(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
if not doc.pdf_filename:
flash('No PDF available for this document.', 'warning')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
pdf_dir = os.path.join(current_app.root_path, '..', current_app.config['PDF_FOLDER'])
return send_from_directory(pdf_dir, doc.pdf_filename, as_attachment=True)
@bp.route('/<int:doc_id>/download-docx')
@login_required
def download_docx(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
if not doc.docx_filename:
flash('No Word document available for this record.', 'warning')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
docx_dir = os.path.join(current_app.root_path, '..', current_app.config.get('DOCX_FOLDER', 'docx_output'))
safe_name = (doc.title or f'document_{doc.id}') + '.docx'
return send_from_directory(docx_dir, doc.docx_filename, as_attachment=True,
download_name=safe_name)
@bp.route('/<int:doc_id>/regenerate', methods=['POST'])
@login_required
def regenerate(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
app = current_app._get_current_object()
# Regenerate .docx if template-based
if doc.template_id and doc.template:
tpl_file = _template_path(app, doc.template.filename)
ctx = doc.get_merge_vars()
out_name = doc.docx_filename or f'doc_{doc.id}_{doc.template_id}.docx'
try:
render_template_to_docx(tpl_file, ctx, out_name)
doc.docx_filename = out_name
except Exception as exc:
flash(f'Word regeneration failed: {exc}', 'warning')
try:
pdf_filename = generate_paperwork_pdf(doc, app)
doc.pdf_filename = pdf_filename
db.session.commit()
flash('Document regenerated.', 'success')
except Exception as exc:
flash(f'PDF generation failed: {exc}', 'danger')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
@bp.route('/<int:doc_id>/sign', methods=['POST'])
@login_required
def sign(doc_id):
"""Record a signature on a document."""
doc = Paperwork.query.get_or_404(doc_id)
signed_by_name = request.form.get('signed_by_name', '').strip()
signature_data = request.form.get('signature_data', '').strip() # base64 PNG from canvas
if not signed_by_name:
flash('Signer name is required.', 'danger')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
doc.signed_at = datetime.utcnow()
doc.signed_by_name = signed_by_name
if signature_data:
doc.signature_data = signature_data
# Regenerate PDF to embed signature
try:
pdf_filename = generate_paperwork_pdf(doc, current_app._get_current_object())
doc.pdf_filename = pdf_filename
except Exception as exc:
current_app.logger.error('PDF re-gen after sign failed: %s', exc)
_log('sign', doc.id, f'Document signed by {signed_by_name}')
db.session.commit()
flash(f'Document signed by {signed_by_name}.', 'success')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
@bp.route('/<int:doc_id>/unsign', methods=['POST'])
@login_required
def unsign(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
doc.signed_at = None
doc.signed_by_name = None
doc.signature_data = None
db.session.commit()
flash('Signature removed.', 'info')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
@@ -0,0 +1,52 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app
from flask_login import login_required, current_user
from app.extensions import db
from app.models.admin_user import AdminUser
bp = Blueprint('settings', __name__, url_prefix='/settings')
@bp.route('/')
@login_required
def index():
admins = AdminUser.query.order_by(AdminUser.username).all()
return render_template('settings/index.html', admins=admins, config=current_app.config)
@bp.route('/admin/new', methods=['POST'])
@login_required
def create_admin():
username = request.form.get('username', '').strip()
email = request.form.get('email', '').strip()
full_name = request.form.get('full_name', '').strip()
password = request.form.get('password', '')
role = request.form.get('role', 'admin')
if not username or not email or not password:
flash('Username, email and password are required.', 'danger')
return redirect(url_for('settings.index'))
if AdminUser.query.filter_by(username=username).first():
flash(f'Username "{username}" is already taken.', 'danger')
return redirect(url_for('settings.index'))
admin = AdminUser(username=username, email=email, full_name=full_name, role=role)
admin.set_password(password)
db.session.add(admin)
db.session.commit()
flash(f'Admin user "{username}" created.', 'success')
return redirect(url_for('settings.index'))
@bp.route('/admin/<int:admin_id>/toggle', methods=['POST'])
@login_required
def toggle_admin(admin_id):
admin = AdminUser.query.get_or_404(admin_id)
if admin.id == current_user.id:
flash('You cannot deactivate your own account.', 'danger')
return redirect(url_for('settings.index'))
admin.is_active = not admin.is_active
db.session.commit()
status = 'activated' if admin.is_active else 'deactivated'
flash(f'Admin "{admin.username}" {status}.', 'success')
return redirect(url_for('settings.index'))
+332
View File
@@ -0,0 +1,332 @@
import json
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify)
from flask_login import login_required, current_user
from app.extensions import db
from app.models.user import User
from app.models.audit_log import AuditLog
from app.services.csv_service import parse_users_csv
from app.services.ldap_service import LDAPService
from app.services.template_service import mask_variables, regenerate_for_paperwork
bp = Blueprint('users', __name__, url_prefix='/users')
def _log(action, record_id, description, old=None, new=None):
entry = AuditLog(
table_name='users',
record_id=record_id,
action=action,
old_values=json.dumps(old) if old else None,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
# ------------------------------------------------------------------
# List
# ------------------------------------------------------------------
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '').strip()
show_masked = request.args.get('masked', '0') == '1'
query = User.query
if not show_masked:
query = query.filter_by(is_masked=False)
if q:
like = f'%{q}%'
query = query.filter(
db.or_(
User.windows_id.like(like),
User.first_name.like(like),
User.last_name.like(like),
User.email.like(like),
User.department.like(like),
)
)
pagination = query.order_by(User.last_name, User.first_name).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
return render_template('users/index.html', pagination=pagination, q=q, show_masked=show_masked)
# ------------------------------------------------------------------
# Create
# ------------------------------------------------------------------
@bp.route('/new', methods=['GET', 'POST'])
@login_required
def create():
if request.method == 'POST':
windows_id = request.form.get('windows_id', '').strip()
if not windows_id:
flash('Windows ID is required.', 'danger')
return render_template('users/form.html', user=None)
if User.query.filter_by(windows_id=windows_id).first():
flash(f'A user with Windows ID {windows_id} already exists.', 'danger')
return render_template('users/form.html', user=None)
user = User(
windows_id=windows_id,
first_name=request.form.get('first_name', '').strip() or None,
last_name=request.form.get('last_name', '').strip() or None,
email=request.form.get('email', '').strip() or None,
phone=request.form.get('phone', '').strip() or None,
department=request.form.get('department', '').strip() or None,
job_title=request.form.get('job_title', '').strip() or None,
location=request.form.get('location', '').strip() or None,
import_source='manual',
)
db.session.add(user)
db.session.flush()
_log('create', user.id, f'Created user WID={windows_id}',
new={'windows_id': windows_id, 'email': user.email})
db.session.commit()
flash(f'User {user.display_name} created.', 'success')
return redirect(url_for('users.detail', user_id=user.id))
return render_template('users/form.html', user=None)
# ------------------------------------------------------------------
# Detail
# ------------------------------------------------------------------
@bp.route('/<int:user_id>')
@login_required
def detail(user_id):
user = User.query.get_or_404(user_id)
assignments = user.assignments.order_by(db.text('assigned_date DESC')).all()
docs = user.paperwork_docs.order_by(db.text('created_at DESC')).all()
return render_template('users/detail.html', user=user, assignments=assignments, docs=docs)
# ------------------------------------------------------------------
# Edit
# ------------------------------------------------------------------
@bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(user_id):
user = User.query.get_or_404(user_id)
if user.is_masked:
flash('Cannot edit a masked user record.', 'warning')
return redirect(url_for('users.detail', user_id=user_id))
if request.method == 'POST':
old = {
'first_name': user.first_name, 'last_name': user.last_name,
'email': user.email, 'department': user.department,
}
user.first_name = request.form.get('first_name', '').strip() or None
user.last_name = request.form.get('last_name', '').strip() or None
user.email = request.form.get('email', '').strip() or None
user.phone = request.form.get('phone', '').strip() or None
user.department = request.form.get('department', '').strip() or None
user.job_title = request.form.get('job_title', '').strip() or None
user.location = request.form.get('location', '').strip() or None
user.is_active = request.form.get('is_active') == 'on'
_log('update', user.id, f'Updated user WID={user.windows_id}',
old=old, new={'first_name': user.first_name, 'last_name': user.last_name})
db.session.commit()
flash('User updated.', 'success')
return redirect(url_for('users.detail', user_id=user_id))
return render_template('users/form.html', user=user)
# ------------------------------------------------------------------
# Mask (GDPR / off-boarding)
# ------------------------------------------------------------------
@bp.route('/<int:user_id>/mask', methods=['POST'])
@login_required
def mask(user_id):
user = User.query.get_or_404(user_id)
if user.is_masked:
flash('User is already masked.', 'info')
return redirect(url_for('users.detail', user_id=user_id))
old = {'first_name': user.first_name, 'last_name': user.last_name, 'email': user.email}
user.mask(current_user.id)
_log('mask', user.id,
f'Masked PII for WID={user.windows_id}',
old=old, new={'is_masked': True})
# Re-render all template-based documents with masked PII
app = current_app._get_current_object()
for pw_doc in user.paperwork_docs:
if pw_doc.merge_vars:
try:
masked_ctx = mask_variables(pw_doc.get_merge_vars())
pw_doc.merge_vars = json.dumps(masked_ctx)
regenerate_for_paperwork(pw_doc, app)
except Exception as exc:
app.logger.error('Failed to re-render doc %s on mask: %s', pw_doc.id, exc)
db.session.commit()
flash(f'User WID={user.windows_id} has been masked. Asset history is preserved.', 'success')
return redirect(url_for('users.detail', user_id=user_id))
# ------------------------------------------------------------------
# Import page
# ------------------------------------------------------------------
@bp.route('/import')
@login_required
def import_page():
return render_template('users/import.html')
# ------------------------------------------------------------------
# CSV import
# ------------------------------------------------------------------
@bp.route('/import/csv', methods=['POST'])
@login_required
def import_csv():
file = request.files.get('csv_file')
if not file or not file.filename.endswith('.csv'):
flash('Please upload a valid CSV file.', 'danger')
return redirect(url_for('users.import_page'))
users_data, errors = parse_users_csv(file.stream)
created = updated = skipped = 0
for row in users_data:
wid = row.get('windows_id', '').strip()
if not wid:
skipped += 1
continue
existing = User.query.filter_by(windows_id=wid).first()
if existing:
if not existing.is_masked:
existing.first_name = row.get('first_name') or existing.first_name
existing.last_name = row.get('last_name') or existing.last_name
existing.email = row.get('email') or existing.email
existing.department = row.get('department') or existing.department
existing.job_title = row.get('job_title') or existing.job_title
existing.location = row.get('location') or existing.location
existing.import_source = 'csv'
updated += 1
else:
skipped += 1
else:
u = User(
windows_id=wid,
first_name=row.get('first_name') or None,
last_name=row.get('last_name') or None,
email=row.get('email') or None,
department=row.get('department') or None,
job_title=row.get('job_title') or None,
location=row.get('location') or None,
import_source='csv',
)
db.session.add(u)
created += 1
_log('import', None,
f'CSV import: {created} created, {updated} updated, {skipped} skipped',
new={'created': created, 'updated': updated, 'skipped': skipped, 'errors': errors})
db.session.commit()
if errors:
flash(f'Import completed with warnings: {"; ".join(errors[:5])}', 'warning')
flash(f'CSV import done — {created} created, {updated} updated, {skipped} skipped.', 'success')
return redirect(url_for('users.index'))
# ------------------------------------------------------------------
# LDAP / AD sync
# ------------------------------------------------------------------
@bp.route('/import/ldap', methods=['POST'])
@login_required
def import_ldap():
if not current_app.config.get('LDAP_SERVER'):
flash('LDAP server is not configured. Update Settings first.', 'danger')
return redirect(url_for('users.import_page'))
try:
service = LDAPService()
users_data = service.sync_users()
except Exception as exc:
flash(f'LDAP connection failed: {exc}', 'danger')
return redirect(url_for('users.import_page'))
created = updated = skipped = 0
for row in users_data:
wid = row.get('windows_id', '').strip()
if not wid:
skipped += 1
continue
existing = User.query.filter_by(windows_id=wid).first()
if existing:
if not existing.is_masked:
existing.first_name = row.get('first_name') or existing.first_name
existing.last_name = row.get('last_name') or existing.last_name
existing.email = row.get('email') or existing.email
existing.department = row.get('department') or existing.department
existing.job_title = row.get('job_title') or existing.job_title
existing.location = row.get('location') or existing.location
existing.ldap_dn = row.get('ldap_dn') or existing.ldap_dn
existing.is_active = row.get('is_active', True)
existing.import_source = 'ldap'
updated += 1
else:
skipped += 1
else:
u = User(
windows_id=wid,
first_name=row.get('first_name') or None,
last_name=row.get('last_name') or None,
email=row.get('email') or None,
department=row.get('department') or None,
job_title=row.get('job_title') or None,
location=row.get('location') or None,
ldap_dn=row.get('ldap_dn') or None,
is_active=row.get('is_active', True),
import_source='ldap',
)
db.session.add(u)
created += 1
_log('import', None,
f'LDAP sync: {created} created, {updated} updated, {skipped} skipped',
new={'created': created, 'updated': updated, 'skipped': skipped})
db.session.commit()
flash(f'LDAP sync done — {created} created, {updated} updated, {skipped} skipped.', 'success')
return redirect(url_for('users.index'))
# ------------------------------------------------------------------
# Quick search (AJAX)
# ------------------------------------------------------------------
@bp.route('/search')
@login_required
def search():
q = request.args.get('q', '').strip()
if len(q) < 2:
return jsonify([])
like = f'%{q}%'
users = User.query.filter(
User.is_masked == False, # noqa: E712
db.or_(
User.windows_id.like(like),
User.first_name.like(like),
User.last_name.like(like),
)
).limit(15).all()
return jsonify([{
'id': u.id,
'text': f'{u.display_name} (WID: {u.windows_id})',
'windows_id': u.windows_id,
} for u in users])
@@ -0,0 +1,5 @@
from app.services.csv_service import parse_users_csv
from app.services.ldap_service import LDAPService
from app.services.pdf_service import generate_paperwork_pdf
__all__ = ['parse_users_csv', 'LDAPService', 'generate_paperwork_pdf']
@@ -0,0 +1,60 @@
import csv
import io
# Maps our field names to possible CSV column header aliases (case-insensitive)
FIELD_ALIASES = {
'windows_id': ['windows_id', 'employeeid', 'employee_id', 'wid', 'id', 'user_id', 'samaccountname'],
'first_name': ['first_name', 'firstname', 'givenname', 'given_name', 'prenom'],
'last_name': ['last_name', 'lastname', 'surname', 'sn', 'family_name', 'nom'],
'email': ['email', 'mail', 'email_address', 'emailaddress'],
'department': ['department', 'dept', 'division'],
'job_title': ['job_title', 'title', 'position', 'jobtitle', 'job_position'],
'phone': ['phone', 'telephone', 'mobile', 'phonenumber', 'telephonenumber'],
'location': ['location', 'office', 'site', 'physicaldeliveryofficename'],
}
def parse_users_csv(file_stream):
"""
Parse a CSV file stream and return (users_list, errors_list).
Accepts BOM-prefixed UTF-8 or plain UTF-8. Column headers are
matched case-insensitively against FIELD_ALIASES.
"""
try:
content = file_stream.read().decode('utf-8-sig')
except (UnicodeDecodeError, AttributeError):
return [], ['Could not decode file. Please use UTF-8 encoding.']
reader = csv.DictReader(io.StringIO(content))
if not reader.fieldnames:
return [], ['CSV file is empty or has no header row.']
# Build a lookup: normalised header -> our field name
norm_headers = {h.lower().strip().replace(' ', '_'): h for h in reader.fieldnames}
col_map = {} # our_field -> actual_csv_header
for field, aliases in FIELD_ALIASES.items():
for alias in aliases:
if alias in norm_headers:
col_map[field] = norm_headers[alias]
break
errors = []
users = []
for row_num, row in enumerate(reader, start=2):
user = {}
for field in FIELD_ALIASES:
csv_col = col_map.get(field)
user[field] = (row.get(csv_col, '') or '').strip() if csv_col else ''
wid = user.get('windows_id', '').strip()
if not wid:
errors.append(f'Row {row_num}: missing windows_id — skipped.')
continue
users.append(user)
return users, errors
@@ -0,0 +1,164 @@
"""
Dell asset lookup service.
Two modes (chosen automatically):
1. **No credentials** Returns a partial pre-fill (brand=Dell, OS default, service_tag).
Model and warranty must be filled in manually; a link to Dell's support page is provided.
Dell's public website is protected by Akamai and cannot be scraped reliably.
2. **TechDirect API** Full data including model, warranty dates, serial number.
Register free at https://tdm.dell.com → API Services → Create an API key pair.
Set DELL_CLIENT_ID and DELL_CLIENT_SECRET in your .env file.
"""
import logging
from datetime import datetime
import requests
from flask import current_app
log = logging.getLogger(__name__)
_TOKEN_URL = "https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
_ASSET_URL = "https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements"
_SUPPORT_PAGE = "https://www.dell.com/support/home/en-us/product-support/servicetag/{tag}/overview"
_TYPE_MAP = [
(["latitude", "inspiron", "xps", "vostro", "precision 5", "precision 7"], "Laptop"),
(["optiplex", "precision tower", "precision 3", "precision 9",
"optiplex micro", "optiplex small"], "Desktop"),
(["poweredge", "server"], "Server"),
(["wyse", "thin client"], "Other"),
(["monitor", "display", "screen", "s24", "s27", "p24", "u27"], "Monitor"),
]
def _detect_type(description: str) -> str:
desc = description.lower()
for keywords, asset_type in _TYPE_MAP:
if any(kw in desc for kw in keywords):
return asset_type
return "Laptop" # sensible default for Dell business hardware
# ------------------------------------------------------------------
# Partial pre-fill (no credentials)
# ------------------------------------------------------------------
def _partial_prefill(tag: str) -> dict:
"""
Return a minimal pre-fill dict using only what we know without querying Dell.
Includes a link to Dell's support page so the user can look up the rest.
"""
return {
"service_tag": tag,
"serial_number": "",
"brand": "Dell",
"model": "",
"asset_type": "Laptop",
"operating_system": "Windows 11 Pro",
"warranty_expiry": "",
"purchase_date": "",
"source": "partial",
"support_url": _SUPPORT_PAGE.format(tag=tag),
}
# ------------------------------------------------------------------
# Official TechDirect API (requires credentials)
# ------------------------------------------------------------------
def _get_token(client_id: str, client_secret: str) -> str:
resp = requests.post(
_TOKEN_URL,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
timeout=10,
)
resp.raise_for_status()
return resp.json()["access_token"]
def _lookup_api(tag: str, client_id: str, client_secret: str) -> dict:
try:
token = _get_token(client_id, client_secret)
except Exception as exc:
raise RuntimeError(f"Failed to obtain Dell API token: {exc}") from exc
try:
resp = requests.get(
_ASSET_URL,
params={"servicetags": tag},
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
raise RuntimeError(f"Dell API request failed: {exc}") from exc
if not data:
raise RuntimeError(f"No data returned for service tag '{tag}'.")
item = data[0] if isinstance(data, list) else data
system_desc = item.get("productLineDescription") or item.get("systemDescription") or ""
model = system_desc.strip()
serial_number = (item.get("serviceTag") or tag).upper()
warranty_expiry = None
for ent in (item.get("entitlements") or []):
end_str = ent.get("endDate") or ""
if end_str:
try:
dt = datetime.fromisoformat(end_str[:10])
if warranty_expiry is None or dt > warranty_expiry:
warranty_expiry = dt
except ValueError:
pass
ship_date = item.get("shipDate") or ""
purchase_date = None
if ship_date:
try:
purchase_date = datetime.fromisoformat(ship_date[:10])
except ValueError:
pass
return {
"service_tag": tag,
"serial_number": serial_number,
"brand": "Dell",
"model": model,
"asset_type": _detect_type(f"{system_desc} {item.get('productFamily', '')}"),
"operating_system": "Windows 11 Pro",
"warranty_expiry": warranty_expiry.strftime("%Y-%m-%d") if warranty_expiry else "",
"purchase_date": purchase_date.strftime("%Y-%m-%d") if purchase_date else "",
"source": "techdirect_api",
"support_url": _SUPPORT_PAGE.format(tag=tag),
}
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
def lookup_service_tag(service_tag: str) -> dict:
"""
Look up a Dell service tag.
Uses TechDirect API when DELL_CLIENT_ID + DELL_CLIENT_SECRET are configured;
otherwise returns a partial pre-fill with a link to Dell's support page.
"""
tag = service_tag.strip().upper()
client_id = current_app.config.get("DELL_CLIENT_ID", "")
client_secret = current_app.config.get("DELL_CLIENT_SECRET", "")
if client_id and client_secret:
log.debug("Dell lookup via TechDirect API for %s", tag)
return _lookup_api(tag, client_id, client_secret)
log.debug("Dell lookup returning partial pre-fill for %s (no API credentials)", tag)
return _partial_prefill(tag)
@@ -0,0 +1,84 @@
from ldap3 import Server, Connection, ALL, SUBTREE
from flask import current_app
class LDAPService:
"""Wraps ldap3 to sync users from Active Directory."""
ATTRIBUTES = [
'employeeID', 'sAMAccountName', 'givenName', 'sn',
'mail', 'department', 'title', 'telephoneNumber',
'distinguishedName', 'physicalDeliveryOfficeName',
'userAccountControl',
]
def _connect(self):
cfg = current_app.config
server = Server(
cfg['LDAP_SERVER'],
port=cfg['LDAP_PORT'],
use_ssl=cfg['LDAP_USE_SSL'],
get_info=ALL,
)
conn = Connection(
server,
user=cfg['LDAP_BIND_USER'],
password=cfg['LDAP_BIND_PASSWORD'],
auto_bind=True,
)
return conn
def sync_users(self):
"""
Query AD and return a list of dicts ready to be upserted into the
User model. Raises an exception if the connection fails.
"""
cfg = current_app.config
conn = self._connect()
conn.search(
search_base=cfg['LDAP_BASE_DN'],
search_filter=cfg['LDAP_USER_SEARCH_FILTER'],
search_scope=SUBTREE,
attributes=self.ATTRIBUTES,
)
wid_attr = cfg['LDAP_WINDOWS_ID_ATTR']
users = []
for entry in conn.entries:
# Resolve windows_id from the configured attribute, fall back to sAMAccountName
raw_wid = str(getattr(entry, wid_attr, '') or '')
if not raw_wid:
raw_wid = str(entry.sAMAccountName or '')
if not raw_wid:
continue # skip entries with no identifier
# userAccountControl bit 2 = disabled account
uac = 0
try:
uac = int(str(entry.userAccountControl or 0))
except (ValueError, TypeError):
pass
is_active = not bool(uac & 2)
users.append({
'windows_id': raw_wid.strip(),
'first_name': str(entry.givenName or '').strip(),
'last_name': str(entry.sn or '').strip(),
'email': str(entry.mail or '').strip(),
'department': str(entry.department or '').strip(),
'job_title': str(entry.title or '').strip(),
'phone': str(entry.telephoneNumber or '').strip(),
'location': str(entry.physicalDeliveryOfficeName or '').strip(),
'ldap_dn': str(entry.distinguishedName or '').strip(),
'is_active': is_active,
})
conn.unbind()
return users
def test_connection(self):
"""Returns True if a bind succeeds, raises on failure."""
conn = self._connect()
conn.unbind()
return True
@@ -0,0 +1,238 @@
import os
import json
from datetime import datetime
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
)
def _styles():
base = getSampleStyleSheet()
custom = {
'title': ParagraphStyle(
'DocTitle', parent=base['Title'],
fontSize=16, spaceAfter=6, alignment=TA_CENTER, textColor=colors.HexColor('#1a3a5c'),
),
'subtitle': ParagraphStyle(
'Subtitle', parent=base['Normal'],
fontSize=10, spaceAfter=4, alignment=TA_CENTER, textColor=colors.HexColor('#555555'),
),
'section': ParagraphStyle(
'Section', parent=base['Heading2'],
fontSize=11, spaceBefore=12, spaceAfter=4,
textColor=colors.HexColor('#1a3a5c'), borderPad=2,
),
'normal': base['Normal'],
'small': ParagraphStyle(
'Small', parent=base['Normal'], fontSize=8, textColor=colors.grey,
),
'footer': ParagraphStyle(
'Footer', parent=base['Normal'],
fontSize=8, alignment=TA_CENTER, textColor=colors.grey,
),
'signature_label': ParagraphStyle(
'SigLabel', parent=base['Normal'], fontSize=9, alignment=TA_CENTER,
),
'right': ParagraphStyle(
'Right', parent=base['Normal'], alignment=TA_RIGHT,
),
}
return custom
def _header_table(company_name, company_address, doc_type_label, doc_id, created_at, styles):
left_data = [
[Paragraph(f'<b>{company_name}</b>', styles['normal'])],
[Paragraph(company_address or '', styles['small'])],
]
right_data = [
[Paragraph(f'<b>{doc_type_label}</b>', styles['right'])],
[Paragraph(f'Doc #: {doc_id}', styles['right'])],
[Paragraph(f'Date: {created_at.strftime("%d/%m/%Y") if created_at else ""}', styles['right'])],
]
table = Table(
[[Table(left_data, colWidths=[9 * cm]), Table(right_data, colWidths=[8 * cm])]],
colWidths=[9 * cm, 8 * cm],
)
table.setStyle(TableStyle([('VALIGN', (0, 0), (-1, -1), 'TOP')]))
return table
def _field_table(rows, styles):
"""rows: list of (label, value) tuples."""
data = [[Paragraph(f'<b>{label}</b>', styles['normal']), Paragraph(str(value or ''), styles['normal'])]
for label, value in rows]
t = Table(data, colWidths=[5 * cm, 12 * cm])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#eaf0f8')),
('GRID', (0, 0), (-1, -1), 0.4, colors.HexColor('#cccccc')),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 6),
]))
return t
def _signature_block(styles):
data = [
[Paragraph('Issued by (IT Dept.)', styles['signature_label']),
Paragraph('Received by (Employee)', styles['signature_label'])],
[Spacer(1, 1.5 * cm), Spacer(1, 1.5 * cm)],
[HRFlowable(width='95%'), HRFlowable(width='95%')],
[Paragraph('Name / Signature / Date', styles['signature_label']),
Paragraph('Name / Signature / Date', styles['signature_label'])],
]
t = Table(data, colWidths=[8.5 * cm, 8.5 * cm])
t.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
]))
return t
def generate_paperwork_pdf(doc, app):
"""
Generate a PDF for a Paperwork document and save it to PDF_FOLDER.
Returns the filename (not the full path).
"""
pdf_dir = os.path.join(app.root_path, '..', app.config['PDF_FOLDER'])
os.makedirs(pdf_dir, exist_ok=True)
filename = f'doc_{doc.id}_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.pdf'
filepath = os.path.join(pdf_dir, filename)
page_doc = SimpleDocTemplate(
filepath, pagesize=A4,
rightMargin=2 * cm, leftMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2.5 * cm,
title=doc.title,
)
styles = _styles()
company_name = app.config.get('COMPANY_NAME', '')
company_address = app.config.get('COMPANY_ADDRESS', '')
user = doc.user
asset = doc.asset
assignment = doc.assignment
# Load extra template fields
extra_fields = {}
if doc.template_data:
try:
raw = json.loads(doc.template_data)
extra_fields = {k.replace('td_', '').replace('_', ' ').title(): v
for k, v in raw.items()}
except (json.JSONDecodeError, TypeError):
pass
story = []
# ── Header ──────────────────────────────────────────────────────────────
story.append(_header_table(company_name, company_address,
doc.doc_type_label, doc.id, doc.created_at, styles))
story.append(Spacer(1, 0.3 * cm))
story.append(HRFlowable(width='100%', thickness=1.5, color=colors.HexColor('#1a3a5c')))
story.append(Spacer(1, 0.4 * cm))
# ── Title ────────────────────────────────────────────────────────────────
story.append(Paragraph(doc.title, styles['title']))
story.append(Spacer(1, 0.5 * cm))
# ── User section ─────────────────────────────────────────────────────────
story.append(Paragraph('Employee Information', styles['section']))
user_rows = [
('Windows ID', user.windows_id),
('Full Name', user.display_name),
('Email', user.display_email),
('Department', user.department or ''),
('Job Title', user.job_title or ''),
('Location', user.location or ''),
]
story.append(_field_table(user_rows, styles))
story.append(Spacer(1, 0.4 * cm))
# ── Asset section ─────────────────────────────────────────────────────────
if asset:
story.append(Paragraph('Asset Information', styles['section']))
asset_rows = [
('Asset Type', asset.asset_type),
('Brand / Model', f'{asset.brand or ""} {asset.model or ""}'.strip() or ''),
('Serial Number', asset.serial_number),
('Service Tag', asset.service_tag or ''),
('Asset Tag', asset.asset_tag or ''),
('Operating System', asset.operating_system or ''),
]
if asset.ram_gb:
asset_rows.append(('RAM', f'{asset.ram_gb} GB'))
if asset.storage_gb:
asset_rows.append(('Storage', f'{asset.storage_gb} GB'))
story.append(_field_table(asset_rows, styles))
story.append(Spacer(1, 0.4 * cm))
# ── Assignment section ────────────────────────────────────────────────────
if assignment:
story.append(Paragraph('Assignment Details', styles['section']))
assign_rows = [
('Assigned Date', str(assignment.assigned_date) if assignment.assigned_date else ''),
('Returned Date', str(assignment.returned_date) if assignment.returned_date else 'Currently assigned'),
]
story.append(_field_table(assign_rows, styles))
story.append(Spacer(1, 0.4 * cm))
# ── Extra / custom fields ─────────────────────────────────────────────────
if extra_fields:
story.append(Paragraph('Additional Information', styles['section']))
story.append(_field_table(list(extra_fields.items()), styles))
story.append(Spacer(1, 0.4 * cm))
# ── Notes ─────────────────────────────────────────────────────────────────
if doc.notes:
story.append(Paragraph('Notes', styles['section']))
story.append(Paragraph(doc.notes, styles['normal']))
story.append(Spacer(1, 0.4 * cm))
# Type-specific clauses
if doc.document_type == 'assignment':
story.append(Paragraph('Terms & Conditions', styles['section']))
clause = (
'By signing below the employee acknowledges receipt of the above equipment in good '
'working condition and agrees to: (1) use it solely for company business, '
'(2) report any damage or loss immediately to the IT department, and '
'(3) return it upon request or at the end of employment.'
)
story.append(Paragraph(clause, styles['normal']))
story.append(Spacer(1, 0.4 * cm))
if doc.document_type == 'return':
story.append(Paragraph('Return Confirmation', styles['section']))
clause = (
'By signing below both parties confirm that the above equipment has been returned '
'to the IT department and has been inspected for completeness and condition.'
)
story.append(Paragraph(clause, styles['normal']))
story.append(Spacer(1, 0.4 * cm))
# ── Signatures ────────────────────────────────────────────────────────────
story.append(Paragraph('Signatures', styles['section']))
story.append(Spacer(1, 0.3 * cm))
story.append(_signature_block(styles))
story.append(Spacer(1, 0.5 * cm))
# ── Footer ────────────────────────────────────────────────────────────────
story.append(HRFlowable(width='100%', thickness=0.5, color=colors.grey))
story.append(Spacer(1, 0.2 * cm))
story.append(Paragraph(
f'Generated by IT Asset Management System · {datetime.utcnow().strftime("%d/%m/%Y %H:%M")} UTC',
styles['footer'],
))
page_doc.build(story)
return filename
@@ -0,0 +1,221 @@
"""
Template service: fill Word (.docx) templates and generate output files.
Variables available in templates (use {{ variable_name }} syntax):
User:
{{ user_name }} Full name (or [MASKED] after PII erasure)
{{ user_email }}
{{ user_phone }}
{{ user_department }}
{{ user_job_title }}
{{ user_location }}
{{ user_windows_id }} Always present — survives masking
{{ user_employee_id }} Same as windows_id (alias)
Asset:
{{ asset_serial }} Serial number
{{ asset_service_tag }}
{{ asset_tag }} Internal asset tag
{{ asset_brand }}
{{ asset_model }}
{{ asset_type }} e.g. Laptop / Desktop
{{ asset_os }}
{{ asset_warranty_expiry }}
{{ asset_location }}
Assignment:
{{ assignment_date }}
{{ assignment_id }}
{{ return_date }}
Document / company:
{{ document_date }} Date of generation (dd/mm/yyyy)
{{ document_number }} Paperwork record ID
{{ company_name }}
{{ company_address }}
{{ admin_name }} Logged-in admin who generated the doc
PII masking:
When User.mask() is called, all Paperwork records that were generated
from a template have their merge_vars updated (PII keys replaced with
[MASKED]) and the .docx/.pdf files are regenerated.
"""
import json
import os
import re
from datetime import datetime
from docxtpl import DocxTemplate
from flask import current_app
# PII variable keys — these are blanked out on user mask
PII_VARS = {'user_name', 'user_email', 'user_phone'}
# Variables that survive masking (non-PII)
SAFE_VARS = {
'user_department', 'user_job_title', 'user_location',
'user_windows_id', 'user_employee_id',
}
def build_context(user, asset=None, assignment=None, paperwork=None, app=None):
"""
Build the Jinja2 context dict from ORM objects.
Used both at generation time and when re-rendering after masking.
"""
if app is None:
app = current_app._get_current_object()
ctx = {
# User
'user_name': user.display_name,
'user_email': user.display_email,
'user_phone': user.display_phone,
'user_department': user.department or '',
'user_job_title': user.job_title or '',
'user_location': user.location or '',
'user_windows_id': user.windows_id or '',
'user_employee_id': user.windows_id or '',
# Asset
'asset_serial': '',
'asset_service_tag': '',
'asset_tag': '',
'asset_brand': '',
'asset_model': '',
'asset_type': '',
'asset_os': '',
'asset_warranty_expiry': '',
'asset_location': '',
# Assignment
'assignment_date': '',
'assignment_id': '',
'return_date': '',
# Document/company
'document_date': datetime.utcnow().strftime('%d/%m/%Y'),
'document_number': str(paperwork.id) if paperwork else '',
'company_name': app.config.get('COMPANY_NAME', ''),
'company_address': app.config.get('COMPANY_ADDRESS', ''),
'admin_name': '',
}
if asset:
ctx.update({
'asset_serial': asset.serial_number or '',
'asset_service_tag': asset.service_tag or '',
'asset_tag': asset.asset_tag or '',
'asset_brand': asset.brand or '',
'asset_model': asset.model or '',
'asset_type': asset.asset_type or '',
'asset_os': asset.operating_system or '',
'asset_warranty_expiry': (asset.warranty_expiry.strftime('%d/%m/%Y')
if asset.warranty_expiry else ''),
'asset_location': asset.location or '',
})
if assignment:
ctx['assignment_date'] = (assignment.assigned_date.strftime('%d/%m/%Y')
if assignment.assigned_date else '')
ctx['assignment_id'] = str(assignment.id)
ctx['return_date'] = (assignment.returned_date.strftime('%d/%m/%Y')
if assignment.returned_date else '')
return ctx
def _docx_path(app, filename):
folder = os.path.join(app.root_path, '..', app.config.get('DOCX_FOLDER', 'docx_output'))
os.makedirs(folder, exist_ok=True)
return os.path.join(folder, filename)
def _template_path(app, filename):
folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates'))
return os.path.join(folder, filename)
def extract_variables(template_path):
"""
Parse a .docx file and return all unique Jinja2 variable names found
in the document text ({{ var_name }} syntax).
"""
try:
tpl = DocxTemplate(template_path)
return sorted(tpl.get_undeclared_template_variables())
except Exception:
# Fallback: open as zip and scan XML for {{ ... }}
import zipfile
vars_found = set()
try:
with zipfile.ZipFile(template_path, 'r') as z:
for name in z.namelist():
if name.endswith('.xml'):
content = z.read(name).decode('utf-8', errors='ignore')
vars_found.update(re.findall(r'\{\{\s*(\w+)\s*\}\}', content))
except Exception:
pass
return sorted(vars_found)
def render_template_to_docx(template_filepath, context, output_filename):
"""
Fill a .docx template with context values and save to DOCX_FOLDER.
Returns the saved filename.
"""
app = current_app._get_current_object()
tpl = DocxTemplate(template_filepath)
tpl.render(context)
out_path = _docx_path(app, output_filename)
tpl.save(out_path)
return output_filename
def regenerate_for_paperwork(paperwork, app=None):
"""
Re-render the .docx for an existing Paperwork record using its stored
merge_vars. Called after PII masking to overwrite files with sanitised data.
If the record was generated from a template, regenerates .docx.
Also regenerates the PDF via pdf_service if a PDF exists.
Returns (docx_filename, pdf_filename) — either may be None.
"""
from app.services.pdf_service import generate_paperwork_pdf
if app is None:
app = current_app._get_current_object()
docx_out = None
pdf_out = None
if paperwork.template_id and paperwork.template:
tpl_file = _template_path(app, paperwork.template.filename)
if os.path.exists(tpl_file):
ctx = paperwork.get_merge_vars()
out_name = paperwork.docx_filename or f'doc_{paperwork.id}.docx'
try:
render_template_to_docx(tpl_file, ctx, out_name)
docx_out = out_name
except Exception as exc:
app.logger.error('Template re-render failed for paperwork %s: %s', paperwork.id, exc)
# Always regenerate the PDF (uses pdf_service, reads from Paperwork + User model)
if paperwork.pdf_filename:
try:
pdf_out = generate_paperwork_pdf(paperwork, app)
except Exception as exc:
app.logger.error('PDF re-render failed for paperwork %s: %s', paperwork.id, exc)
return docx_out, pdf_out
def mask_variables(merge_vars: dict) -> dict:
"""Return a copy of merge_vars with PII values replaced by [MASKED]."""
masked = dict(merge_vars)
for key in PII_VARS:
if key in masked:
masked[key] = '[MASKED]'
return masked
@@ -0,0 +1,453 @@
{% extends 'base.html' %}
{% block title %}{{ asset.serial_number }} IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('assets.index') }}">Assets</a></li>
<li class="breadcrumb-item active">{{ asset.serial_number }}</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1>
<i class="bi bi-laptop me-2"></i>{{ asset.brand or '' }} {{ asset.model or '' }}
<span class="badge badge-{{ asset.status }} fs-6 align-middle ms-2">{{ asset.status | title }}</span>
</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('assets.edit', asset_id=asset.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
{% if asset.status == 'available' %}
<a href="{{ url_for('assignments.create', asset_id=asset.id) }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-plus-circle me-1"></i>Assign
</a>
{% endif %}
{% if asset.current_user %}
<a href="{{ url_for('paperwork.create', asset_id=asset.id, user_id=asset.current_user.id) }}"
class="btn btn-sm btn-outline-info">
<i class="bi bi-file-earmark-plus me-1"></i>New Doc
</a>
{% endif %}
</div>
</div>
<div class="row g-3">
<!-- Asset details -->
<div class="col-md-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-info-circle me-2 text-primary"></i>Asset Details
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Type</dt>
<dd class="col-7"><span class="badge bg-secondary">{{ asset.asset_type }}</span></dd>
<dt class="col-5 text-muted small">Brand</dt>
<dd class="col-7">{{ asset.brand or '—' }}</dd>
<dt class="col-5 text-muted small">Model</dt>
<dd class="col-7">{{ asset.model or '—' }}</dd>
<dt class="col-5 text-muted small">Serial No.</dt>
<dd class="col-7"><code>{{ asset.serial_number }}</code></dd>
<dt class="col-5 text-muted small">Service Tag</dt>
<dd class="col-7"><code>{{ asset.service_tag or '—' }}</code></dd>
<dt class="col-5 text-muted small">Asset Tag</dt>
<dd class="col-7">{{ asset.asset_tag or '—' }}</dd>
<dt class="col-5 text-muted small">OS</dt>
<dd class="col-7">{{ asset.operating_system or '—' }}</dd>
{% if asset.processor %}
<dt class="col-5 text-muted small">CPU</dt>
<dd class="col-7">{{ asset.processor }}</dd>
{% endif %}
{% if asset.ram_gb %}
<dt class="col-5 text-muted small">RAM</dt>
<dd class="col-7">{{ asset.ram_gb }} GB</dd>
{% endif %}
{% if asset.storage_gb %}
<dt class="col-5 text-muted small">Storage</dt>
<dd class="col-7">{{ asset.storage_gb }} GB</dd>
{% endif %}
{% if asset.mac_address %}
<dt class="col-5 text-muted small">MAC</dt>
<dd class="col-7"><code>{{ asset.mac_address }}</code></dd>
{% endif %}
</dl>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-receipt me-2 text-primary"></i>Procurement
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Purchased</dt>
<dd class="col-7">{{ asset.purchase_date.strftime('%d/%m/%Y') if asset.purchase_date else '—' }}</dd>
<dt class="col-5 text-muted small">Warranty</dt>
<dd class="col-7">{{ asset.warranty_expiry.strftime('%d/%m/%Y') if asset.warranty_expiry else '—' }}</dd>
<dt class="col-5 text-muted small">Price</dt>
<dd class="col-7">{{ '%.2f'|format(asset.purchase_price) if asset.purchase_price else '—' }}</dd>
<dt class="col-5 text-muted small">Supplier</dt>
<dd class="col-7">{{ asset.supplier or '—' }}</dd>
<dt class="col-5 text-muted small">PO #</dt>
<dd class="col-7">{{ asset.po_number or '—' }}</dd>
<dt class="col-5 text-muted small">Location</dt>
<dd class="col-7">{{ asset.location or '—' }}</dd>
</dl>
{% if asset.notes %}
<hr class="my-2">
<p class="small text-muted mb-0">{{ asset.notes }}</p>
{% endif %}
</div>
</div>
</div>
<!-- History + Docs -->
<div class="col-md-8">
<!-- Assignment history -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history me-2 text-primary"></i>Assignment History</span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Windows ID</th>
<th>From</th>
<th>Until</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in history %}
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
<td>{{ a.user.display_name }}</td>
<td><code>{{ a.user.windows_id }}</code></td>
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '—' }}</td>
<td>
{% if a.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Returned</span>
{% endif %}
</td>
<td>
{% if a.is_active %}
<button class="btn btn-sm btn-outline-warning py-0 px-2"
data-bs-toggle="modal" data-bs-target="#returnModal{{ a.id }}">
<i class="bi bi-arrow-return-left"></i> Return
</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-3">No assignment history.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Documents -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-earmark-text me-2 text-primary"></i>Documents</span>
{% if asset.current_user %}
<a href="{{ url_for('paperwork.create', asset_id=asset.id, user_id=asset.current_user.id) }}"
class="btn btn-sm btn-outline-info py-0 px-2">
<i class="bi bi-plus"></i> New
</a>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Title</th><th>Type</th><th>User</th><th>Date</th><th></th></tr>
</thead>
<tbody>
{% for d in docs %}
<tr>
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
<td>{{ d.user.display_name }}</td>
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
<td>
{% if d.pdf_filename %}
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-download"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No documents.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% if asset.asset_type in ('Laptop', 'Desktop') %}
<!-- Compliance card — Laptop / Desktop only -->
<div class="card border-0 shadow-sm mt-3">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-shield-check me-2 text-success"></i>IT Compliance &amp; Inventory</span>
<button class="btn btn-sm btn-outline-primary" type="button"
data-bs-toggle="collapse" data-bs-target="#complianceEdit">
<i class="bi bi-pencil me-1"></i>Edit
</button>
</div>
<!-- Read-only summary -->
<div class="card-body pb-2">
<div class="row g-2">
<div class="col-md-4 col-6">
<div class="small text-muted">Inventory #</div>
<div class="fw-semibold">{{ asset.inventory_number or '—' }}</div>
</div>
<div class="col-md-4 col-6">
<div class="small text-muted">AD Device Name</div>
<div class="fw-semibold"><code>{{ asset.ad_device_name or '—' }}</code></div>
</div>
<div class="col-md-4 col-12">
<div class="small text-muted">Location Note</div>
<div class="fw-semibold">{{ asset.location_note or '—' }}</div>
</div>
<div class="col-md-4 col-4 mt-2">
{% if asset.encryption_checked %}
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Encrypted</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-lock me-1"></i>Not Encrypted</span>
{% endif %}
{% if asset.encryption_checked_by %}
<div class="small text-muted mt-1">
by <strong>{{ asset.encryption_checked_by.username }}</strong>
{% if asset.encryption_checked_at %}
&mdash; {{ asset.encryption_checked_at.strftime('%d/%m/%Y %H:%M') }}
{% endif %}
</div>
{% endif %}
</div>
<div class="col-md-4 col-4 mt-2">
{% if asset.backup_checked %}
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Backup OK</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="bi bi-cloud me-1"></i>No Backup</span>
{% endif %}
{% if asset.backup_checked_by %}
<div class="small text-muted mt-1">
by <strong>{{ asset.backup_checked_by.username }}</strong>
{% if asset.backup_checked_at %}
&mdash; {{ asset.backup_checked_at.strftime('%d/%m/%Y %H:%M') }}
{% endif %}
</div>
{% endif %}
</div>
<div class="col-md-4 col-4 mt-2">
{% if asset.hr_notified %}
<span class="badge bg-success"><i class="bi bi-person-check me-1"></i>HR Notified</span>
{% else %}
<span class="badge bg-secondary"><i class="bi bi-person me-1"></i>HR Pending</span>
{% endif %}
{% if asset.hr_notified_by %}
<div class="small text-muted mt-1">
by <strong>{{ asset.hr_notified_by.username }}</strong>
{% if asset.hr_notified_at %}
&mdash; {{ asset.hr_notified_at.strftime('%d/%m/%Y %H:%M') }}
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Collapsible edit form -->
<div class="collapse" id="complianceEdit">
<div class="card-body border-top pt-3">
<form method="POST" action="{{ url_for('assets.update_compliance', asset_id=asset.id) }}">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small fw-semibold">Inventory Number</label>
<input type="text" name="inventory_number" class="form-control form-control-sm"
value="{{ asset.inventory_number or '' }}" placeholder="INV-0001">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">AD Device Name</label>
<input type="text" name="ad_device_name" class="form-control form-control-sm"
value="{{ asset.ad_device_name or '' }}" placeholder="DESKTOP-AB1234">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Current User in AD</label>
{% if asset.current_user %}
<div class="form-control form-control-sm bg-light text-muted">
{{ asset.current_user.display_name }} ({{ asset.current_user.windows_id }})
</div>
{% else %}
<div class="form-control form-control-sm bg-light text-muted">Not assigned</div>
{% endif %}
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Location Note</label>
<textarea name="location_note" class="form-control form-control-sm" rows="2"
placeholder="e.g. Building A, Room 102, Desk 5">{{ asset.location_note or '' }}</textarea>
</div>
<div class="col-12">
<div class="d-flex gap-4 flex-wrap mt-1">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="encryption_checked"
id="chkEncrypt" value="1"
{% if asset.encryption_checked %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="chkEncrypt">
<i class="bi bi-lock-fill me-1 text-success"></i>Encryption Verified
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="backup_checked"
id="chkBackup" value="1"
{% if asset.backup_checked %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="chkBackup">
<i class="bi bi-cloud-check me-1 text-primary"></i>Backup Configured
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="hr_notified"
id="chkHR" value="1"
{% if asset.hr_notified %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="chkHR">
<i class="bi bi-person-check me-1 text-warning"></i>HR Send / Notified
</label>
</div>
</div>
</div>
<div class="col-12">
<label class="form-label small fw-semibold" for="compliance_notes">
<i class="bi bi-chat-left-text me-1 text-secondary"></i>Note
<span class="text-muted fw-normal">(reason for check / uncheck — saved with each change)</span>
</label>
<textarea name="compliance_notes" id="compliance_notes"
class="form-control form-control-sm" rows="2"
placeholder="e.g. BitLocker verified on site visit, backup re-enabled after restore…"></textarea>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-sm btn-success">
<i class="bi bi-check2 me-1"></i>Save Changes
</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-bs-toggle="collapse" data-bs-target="#complianceEdit">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Compliance change history -->
{% if compliance_log %}
<div class="card border-0 shadow-sm mt-2">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<!-- Compliance per-check history -->
{% if check_history %}
<div class="card border-0 shadow-sm mt-2">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-shield-exclamation me-2 text-secondary"></i>Compliance Check History</span>
<button class="btn btn-sm btn-outline-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#checkHistory">
Show / Hide
</button>
</div>
<div class="collapse" id="checkHistory">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light">
<tr>
<th style="width:160px">Date &amp; Time</th>
<th style="width:140px">Check</th>
<th style="width:90px">Result</th>
<th style="width:130px">Performed by</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{% for entry in check_history %}
<tr>
<td class="text-nowrap">{{ entry.performed_at.strftime('%d/%m/%Y %H:%M') }}</td>
<td>{{ entry.check_type_label }}</td>
<td>
{% if entry.checked %}
<span class="badge bg-success">Verified</span>
{% else %}
<span class="badge bg-danger">Cleared</span>
{% endif %}
</td>
<td>
{% if entry.performed_by %}
<span class="fw-semibold">{{ entry.performed_by.username }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>{{ entry.notes or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endif %}{# end asset_type in Laptop/Desktop #}
<!-- Return modals -->
{% for a in history %}{% if a.is_active %}
<div class="modal fade" id="returnModal{{ a.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Return Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('assignments.return_asset', assignment_id=a.id) }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Return Date</label>
<input type="date" name="returned_date" class="form-control"
value="{{ today_date }}" required>
</div>
<div class="mb-3">
<label class="form-label">Notes (optional)</label>
<textarea name="return_notes" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">Confirm Return</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}{% endfor %}
{% endblock %}
@@ -0,0 +1,156 @@
{% extends 'base.html' %}
{% block title %}{{ 'Edit Asset' if asset else 'New Asset' }} IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('assets.index') }}">Assets</a></li>
<li class="breadcrumb-item active">{{ 'Edit' if asset else 'New Asset' }}</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-laptop me-2"></i>{{ 'Edit Asset' if asset else 'Add Asset' }}</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:800px;">
<div class="card-body">
{% if not asset and prefill and prefill.service_tag %}
<div class="alert alert-info py-2 mb-3 small">
<i class="bi bi-cloud-check me-1"></i>
Pre-filled from Dell service tag <strong>{{ prefill.service_tag }}</strong>. Review the details below before saving.
</div>
{% endif %}
<form method="POST" action="{{ url_for('assets.edit', asset_id=asset.id) if asset else url_for('assets.create') }}">
<h6 class="text-uppercase text-muted mb-3 small">Identifiers</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Service Tag / Serial Number <span class="text-danger">*</span></label>
<input type="text" name="serial_number" class="form-control"
value="{{ asset.service_tag or asset.serial_number if asset else (prefill.service_tag or prefill.serial_number if prefill else '') }}"
placeholder="e.g. ABC1234" required>
<div class="form-text">Used as both the service tag and serial number.</div>
</div>
<div class="col-md-6">
<label class="form-label">Asset Tag</label>
<input type="text" name="asset_tag" class="form-control"
value="{{ asset.asset_tag or '' if asset else '' }}"
placeholder="Internal barcode/tag">
</div>
</div>
<h6 class="text-uppercase text-muted mb-3 small">Classification</h6>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Asset Type <span class="text-danger">*</span></label>
<select name="asset_type" class="form-select" required>
{% for t in asset_types %}
<option value="{{ t }}"
{% if asset and asset.asset_type == t %}selected
{% elif not asset and prefill and prefill.asset_type == t %}selected
{% elif not asset and not prefill and t == 'Laptop' %}selected
{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Brand</label>
<input type="text" name="brand" class="form-control"
value="{{ asset.brand or '' if asset else (prefill.brand if prefill else '') }}"
placeholder="e.g. Dell, Lenovo, HP">
</div>
<div class="col-md-4">
<label class="form-label">Model</label>
<input type="text" name="model" class="form-control"
value="{{ asset.model or '' if asset else (prefill.model if prefill else '') }}"
placeholder="e.g. Latitude 5540">
</div>
</div>
<h6 class="text-uppercase text-muted mb-3 small">Technical Specs</h6>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Processor</label>
<input type="text" name="processor" class="form-control"
value="{{ asset.processor or '' if asset else '' }}"
placeholder="e.g. Intel Core i5-1345U">
</div>
<div class="col-md-3">
<label class="form-label">RAM (GB)</label>
<input type="number" name="ram_gb" class="form-control" min="0"
value="{{ asset.ram_gb or '' if asset else '' }}">
</div>
<div class="col-md-3">
<label class="form-label">Storage (GB)</label>
<input type="number" name="storage_gb" class="form-control" min="0"
value="{{ asset.storage_gb or '' if asset else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Operating System</label>
<input type="text" name="operating_system" class="form-control"
value="{{ asset.operating_system or '' if asset else (prefill.operating_system if prefill else 'Windows 11 Pro') }}"
placeholder="e.g. Windows 11 Pro">
</div>
<div class="col-md-4">
<label class="form-label">MAC Address</label>
<input type="text" name="mac_address" class="form-control"
value="{{ asset.mac_address or '' if asset else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Location</label>
<input type="text" name="location" class="form-control"
value="{{ asset.location or '' if asset else '' }}">
</div>
</div>
<h6 class="text-uppercase text-muted mb-3 small">Procurement</h6>
<div class="row g-3 mb-3">
<div class="col-md-3">
<label class="form-label">Purchase Date</label>
<input type="date" name="purchase_date" class="form-control"
value="{{ asset.purchase_date.isoformat() if asset and asset.purchase_date else (prefill.purchase_date if prefill else '') }}">
</div>
<div class="col-md-3">
<label class="form-label">Warranty Expiry</label>
<input type="date" name="warranty_expiry" class="form-control"
value="{{ asset.warranty_expiry.isoformat() if asset and asset.warranty_expiry else (prefill.warranty_expiry if prefill else '') }}">
</div>
<div class="col-md-3">
<label class="form-label">Purchase Price</label>
<input type="number" name="purchase_price" class="form-control" step="0.01" min="0"
value="{{ asset.purchase_price or '' if asset else '' }}">
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="status" class="form-select">
{% for val, label in asset_statuses %}
<option value="{{ val }}" {% if asset and asset.status == val %}selected{% elif not asset and val == 'available' %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Supplier</label>
<input type="text" name="supplier" class="form-control"
value="{{ asset.supplier or '' if asset else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">PO Number</label>
<input type="text" name="po_number" class="form-control"
value="{{ asset.po_number or '' if asset else '' }}">
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea name="notes" class="form-control" rows="2">{{ asset.notes or '' if asset else '' }}</textarea>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{ 'Save Changes' if asset else 'Create Asset' }}
</button>
<a href="{{ url_for('assets.detail', asset_id=asset.id) if asset else url_for('assets.index') }}"
class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,300 @@
{% extends 'base.html' %}
{% block title %}Assets IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Assets</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-laptop me-2"></i>Assets</h1>
<a href="{{ url_for('assets.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Add Asset
</a>
</div>
<!-- Dell Service Tag Quick Import -->
<div class="card border-0 shadow-sm mb-4" id="dellLookupCard">
<div class="card-body py-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-semibold text-nowrap">
<i class="bi bi-search me-1 text-primary"></i>Dell Quick Import
</span>
<span class="text-muted small text-nowrap">Enter a service tag to open the asset form pre-filled:</span>
<div class="input-group input-group-sm" style="max-width:220px;">
<input type="text" id="dellTagInput" class="form-control text-uppercase"
placeholder="e.g. ABC1234" maxlength="20"
style="text-transform:uppercase; letter-spacing:.05em;">
<button class="btn btn-outline-primary" id="dellLookupBtn" type="button">
<i class="bi bi-cloud-download me-1"></i>Lookup
</button>
</div>
<div id="dellLookupSpinner" class="spinner-border spinner-border-sm text-primary d-none" role="status">
<span class="visually-hidden">Loading…</span>
</div>
<span class="text-muted small ms-auto">
<i class="bi bi-info-circle me-1"></i>
Full auto-fill available with a
<a href="https://tdm.dell.com" target="_blank" class="text-decoration-none">free Dell TechDirect API key</a>
</span>
</div>
<!-- Result preview (hidden until data arrives) -->
<div id="dellLookupResult" class="mt-3 d-none">
<div class="alert mb-2 py-2 d-flex align-items-start gap-3" id="dellResultBody">
<i class="bi bi-pc-display-horizontal fs-4 flex-shrink-0 mt-1" id="dellResultIcon"></i>
<div class="flex-grow-1">
<div class="fw-semibold mb-1" id="dellResultTitle"></div>
<div class="row row-cols-2 row-cols-md-4 g-1 small" id="dellResultMeta"></div>
</div>
<div class="d-flex flex-column gap-1 flex-shrink-0">
<a id="dellCreateBtn" href="#" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle me-1"></i>Create Asset
</a>
<a id="dellSupportLink" href="#" target="_blank" class="btn btn-sm btn-outline-secondary d-none">
<i class="bi bi-box-arrow-up-right me-1"></i>View on Dell
</a>
</div>
</div>
</div>
<!-- Error area -->
<div id="dellLookupError" class="alert alert-warning mt-2 py-2 d-none small mb-0"></div>
</div>
</div>
<!-- Filters -->
<form method="GET" class="row g-2 mb-3">
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="Search SN, service tag, brand, model…" value="{{ q }}">
</div>
</div>
<div class="col-md-2">
<select name="status" class="form-select form-select-sm">
<option value="">All statuses</option>
{% for val, label in asset_statuses %}
<option value="{{ val }}" {% if status_filter == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="asset_type" class="form-select form-select-sm">
<option value="">All types</option>
{% for t in asset_types %}
<option value="{{ t }}" {% if type_filter == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
<a href="{{ url_for('assets.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Type</th>
<th>Brand / Model</th>
<th>Serial Number</th>
<th>Service Tag</th>
<th>Status</th>
<th>Assigned To</th>
<th>Warranty</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in pagination.items %}
<tr>
<td><span class="badge bg-secondary">{{ a.asset_type }}</span></td>
<td>{{ a.brand or '' }} {{ a.model or '' }}</td>
<td><code>{{ a.serial_number }}</code></td>
<td><code>{{ a.service_tag or '—' }}</code></td>
<td>
<span class="badge badge-{{ a.status }}">{{ a.status | title }}</span>
</td>
<td>
{% if a.current_user %}
<a href="{{ url_for('users.detail', user_id=a.current_user.id) }}">
{{ a.current_user.display_name }}
</a>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{% if a.warranty_expiry %}
<span class="{% if a.warranty_expiry < today %}text-danger{% else %}text-success{% endif %}">
{{ a.warranty_expiry.strftime('%d/%m/%Y') }}
</span>
{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('assets.detail', asset_id=a.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No assets found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('assets.index', page=pagination.prev_num, q=q, status=status_filter, asset_type=type_filter) }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('assets.index', page=p, q=q, status=status_filter, asset_type=type_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('assets.index', page=pagination.next_num, q=q, status=status_filter, asset_type=type_filter) }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_head %}
<script>
// make today available for warranty colour
const today = new Date().toISOString().slice(0, 10);
</script>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const input = document.getElementById('dellTagInput');
const btn = document.getElementById('dellLookupBtn');
const spinner = document.getElementById('dellLookupSpinner');
const result = document.getElementById('dellLookupResult');
const errBox = document.getElementById('dellLookupError');
const title = document.getElementById('dellResultTitle');
const meta = document.getElementById('dellResultMeta');
const createBtn = document.getElementById('dellCreateBtn');
function setLoading(on) {
btn.disabled = on;
spinner.classList.toggle('d-none', !on);
}
function showError(msg) {
errBox.textContent = msg;
errBox.classList.remove('d-none');
result.classList.add('d-none');
}
function doLookup() {
const tag = input.value.trim().toUpperCase();
if (!tag) { input.focus(); return; }
result.classList.add('d-none');
errBox.classList.add('d-none');
setLoading(true);
fetch(`{{ url_for('assets.dell_lookup') }}?tag=${encodeURIComponent(tag)}`)
.then(r => r.json().then(d => ({ ok: r.ok, status: r.status, data: d })))
.then(({ ok, status, data }) => {
setLoading(false);
if (!ok) {
if (status === 409 && data.existing_id) {
errBox.innerHTML = `${data.error} — <a href="/assets/${data.existing_id}">View asset</a>`;
errBox.classList.remove('d-none');
} else {
showError(data.error || 'Lookup failed.');
}
return;
}
// Build preview
const isPartial = data.source === 'partial';
const resultBody = document.getElementById('dellResultBody');
const resultIcon = document.getElementById('dellResultIcon');
const supportLink = document.getElementById('dellSupportLink');
resultBody.className = `alert mb-2 py-2 d-flex align-items-start gap-3 ${isPartial ? 'alert-warning' : 'alert-info'}`;
resultIcon.className = `bi bi-pc-display-horizontal fs-4 flex-shrink-0 mt-1 ${isPartial ? 'text-warning' : 'text-primary'}`;
if (isPartial) {
// Auto-open Dell's warranty page in a new tab so the user can read model + warranty
if (data.support_url) window.open(data.support_url, '_blank', 'noopener');
title.innerHTML = `Dell service tag <strong>${data.service_tag}</strong> &mdash; Dell&rsquo;s page opened in a new tab. Copy model &amp; warranty date into the form below.`;
meta.innerHTML = `
<div><span class="fw-medium">Brand:</span> Dell</div>
<div><span class="fw-medium">OS:</span> ${data.operating_system}</div>
<div><span class="fw-medium">Model:</span> <em class="text-muted">fill from Dell tab &rarr;</em></div>
<div><span class="fw-medium">Warranty:</span> <em class="text-muted">fill from Dell tab &rarr;</em></div>`;
supportLink.href = data.support_url;
supportLink.classList.remove('d-none');
createBtn.textContent = '';
createBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Open Form';
} else {
title.textContent = `Dell ${data.model || data.service_tag}`;
supportLink.href = data.support_url || '#';
supportLink.classList.remove('d-none');
const fields = [
['Type', data.asset_type],
['Service Tag', data.service_tag],
['Serial', data.serial_number || '—'],
['Warranty', data.warranty_expiry || '—'],
['Purchased', data.purchase_date || '—'],
['OS', data.operating_system],
];
meta.innerHTML = fields
.map(([k, v]) => `<div><span class="fw-medium">${k}:</span> ${v || '—'}</div>`)
.join('');
}
// Build "Create Asset" URL with pre-filled params
const params = new URLSearchParams({
service_tag: data.service_tag || '',
serial_number: data.serial_number || '',
brand: data.brand || 'Dell',
model: data.model || '',
asset_type: data.asset_type || '',
operating_system: data.operating_system || '',
warranty_expiry: data.warranty_expiry || '',
purchase_date: data.purchase_date || '',
});
createBtn.href = `{{ url_for('assets.create') }}?${params.toString()}`;
result.classList.remove('d-none');
})
.catch(() => {
setLoading(false);
showError('Network error could not reach the server.');
});
}
btn.addEventListener('click', doLookup);
input.addEventListener('keydown', e => { if (e.key === 'Enter') doLookup(); });
input.addEventListener('input', () => {
input.value = input.value.toUpperCase();
});
})();
</script>
{% endblock %}
@@ -0,0 +1,133 @@
{% extends 'base.html' %}
{% block title %}Assign Asset IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('assignments.index') }}">Assignments</a></li>
<li class="breadcrumb-item active">New Assignment</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-arrow-left-right me-2"></i>Assign Asset to User</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:600px;">
<div class="card-body">
<form method="POST" action="{{ url_for('assignments.create') }}">
<div class="mb-3">
<label class="form-label">User <span class="text-danger">*</span></label>
<input type="hidden" name="user_id" id="userId" value="{{ preselect_user_id or '' }}">
<input type="text" id="userSearch" class="form-control"
placeholder="Search by name or Windows ID…"
value="" autocomplete="off">
<div id="userDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
<div id="userDisplay" class="form-text text-success fw-semibold"></div>
</div>
<div class="mb-3">
<label class="form-label">Asset <span class="text-danger">*</span></label>
<input type="hidden" name="asset_id" id="assetId" value="{{ preselect_asset_id or '' }}">
<input type="text" id="assetSearch" class="form-control"
placeholder="Search by serial number or service tag…"
value="" autocomplete="off">
<div id="assetDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
<div id="assetDisplay" class="form-text text-success fw-semibold"></div>
</div>
<div class="mb-3">
<label class="form-label">Assigned Date</label>
<input type="date" name="assigned_date" class="form-control" id="assignedDate">
</div>
<div class="mb-4">
<label class="form-label">Notes</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Create Assignment
</button>
<a href="{{ url_for('assignments.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Set today's date as default
document.getElementById('assignedDate').value = new Date().toISOString().slice(0,10);
// ── Generic live-search helper ───────────────────────────────────
function liveSearch(inputId, dropdownId, hiddenId, displayId, endpoint, labelField) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);
const hidden = document.getElementById(hiddenId);
const display = document.getElementById(displayId);
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const q = input.value.trim();
if (q.length < 2) { dropdown.style.display = 'none'; return; }
fetch(`${endpoint}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(items => {
dropdown.innerHTML = '';
if (!items.length) { dropdown.style.display = 'none'; return; }
items.forEach(item => {
const a = document.createElement('a');
a.className = 'list-group-item list-group-item-action py-2';
a.textContent = item.text || item[labelField];
// colour disabled assets
if (item.status && item.status !== 'available') {
a.className += ' text-muted';
a.textContent += ` [${item.status}]`;
}
a.addEventListener('click', () => {
hidden.value = item.id;
input.value = item.text || item[labelField];
display.textContent = '✓ Selected: ' + (item.windows_id || item.serial_number || '');
dropdown.style.display = 'none';
});
dropdown.appendChild(a);
});
dropdown.style.display = 'block';
});
}, 250);
});
document.addEventListener('click', e => {
if (!input.contains(e.target)) dropdown.style.display = 'none';
});
}
liveSearch('userSearch', 'userDropdown', 'userId', 'userDisplay',
'{{ url_for("users.search") }}', 'text');
liveSearch('assetSearch', 'assetDropdown', 'assetId', 'assetDisplay',
'{{ url_for("assets.search") }}', 'text');
// Pre-fill labels if IDs were passed via URL
{% if preselect_user_id %}
fetch('{{ url_for("users.search") }}?q={{ preselect_user_id }}')
.then(r => r.json()).then(items => {
if (items.length) {
document.getElementById('userSearch').value = items[0].text;
document.getElementById('userDisplay').textContent = '✓ Pre-selected';
}
});
{% endif %}
{% if preselect_asset_id %}
fetch('{{ url_for("assets.search") }}?q={{ preselect_asset_id }}')
.then(r => r.json()).then(items => {
if (items.length) {
document.getElementById('assetSearch').value = items[0].text;
document.getElementById('assetDisplay').textContent = '✓ Pre-selected';
}
});
{% endif %}
</script>
{% endblock %}
@@ -0,0 +1,144 @@
{% extends 'base.html' %}
{% block title %}Assignments IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Assignments</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-arrow-left-right me-2"></i>Assignments</h1>
<a href="{{ url_for('assignments.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>Assign Asset
</a>
</div>
<form method="GET" class="row g-2 mb-3">
<div class="col-auto">
<div class="form-check form-check-inline mt-1">
<input class="form-check-input" type="checkbox" name="active" value="0" id="chkAll"
{% if not active_only %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="chkAll">Show returned</label>
</div>
</div>
</form>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Windows ID</th>
<th>Asset</th>
<th>Serial Number</th>
<th>Assigned</th>
<th>Returned</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in pagination.items %}
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
<td>
<a href="{{ url_for('users.detail', user_id=a.user.id) }}">{{ a.user.display_name }}</a>
</td>
<td><code>{{ a.user.windows_id }}</code></td>
<td>
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}">
{{ a.asset.brand or '' }} {{ a.asset.model or '' }}
</a>
</td>
<td><code>{{ a.asset.serial_number }}</code></td>
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '—' }}</td>
<td>
{% if a.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Returned</span>
{% endif %}
</td>
<td>
{% if a.is_active %}
<button class="btn btn-sm btn-outline-warning py-0 px-2"
data-bs-toggle="modal" data-bs-target="#returnModal{{ a.id }}">
<i class="bi bi-arrow-return-left"></i>
</button>
{% endif %}
<a href="{{ url_for('paperwork.create', assignment_id=a.id, user_id=a.user.id, asset_id=a.asset.id) }}"
class="btn btn-sm btn-outline-info py-0 px-2" title="Create document">
<i class="bi bi-file-earmark-plus"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No assignments found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('assignments.index', page=pagination.prev_num, active='0' if not active_only else '1') }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('assignments.index', page=p, active='0' if not active_only else '1') }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('assignments.index', page=pagination.next_num, active='0' if not active_only else '1') }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
<!-- Return modals -->
{% for a in pagination.items %}{% if a.is_active %}
<div class="modal fade" id="returnModal{{ a.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Return Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('assignments.return_asset', assignment_id=a.id) }}">
<div class="modal-body">
<p>Returning <strong>{{ a.asset.serial_number }}</strong> from
<strong>{{ a.user.display_name }}</strong>.</p>
<div class="mb-3">
<label class="form-label">Return Date</label>
<input type="date" name="returned_date" class="form-control" required>
</div>
<div class="mb-0">
<label class="form-label">Notes</label>
<textarea name="return_notes" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">Confirm Return</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}{% endfor %}
{% endblock %}
@@ -0,0 +1,99 @@
{% extends 'base.html' %}
{% block title %}Audit Log IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Audit Log</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-shield-check me-2"></i>Audit Log</h1>
</div>
<form method="GET" class="row g-2 mb-3">
<div class="col-md-2">
<select name="table" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All tables</option>
{% for t in tables %}
<option value="{{ t }}" {% if table_filter == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select name="action" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if action_filter == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<a href="{{ url_for('audit.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date/Time</th>
<th>Performed By</th>
<th>Action</th>
<th>Table</th>
<th>Record</th>
<th>Description</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{% for e in pagination.items %}
<tr>
<td class="text-nowrap">{{ e.performed_at.strftime('%d/%m/%Y %H:%M') if e.performed_at else '—' }}</td>
<td>{{ e.performed_by.username if e.performed_by else '<system>' }}</td>
<td>
{% set colours = {'create':'success','update':'primary','delete':'danger','mask':'purple','assign':'info','return':'warning','import':'secondary'} %}
<span class="badge bg-{{ colours.get(e.action, 'secondary') }}">{{ e.action }}</span>
</td>
<td><code>{{ e.table_name }}</code></td>
<td>{{ e.record_id or '—' }}</td>
<td>{{ e.description or '—' }}</td>
<td><small class="text-muted">{{ e.ip_address or '—' }}</small></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No audit entries found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit.index', page=pagination.prev_num, table=table_filter, action=action_filter) }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('audit.index', page=p, table=table_filter, action=action_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit.index', page=pagination.next_num, table=table_filter, action=action_filter) }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login IT Asset Management</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { background: #1a3a5c; min-height: 100vh; display:flex; align-items:center; justify-content:center; }
.login-card { width: 380px; border-radius: .8rem; border: none;
box-shadow: 0 8px 32px rgba(0,0,0,.3); }
.login-brand { font-size: 1.1rem; font-weight: 700; color: #1a3a5c; }
</style>
</head>
<body>
<div class="card login-card p-4">
<div class="text-center mb-4">
<i class="bi bi-hdd-rack-fill text-primary" style="font-size:2.5rem;"></i>
<div class="login-brand mt-2">IT Asset Management</div>
<small class="text-muted">Sign in to continue</small>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} py-2">{{ msg }}</div>
{% endfor %}{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-person"></i></span>
<input type="text" name="username" class="form-control" required autofocus>
</div>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" name="password" class="form-control" required>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}IT Asset Management{% endblock %}</title>
<!-- Bootstrap 5 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
:root {
--sidebar-bg: #1a3a5c;
--sidebar-text: #cce0f5;
--sidebar-active: #2e6da4;
--accent: #2e86de;
}
body { background: #f0f4f8; font-size: .92rem; }
/* Sidebar */
#sidebar {
min-height: 100vh;
width: 240px;
background: var(--sidebar-bg);
position: fixed;
top: 0; left: 0;
display: flex; flex-direction: column;
z-index: 1000;
transition: width .2s;
}
#sidebar .brand {
padding: 1.1rem 1.2rem;
font-size: 1.05rem;
font-weight: 700;
color: #fff;
border-bottom: 1px solid rgba(255,255,255,.12);
letter-spacing: .02em;
}
#sidebar .brand small { font-weight: 400; font-size: .7rem; color: var(--sidebar-text); display:block; }
#sidebar .nav-section {
font-size: .68rem;
text-transform: uppercase;
letter-spacing: .08em;
color: rgba(255,255,255,.4);
padding: .75rem 1.2rem .25rem;
}
#sidebar .nav-link {
color: var(--sidebar-text);
padding: .45rem 1.2rem;
border-radius: 0;
display: flex; align-items: center; gap: .6rem;
font-size: .88rem;
}
#sidebar .nav-link:hover,
#sidebar .nav-link.active {
background: var(--sidebar-active);
color: #fff;
}
#sidebar .nav-link i { font-size: 1rem; width: 1.2rem; text-align: center; }
#sidebar .sidebar-footer {
margin-top: auto;
padding: .8rem 1.2rem;
border-top: 1px solid rgba(255,255,255,.12);
font-size: .8rem;
color: var(--sidebar-text);
}
/* Main content */
#main-wrapper { margin-left: 240px; min-height: 100vh; display: flex; flex-direction: column; }
#topbar {
background: #fff;
border-bottom: 1px solid #dde3ea;
padding: .55rem 1.5rem;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 900;
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
#topbar .breadcrumb { margin: 0; background: none; padding: 0; font-size: .85rem; }
#page-content { flex: 1; padding: 1.5rem; }
/* Cards */
.stat-card { border: none; border-radius: .6rem; box-shadow: 0 2px 8px rgba(0,0,0,.07); }
.stat-card .card-body { padding: 1.1rem 1.3rem; }
.stat-card .stat-icon { font-size: 2rem; opacity: .85; }
.stat-card .stat-value { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-card .stat-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .8; }
/* Badges */
.badge-available { background:#198754 !important; }
.badge-assigned { background:#0d6efd !important; }
.badge-maintenance{ background:#ffc107 !important; color:#000 !important; }
.badge-retired { background:#6c757d !important; }
.badge-lost { background:#dc3545 !important; }
.badge-masked { background:#6f42c1 !important; }
/* Tables */
.table-hover tbody tr:hover { background:#f5f8ff; }
/* Masked row */
tr.masked-row { opacity: .65; font-style: italic; }
/* Search bar */
.search-bar { max-width: 360px; }
/* Page header */
.page-header h1 { font-size: 1.35rem; font-weight: 700; color: #1a3a5c; margin: 0; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- ===== SIDEBAR ===== -->
<nav id="sidebar">
<div class="brand">
<i class="bi bi-hdd-rack-fill me-2"></i>IT Assets
<small>Hardware Management</small>
</div>
<div class="nav-section">Main</div>
<a href="{{ url_for('dashboard.index') }}"
class="nav-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
<div class="nav-section">People</div>
<a href="{{ url_for('users.index') }}"
class="nav-link {% if request.blueprint == 'users' %}active{% endif %}">
<i class="bi bi-people-fill"></i> Users
</a>
<a href="{{ url_for('users.import_page') }}"
class="nav-link {% if request.endpoint == 'users.import_page' %}active{% endif %}">
<i class="bi bi-cloud-download"></i> Import Users
</a>
<div class="nav-section">Hardware</div>
<a href="{{ url_for('assets.index') }}"
class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}">
<i class="bi bi-laptop"></i> Assets
</a>
<a href="{{ url_for('assets.create') }}"
class="nav-link {% if request.endpoint == 'assets.create' %}active{% endif %}">
<i class="bi bi-plus-circle"></i> Add Asset
</a>
<div class="nav-section">Assignments</div>
<a href="{{ url_for('assignments.index') }}"
class="nav-link {% if request.blueprint == 'assignments' %}active{% endif %}">
<i class="bi bi-arrow-left-right"></i> Assignments
</a>
<a href="{{ url_for('assignments.create') }}"
class="nav-link {% if request.endpoint == 'assignments.create' %}active{% endif %}">
<i class="bi bi-plus-circle"></i> Assign Asset
</a>
<div class="nav-section">Documents</div>
<a href="{{ url_for('paperwork.index') }}"
class="nav-link {% if request.blueprint == 'paperwork' %}active{% endif %}">
<i class="bi bi-file-earmark-text"></i> Paperwork
</a>
<a href="{{ url_for('paperwork.create') }}"
class="nav-link {% if request.endpoint == 'paperwork.create' %}active{% endif %}">
<i class="bi bi-file-earmark-plus"></i> New Document
</a>
<a href="{{ url_for('doc_templates.index') }}"
class="nav-link {% if request.blueprint == 'doc_templates' %}active{% endif %}">
<i class="bi bi-file-earmark-word"></i> Templates
</a>
<div class="nav-section">System</div>
<a href="{{ url_for('audit.index') }}"
class="nav-link {% if request.blueprint == 'audit' %}active{% endif %}">
<i class="bi bi-shield-check"></i> Audit Log
</a>
<a href="{{ url_for('settings.index') }}"
class="nav-link {% if request.blueprint == 'settings' %}active{% endif %}">
<i class="bi bi-gear"></i> Settings
</a>
<div class="sidebar-footer">
<i class="bi bi-person-circle me-1"></i>
<strong>{{ current_user.username }}</strong>
<a href="{{ url_for('auth.logout') }}" class="ms-2 text-warning text-decoration-none">
<i class="bi bi-box-arrow-right"></i>
</a>
</div>
</nav>
<!-- ===== MAIN WRAPPER ===== -->
<div id="main-wrapper">
<!-- Topbar -->
<div id="topbar">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">{% block breadcrumb %}<li class="breadcrumb-item active">Home</li>{% endblock %}</ol>
</nav>
<div class="d-flex align-items-center gap-3">
<span class="text-muted" style="font-size:.8rem;">
<i class="bi bi-calendar3"></i>
{{ now.strftime('%d %b %Y') if now else '' }}
</span>
</div>
</div>
<!-- Flash messages -->
<div id="page-content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} alert-dismissible fade show mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
@@ -0,0 +1,169 @@
{% extends 'base.html' %}
{% block title %}Dashboard IT Asset Management{% endblock %}
{% block breadcrumb %}<li class="breadcrumb-item active">Dashboard</li>{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-speedometer2 me-2"></i>Dashboard</h1>
</div>
<!-- ── Stat Cards ──────────────────────────────────────────────── -->
<div class="row g-3 mb-4">
<!-- Users -->
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#1a3a5c;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-people-fill stat-icon"></i>
<div>
<div class="stat-value">{{ stats.active_users }}</div>
<div class="stat-label">Active Users</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#6f42c1;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-eye-slash-fill stat-icon"></i>
<div>
<div class="stat-value">{{ stats.masked_users }}</div>
<div class="stat-label">Masked Records</div>
</div>
</div>
</div>
</div>
<!-- Assets -->
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#198754;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-laptop stat-icon"></i>
<div>
<div class="stat-value">{{ stats.available_assets }}</div>
<div class="stat-label">Available Assets</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#0d6efd;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-arrow-left-right stat-icon"></i>
<div>
<div class="stat-value">{{ stats.assigned_assets }}</div>
<div class="stat-label">Assigned Assets</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#ffc107; color:#000 !important;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-tools stat-icon"></i>
<div>
<div class="stat-value">{{ stats.maintenance_assets }}</div>
<div class="stat-label">In Maintenance</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#6c757d;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-hdd-fill stat-icon"></i>
<div>
<div class="stat-value">{{ stats.total_assets }}</div>
<div class="stat-label">Total Assets</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#0dcaf0; color:#000 !important;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-file-earmark-text stat-icon"></i>
<div>
<div class="stat-value">{{ stats.total_paperwork }}</div>
<div class="stat-label">Documents</div>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card stat-card text-white" style="background:#fd7e14;">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-person-badge stat-icon"></i>
<div>
<div class="stat-value">{{ stats.active_assignments }}</div>
<div class="stat-label">Open Assignments</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Quick Actions ──────────────────────────────────────────── -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body py-2">
<span class="text-muted me-3" style="font-size:.8rem;">QUICK ACTIONS</span>
<a href="{{ url_for('assets.create') }}" class="btn btn-sm btn-outline-primary me-2">
<i class="bi bi-plus-circle me-1"></i>Add Asset
</a>
<a href="{{ url_for('assignments.create') }}" class="btn btn-sm btn-outline-success me-2">
<i class="bi bi-arrow-left-right me-1"></i>Assign Asset
</a>
<a href="{{ url_for('paperwork.create') }}" class="btn btn-sm btn-outline-info me-2">
<i class="bi bi-file-earmark-plus me-1"></i>New Document
</a>
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-cloud-download me-1"></i>Import Users
</a>
</div>
</div>
</div>
</div>
<!-- ── Recent Assignments ─────────────────────────────────────── -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-clock-history me-2 text-primary"></i>Current Assignments (latest 10)
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Windows ID</th>
<th>Asset</th>
<th>Serial / Service Tag</th>
<th>Since</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in recent_assignments %}
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
<td>{{ a.user.display_name }}</td>
<td><code>{{ a.user.windows_id }}</code></td>
<td>{{ a.asset.brand or '' }} {{ a.asset.model or '' }}</td>
<td>
<code>{{ a.asset.serial_number }}</code>
{% if a.asset.service_tag %}<br><small class="text-muted">{{ a.asset.service_tag }}</small>{% endif %}
</td>
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
<td>
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}"
class="btn btn-xs btn-outline-secondary btn-sm py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-3">No active assignments.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,155 @@
{% extends 'base.html' %}
{% block title %}{{ tpl.name }} Templates{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
<li class="breadcrumb-item active">{{ tpl.name }}</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-file-earmark-word me-2"></i>{{ tpl.name }}</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('doc_templates.download', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-download me-1"></i>Download .docx
</a>
<a href="{{ url_for('doc_templates.edit', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>Edit details
</a>
<form method="POST" action="{{ url_for('doc_templates.rescan', tpl_id=tpl.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Re-scan variables
</button>
</form>
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="bi bi-trash me-1"></i>Delete
</button>
</div>
</div>
<div class="row g-4">
<!-- Metadata -->
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold small text-uppercase text-muted">Details</div>
<div class="card-body">
<dl class="row small mb-0">
<dt class="col-5">Category</dt>
<dd class="col-7">
{% if tpl.category %}
<span class="badge bg-secondary">{{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }}</span>
{% else %}<span class="text-muted"></span>{% endif %}
</dd>
<dt class="col-5">File</dt>
<dd class="col-7"><code>{{ tpl.filename }}</code></dd>
<dt class="col-5">Uploaded</dt>
<dd class="col-7">{{ tpl.created_at.strftime('%d %b %Y %H:%M') }}</dd>
<dt class="col-5">By</dt>
<dd class="col-7">{{ tpl.created_by.username if tpl.created_by else '—' }}</dd>
<dt class="col-5">Docs generated</dt>
<dd class="col-7">{{ tpl.paperwork_docs.count() }}</dd>
</dl>
{% if tpl.description %}
<hr class="my-2">
<p class="small text-muted mb-0">{{ tpl.description }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Variables -->
<div class="col-md-8">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold small text-uppercase text-muted d-flex justify-content-between align-items-center">
Detected Variables
<span class="badge bg-primary rounded-pill">{{ tpl.variables | length }}</span>
</div>
{% set vars = tpl.variables %}
{% if vars %}
<div class="card-body">
<p class="small text-muted mb-3">
These placeholders were detected in the template file.
They will be filled automatically when generating a document.
<strong class="text-danger">PII variables</strong> (name, email, phone)
are replaced with <code>[MASKED]</code> when a user's record is erased.
</p>
{% set pii = ['user_name','user_email','user_phone'] %}
<div class="row row-cols-2 row-cols-md-3 g-2">
{% for v in vars %}
<div class="col">
<span class="badge {% if v in pii %}bg-danger{% else %}bg-light text-dark border{% endif %} w-100 text-start p-2">
{% if v in pii %}<i class="bi bi-shield-x me-1"></i>{% else %}<i class="bi bi-braces me-1"></i>{% endif %}
{{ v }}
</span>
</div>
{% endfor %}
</div>
<div class="mt-3 small">
<span class="badge bg-danger me-1">PII</span> masked on departure &nbsp;
<span class="badge bg-light text-dark border me-1">other</span> retained
</div>
</div>
{% else %}
<div class="card-body text-muted small">
No variables detected. Make sure your template uses <code>&#123;&#123; variable_name &#125;&#125;</code> syntax
and click <strong>Re-scan</strong>.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Documents generated from this template -->
{% set recent_docs = tpl.paperwork_docs.order_by('created_at desc').limit(10).all() %}
{% if recent_docs %}
<div class="card border-0 shadow-sm mt-4">
<div class="card-header bg-white fw-semibold small text-uppercase text-muted">Recently Generated Documents</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light">
<tr><th>Title</th><th>User</th><th>Created</th><th>Signed</th><th></th></tr>
</thead>
<tbody>
{% for doc in recent_docs %}
<tr>
<td>{{ doc.title }}</td>
<td>{{ doc.user.display_name if doc.user else '—' }}</td>
<td>{{ doc.created_at.strftime('%d/%m/%Y') }}</td>
<td>{% if doc.is_signed %}<i class="bi bi-check-circle text-success"></i>{% else %}<span class="text-muted"></span>{% endif %}</td>
<td><a href="{{ url_for('paperwork.detail', doc_id=doc.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2"><i class="bi bi-eye"></i></a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Delete modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
Delete <strong>{{ tpl.name }}</strong>?
{% if tpl.paperwork_docs.count() > 0 %}
<div class="alert alert-danger mt-2 small">
Cannot delete — {{ tpl.paperwork_docs.count() }} document(s) were generated from this template.
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
{% if tpl.paperwork_docs.count() == 0 %}
<form method="POST" action="{{ url_for('doc_templates.delete', tpl_id=tpl.id) }}">
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% block title %}Edit {{ tpl.name }} Templates{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}">{{ tpl.name }}</a></li>
<li class="breadcrumb-item active">Edit</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-pencil me-2"></i>Edit Template</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:600px;">
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" value="{{ tpl.name }}" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Category</label>
<select name="category" class="form-select">
<option value="">— no category —</option>
{% for val, label in doc_types %}
<option value="{{ val }}" {% if tpl.category == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Description</label>
<textarea name="description" class="form-control" rows="3">{{ tpl.description or '' }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% block title %}Document Templates IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Document Templates</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-file-earmark-word me-2"></i>Document Templates</h1>
<a href="{{ url_for('doc_templates.upload') }}" class="btn btn-primary btn-sm">
<i class="bi bi-upload me-1"></i>Upload Template
</a>
</div>
<div class="alert alert-info small mb-4">
<i class="bi bi-info-circle me-1"></i>
Upload <strong>.docx</strong> Word files with <code>&#123;&#123; variable_name &#125;&#125;</code> placeholders.
When creating paperwork, the system fills them automatically from user / asset data.
All generated documents can be regenerated with masked PII if a user leaves the company.
</div>
{% if templates %}
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3">
{% for tpl in templates %}
<div class="col">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between mb-2">
<h6 class="mb-0 fw-semibold">
<i class="bi bi-file-earmark-word text-primary me-1"></i>{{ tpl.name }}
</h6>
{% if tpl.category %}
<span class="badge bg-secondary ms-2">{{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }}</span>
{% endif %}
</div>
{% if tpl.description %}
<p class="text-muted small mb-2">{{ tpl.description }}</p>
{% endif %}
<div class="small text-muted mb-3">
<i class="bi bi-braces me-1"></i>
{% set vars = tpl.variables %}
{% if vars %}
{{ vars | length }} variable(s):
{% for v in vars[:5] %}<code class="me-1">{{ v }}</code>{% endfor %}
{% if vars | length > 5 %}<em>+{{ vars | length - 5 }} more</em>{% endif %}
{% else %}
No variables detected
{% endif %}
</div>
<div class="d-flex gap-1 align-items-center">
<a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="{{ url_for('doc_templates.download', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-download me-1"></i>Download
</a>
<span class="ms-auto text-muted small">
{{ tpl.paperwork_docs.count() }} doc(s) generated
</span>
</div>
</div>
<div class="card-footer bg-white text-muted small">
Uploaded {{ tpl.created_at.strftime('%d %b %Y') }}
{% if tpl.created_by %} by {{ tpl.created_by.username }}{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-file-earmark-word display-4 d-block mb-3"></i>
No templates yet. <a href="{{ url_for('doc_templates.upload') }}">Upload your first template</a>.
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,96 @@
{% extends 'base.html' %}
{% block title %}Upload Template IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
<li class="breadcrumb-item active">Upload</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-upload me-2"></i>Upload Document Template</h1>
</div>
<div class="row">
<div class="col-lg-7">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" placeholder="e.g. Equipment Handover Receipt" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Category</label>
<select name="category" class="form-select">
<option value="">— no category —</option>
{% for val, label in doc_types %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
<div class="form-text">Used to pre-select this template when creating paperwork of that type.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Optional notes about this template…"></textarea>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">.docx Template File <span class="text-danger">*</span></label>
<input type="file" name="docx_file" class="form-control" accept=".docx" required>
<div class="form-text">Word document (.docx) with <code>&#123;&#123; variable_name &#125;&#125;</code> placeholders.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload me-1"></i>Upload
</button>
<a href="{{ url_for('doc_templates.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card border-0 shadow-sm">
<div class="card-header fw-semibold small text-uppercase text-muted bg-white">
Available Variables
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light"><tr><th>Variable</th><th>Value</th></tr></thead>
<tbody>
{% set var_docs = [
('user_name','Full name (masked if user left)'),
('user_email','Email address'),
('user_phone','Phone number'),
('user_department','Department (retained after masking)'),
('user_job_title','Job title'),
('user_location','Office location'),
('user_windows_id','Windows / AD ID — never masked'),
('asset_serial','Asset serial number'),
('asset_service_tag','Dell / vendor service tag'),
('asset_brand','Brand (e.g. Dell)'),
('asset_model','Model name'),
('asset_type','Type (Laptop / Desktop / …)'),
('asset_os','Operating system'),
('asset_warranty_expiry','Warranty expiry date'),
('assignment_date','Date asset was assigned'),
('return_date','Date asset was returned'),
('document_date','Today\'s date'),
('document_number','Document / paperwork ID'),
('company_name','Your company name'),
('company_address','Your company address'),
] %}
{% for var, desc in var_docs %}
<tr>
<td><code>&#123;&#123; {{ var }} &#125;&#125;</code></td>
<td class="text-muted">{{ desc }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,238 @@
{% extends 'base.html' %}
{% block title %}{{ doc.title }} IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('paperwork.index') }}">Paperwork</a></li>
<li class="breadcrumb-item active">{{ doc.title }}</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-file-earmark-text me-2"></i>{{ doc.title }}</h1>
<div class="d-flex gap-2 flex-wrap">
{% if doc.pdf_filename %}
<a href="{{ url_for('paperwork.download', doc_id=doc.id) }}" class="btn btn-sm btn-primary">
<i class="bi bi-filetype-pdf me-1"></i>Download PDF
</a>
{% endif %}
{% if doc.docx_filename %}
<a href="{{ url_for('paperwork.download_docx', doc_id=doc.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-word me-1"></i>Download .docx
</a>
{% endif %}
<form method="POST" action="{{ url_for('paperwork.regenerate', doc_id=doc.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Regenerate
</button>
</form>
</div>
</div>
<div class="row g-3">
<!-- Left column: meta -->
<div class="col-md-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-info-circle me-2 text-primary"></i>Document Info
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Type</dt>
<dd class="col-7"><span class="badge bg-info text-dark">{{ doc.doc_type_label }}</span></dd>
<dt class="col-5 text-muted small">User</dt>
<dd class="col-7">
<a href="{{ url_for('users.detail', user_id=doc.user.id) }}">{{ doc.user.display_name }}</a>
<br><code class="small">WID: {{ doc.user.windows_id }}</code>
</dd>
{% if doc.asset %}
<dt class="col-5 text-muted small">Asset</dt>
<dd class="col-7">
<a href="{{ url_for('assets.detail', asset_id=doc.asset.id) }}">
{{ doc.asset.brand or '' }} {{ doc.asset.model or '' }}
</a>
<br><code class="small">{{ doc.asset.serial_number }}</code>
</dd>
{% endif %}
{% if doc.template %}
<dt class="col-5 text-muted small">Template</dt>
<dd class="col-7">
<a href="{{ url_for('doc_templates.detail', template_id=doc.template.id) }}">{{ doc.template.name }}</a>
</dd>
{% endif %}
<dt class="col-5 text-muted small">Created</dt>
<dd class="col-7">{{ doc.created_at.strftime('%d/%m/%Y %H:%M') if doc.created_at else '—' }}</dd>
<dt class="col-5 text-muted small">Created by</dt>
<dd class="col-7">{{ doc.created_by.username if doc.created_by else '—' }}</dd>
<dt class="col-5 text-muted small">PDF</dt>
<dd class="col-7">
{% if doc.pdf_filename %}<span class="badge bg-success">Generated</span>
{% else %}<span class="badge bg-secondary">Not generated</span>{% endif %}
</dd>
<dt class="col-5 text-muted small">Word doc</dt>
<dd class="col-7">
{% if doc.docx_filename %}<span class="badge bg-primary">Available</span>
{% else %}<span class="badge bg-secondary">None</span>{% endif %}
</dd>
<dt class="col-5 text-muted small">Signed</dt>
<dd class="col-7">
{% if doc.is_signed %}
<span class="badge bg-success">
<i class="bi bi-pen me-1"></i>{{ doc.signed_by_name }}
</span>
<br><span class="small text-muted">{{ doc.signed_at.strftime('%d/%m/%Y %H:%M') }}</span>
{% else %}
<span class="badge bg-warning text-dark">Unsigned</span>
{% endif %}
</dd>
</dl>
</div>
</div>
<!-- Signature card -->
{% if doc.is_signed %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between">
<span><i class="bi bi-pen me-2 text-success"></i>Signature</span>
<form method="POST" action="{{ url_for('paperwork.unsign', doc_id=doc.id) }}" class="d-inline">
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Remove signature?')">Remove</button>
</form>
</div>
<div class="card-body text-center">
{% if doc.signature_data %}
<img src="{{ doc.signature_data }}" alt="Signature" class="img-fluid border rounded"
style="max-height:80px; background:#fff;">
{% endif %}
<p class="mb-0 mt-2 small text-muted">
Signed by <strong>{{ doc.signed_by_name }}</strong><br>
{{ doc.signed_at.strftime('%d/%m/%Y at %H:%M') }}
</p>
</div>
</div>
{% else %}
<!-- Sign document card -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-pen me-2 text-warning"></i>Sign Document
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('paperwork.sign', doc_id=doc.id) }}">
<div class="mb-2">
<label class="form-label small">Signer's full name <span class="text-danger">*</span></label>
<input type="text" name="signed_by_name" class="form-control form-control-sm"
placeholder="{{ doc.user.display_name }}" required>
</div>
<div class="mb-2">
<label class="form-label small">Draw signature (optional)</label>
<canvas id="sigCanvas" width="260" height="80"
class="border rounded d-block"
style="background:#fff; cursor:crosshair; touch-action:none;"></canvas>
<input type="hidden" name="signature_data" id="sigData">
<div class="d-flex gap-2 mt-1">
<button type="button" class="btn btn-xs btn-outline-secondary btn-sm" id="clearSig">Clear</button>
</div>
</div>
<button type="submit" class="btn btn-sm btn-success w-100" id="signBtn">
<i class="bi bi-pen me-1"></i>Sign Document
</button>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Right column: notes + merge vars -->
<div class="col-md-8">
{% if doc.notes %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-chat-left-text me-2 text-primary"></i>Notes
</div>
<div class="card-body">
<p class="mb-0">{{ doc.notes }}</p>
</div>
</div>
{% endif %}
{% if merge_vars %}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-braces me-2 text-primary"></i>Merge Variables Used</span>
<button class="btn btn-sm btn-outline-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#mergeVarsBody">
Toggle
</button>
</div>
<div id="mergeVarsBody" class="collapse show">
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0 small">
<thead class="table-light"><tr><th>Variable</th><th>Value</th></tr></thead>
<tbody>
{% set PII = ['user_name','user_email','user_phone'] %}
{% for k, v in merge_vars.items()|sort %}
<tr {% if k in PII %}class="table-danger"{% endif %}>
<td><code>{{ '{{' }} {{ k }} {{ '}}' }}</code>
{% if k in PII %}<span class="badge bg-danger ms-1 small">PII</span>{% endif %}
</td>
<td>{{ v or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if not doc.is_signed %}
<script>
(function () {
const canvas = document.getElementById('sigCanvas');
const ctx = canvas.getContext('2d');
let drawing = false;
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
canvas.addEventListener('mousedown', e => { drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
canvas.addEventListener('mousemove', e => { if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
canvas.addEventListener('mouseup', () => { drawing = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
canvas.addEventListener('touchend', () => { drawing = false; });
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
document.getElementById('clearSig').addEventListener('click', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
});
document.querySelector('form[action*="/sign"]').addEventListener('submit', () => {
// Only attach non-empty canvas
const blank = document.createElement('canvas');
blank.width = canvas.width; blank.height = canvas.height;
if (canvas.toDataURL() !== blank.toDataURL()) {
document.getElementById('sigData').value = canvas.toDataURL('image/png');
}
});
})();
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,168 @@
{% extends 'base.html' %}
{% block title %}New Document IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('paperwork.index') }}">Paperwork</a></li>
<li class="breadcrumb-item active">New Document</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-file-earmark-plus me-2"></i>Create Document</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:740px;">
<div class="card-body">
<form method="POST" action="{{ url_for('paperwork.create') }}">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Document Type <span class="text-danger">*</span></label>
<select name="document_type" class="form-select" id="docType">
{% for val, label in doc_types %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Title</label>
<input type="text" name="title" class="form-control"
placeholder="Leave blank to auto-generate">
</div>
</div>
<!-- Word Template selector -->
{% if all_templates %}
<div class="mb-3">
<label class="form-label">
Word Template
<span class="text-muted small">(optional — generates an editable .docx)</span>
</label>
<select name="template_id" id="templateSelect" class="form-select">
<option value="">— No template (PDF only) —</option>
{% for tpl in all_templates %}
<option value="{{ tpl.id }}">{{ tpl.name }}{% if tpl.category %} [{{ tpl.category }}]{% endif %}</option>
{% endfor %}
</select>
<!-- Variable preview loaded via AJAX -->
<div id="tplVarsBox" class="mt-2" style="display:none">
<div class="small text-muted mb-1">Variables auto-filled from this template:</div>
<div id="tplVarsList" class="d-flex flex-wrap gap-1"></div>
</div>
</div>
{% endif %}
<!-- User search -->
<div class="mb-3">
<label class="form-label">User <span class="text-danger">*</span></label>
<input type="hidden" name="user_id" id="userId" value="{{ preselect_user_id or '' }}">
<input type="text" id="userSearch" class="form-control"
placeholder="Type name or Windows ID…" autocomplete="off">
<div id="userDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
<div id="userDisplay" class="form-text text-success fw-semibold"></div>
</div>
<!-- Asset search (optional) -->
<div class="mb-3">
<label class="form-label">Asset <span class="text-muted small">(optional)</span></label>
<input type="hidden" name="asset_id" id="assetId" value="{{ preselect_asset_id or '' }}">
<input type="text" id="assetSearch" class="form-control"
placeholder="Serial number or service tag…" autocomplete="off">
<div id="assetDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
<div id="assetDisplay" class="form-text text-success fw-semibold"></div>
</div>
{% if preselect_assignment_id %}
<input type="hidden" name="assignment_id" value="{{ preselect_assignment_id }}">
{% endif %}
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-file-earmark-check me-1"></i>Generate Document
</button>
<a href="{{ url_for('paperwork.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% if all_templates %}
<div class="mt-3">
<a href="{{ url_for('doc_templates.index') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-word me-1"></i>Manage Templates
</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
function liveSearch(inputId, dropdownId, hiddenId, displayId, endpoint) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);
const hidden = document.getElementById(hiddenId);
const display = document.getElementById(displayId);
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const q = input.value.trim();
if (q.length < 2) { dropdown.style.display = 'none'; return; }
fetch(`${endpoint}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(items => {
dropdown.innerHTML = '';
if (!items.length) { dropdown.style.display = 'none'; return; }
items.forEach(item => {
const a = document.createElement('a');
a.className = 'list-group-item list-group-item-action py-2';
a.textContent = item.text;
a.addEventListener('click', () => {
hidden.value = item.id;
input.value = item.text;
display.textContent = '✓ Selected';
dropdown.style.display = 'none';
});
dropdown.appendChild(a);
});
dropdown.style.display = 'block';
});
}, 250);
});
document.addEventListener('click', e => { if (!input.contains(e.target)) dropdown.style.display = 'none'; });
}
liveSearch('userSearch', 'userDropdown', 'userId', 'userDisplay', '{{ url_for("users.search") }}');
liveSearch('assetSearch', 'assetDropdown', 'assetId', 'assetDisplay', '{{ url_for("assets.search") }}');
// Template variable preview
const tplSelect = document.getElementById('templateSelect');
if (tplSelect) {
const PII_VARS = new Set(['user_name', 'user_email', 'user_phone']);
tplSelect.addEventListener('change', () => {
const id = tplSelect.value;
const box = document.getElementById('tplVarsBox');
const list = document.getElementById('tplVarsList');
if (!id) { box.style.display = 'none'; return; }
fetch(`/doc-templates/${id}/variables.json`)
.then(r => r.json())
.then(data => {
list.innerHTML = '';
(data.variables || []).forEach(v => {
const badge = document.createElement('span');
badge.className = 'badge ' + (PII_VARS.has(v) ? 'bg-danger' : 'bg-secondary');
badge.title = PII_VARS.has(v) ? 'PII — will be masked on user departure' : '';
badge.textContent = '{{ ' + v + ' }}';
list.appendChild(badge);
});
box.style.display = 'block';
})
.catch(() => { box.style.display = 'none'; });
});
}
</script>
{% endblock %}
@@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}Paperwork IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Paperwork</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-file-earmark-text me-2"></i>Paperwork</h1>
<a href="{{ url_for('paperwork.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-plus me-1"></i>New Document
</a>
</div>
<form method="GET" class="row g-2 mb-3">
<div class="col-md-3">
<select name="doc_type" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All document types</option>
{% for val, label in doc_types %}
<option value="{{ val }}" {% if doc_type_filter == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<a href="{{ url_for('paperwork.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Title</th>
<th>Type</th>
<th>User</th>
<th>Asset SN</th>
<th>Created</th>
<th>PDF</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in pagination.items %}
<tr>
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
<td>
<a href="{{ url_for('users.detail', user_id=d.user.id) }}">{{ d.user.display_name }}</a>
</td>
<td>{{ d.asset.serial_number if d.asset else '—' }}</td>
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
<td>
{% if d.pdf_filename %}
<span class="badge bg-success"><i class="bi bi-check"></i> Ready</span>
{% else %}
<span class="badge bg-secondary"></span>
{% endif %}
</td>
<td>
<a href="{{ url_for('paperwork.detail', doc_id=d.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
{% if d.pdf_filename %}
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
class="btn btn-sm btn-outline-primary py-0 px-2">
<i class="bi bi-download"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No documents found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('paperwork.index', page=pagination.prev_num, doc_type=doc_type_filter) }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('paperwork.index', page=p, doc_type=doc_type_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('paperwork.index', page=pagination.next_num, doc_type=doc_type_filter) }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,126 @@
{% extends 'base.html' %}
{% block title %}Settings IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Settings</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-gear me-2"></i>Settings</h1>
</div>
<div class="row g-4">
<!-- Admin users -->
<div class="col-md-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-person-gear me-2 text-primary"></i>Admin Users
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th><th></th></tr>
</thead>
<tbody>
{% for a in admins %}
<tr>
<td><strong>{{ a.username }}</strong></td>
<td>{{ a.full_name or '—' }}</td>
<td>{{ a.email }}</td>
<td><span class="badge bg-secondary">{{ a.role }}</span></td>
<td>{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }}</td>
<td>
{% if a.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>
{% if a.id != current_user.id %}
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
{{ 'Deactivate' if a.is_active else 'Activate' }}
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Add admin form -->
<div class="card-footer bg-white">
<h6 class="fw-semibold mb-3 mt-1">Add Admin User</h6>
<form method="POST" action="{{ url_for('settings.create_admin') }}">
<div class="row g-2">
<div class="col-md-3">
<input type="text" name="username" class="form-control form-control-sm" placeholder="Username" required>
</div>
<div class="col-md-3">
<input type="text" name="full_name" class="form-control form-control-sm" placeholder="Full Name">
</div>
<div class="col-md-3">
<input type="email" name="email" class="form-control form-control-sm" placeholder="Email" required>
</div>
<div class="col-md-2">
<input type="password" name="password" class="form-control form-control-sm" placeholder="Password" required minlength="8">
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-sm btn-primary w-100">Add</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- LDAP config info -->
<div class="col-md-5">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-diagram-3 me-2 text-primary"></i>LDAP / AD Configuration
</div>
<div class="card-body">
<p class="text-muted small mb-3">
LDAP settings are managed via environment variables (see <code>.env</code> file).
Restart the application after changing these values.
</p>
<table class="table table-sm table-bordered mb-0">
<tbody>
<tr><th class="bg-light">LDAP_SERVER</th><td><code>{{ config.LDAP_SERVER or '(not set)' }}</code></td></tr>
<tr><th class="bg-light">LDAP_PORT</th><td>{{ config.LDAP_PORT }}</td></tr>
<tr><th class="bg-light">LDAP_USE_SSL</th><td>{{ config.LDAP_USE_SSL }}</td></tr>
<tr><th class="bg-light">LDAP_BASE_DN</th><td><code>{{ config.LDAP_BASE_DN or '(not set)' }}</code></td></tr>
<tr><th class="bg-light">LDAP_BIND_USER</th><td>{{ config.LDAP_BIND_USER or '(not set)' }}</td></tr>
<tr><th class="bg-light">Windows ID attr</th><td><code>{{ config.LDAP_WINDOWS_ID_ATTR }}</code></td></tr>
</tbody>
</table>
<div class="mt-3">
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-repeat me-1"></i>Go to Import / Sync
</a>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-building me-2 text-primary"></i>Company Info (for PDFs)
</div>
<div class="card-body">
<table class="table table-sm table-bordered mb-0">
<tbody>
<tr><th class="bg-light">COMPANY_NAME</th><td>{{ config.COMPANY_NAME or '(not set)' }}</td></tr>
<tr><th class="bg-light">COMPANY_ADDRESS</th><td>{{ config.COMPANY_ADDRESS or '(not set)' }}</td></tr>
</tbody>
</table>
<p class="small text-muted mt-2 mb-0">Edit these in <code>.env</code> and restart.</p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,211 @@
{% extends 'base.html' %}
{% block title %}{{ user.display_name }} IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('users.index') }}">Users</a></li>
<li class="breadcrumb-item active">{{ user.display_name }}</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1>
<i class="bi bi-person-circle me-2"></i>
{{ user.display_name }}
{% if user.is_masked %}<span class="badge badge-masked fs-6 align-middle ms-2">MASKED</span>{% endif %}
</h1>
<div class="d-flex gap-2">
{% if not user.is_masked %}
<a href="{{ url_for('users.edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('assignments.create', user_id=user.id) }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-plus-circle me-1"></i>Assign Asset
</a>
<a href="{{ url_for('paperwork.create', user_id=user.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-file-earmark-plus me-1"></i>New Document
</a>
<!-- Mask button -->
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#maskModal">
<i class="bi bi-eye-slash me-1"></i>Mask User
</button>
{% endif %}
</div>
</div>
<div class="row g-3">
<!-- Info card -->
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-info-circle me-2 text-primary"></i>User Information
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Windows ID</dt>
<dd class="col-7"><code>{{ user.windows_id }}</code></dd>
<dt class="col-5 text-muted small">Full Name</dt>
<dd class="col-7">{{ user.display_name }}</dd>
<dt class="col-5 text-muted small">Email</dt>
<dd class="col-7" style="word-break:break-all;">{{ user.display_email }}</dd>
<dt class="col-5 text-muted small">Phone</dt>
<dd class="col-7">{{ user.display_phone }}</dd>
<dt class="col-5 text-muted small">Department</dt>
<dd class="col-7">{{ user.department or '—' }}</dd>
<dt class="col-5 text-muted small">Job Title</dt>
<dd class="col-7">{{ user.job_title or '—' }}</dd>
<dt class="col-5 text-muted small">Location</dt>
<dd class="col-7">{{ user.location or '—' }}</dd>
<dt class="col-5 text-muted small">Source</dt>
<dd class="col-7"><span class="badge bg-secondary">{{ user.import_source }}</span></dd>
<dt class="col-5 text-muted small">Status</dt>
<dd class="col-7">
{% if user.is_masked %}
<span class="badge badge-masked">Masked</span>
<br><small class="text-muted">{{ user.masked_at.strftime('%d/%m/%Y') if user.masked_at else '' }}</small>
{% elif user.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-warning text-dark">Inactive</span>
{% endif %}
</dd>
<dt class="col-5 text-muted small">Added</dt>
<dd class="col-7">{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '—' }}</dd>
</dl>
</div>
</div>
</div>
<!-- Assignments -->
<div class="col-md-8">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-arrow-left-right me-2 text-primary"></i>Asset History</span>
{% if not user.is_masked %}
<a href="{{ url_for('assignments.create', user_id=user.id) }}" class="btn btn-xs btn-sm btn-outline-success py-0 px-2">
<i class="bi bi-plus"></i> Assign
</a>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Asset</th>
<th>SN / Service Tag</th>
<th>From</th>
<th>Until</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td>{{ a.asset.brand or '' }} {{ a.asset.model or '' }}</td>
<td>
<code>{{ a.asset.serial_number }}</code>
{% if a.asset.service_tag %}<br><small>{{ a.asset.service_tag }}</small>{% endif %}
</td>
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '<span class="badge bg-primary">current</span>' | safe }}</td>
<td>
{% if a.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Returned</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-3">No assignments.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Documents -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-earmark-text me-2 text-primary"></i>Documents</span>
{% if not user.is_masked %}
<a href="{{ url_for('paperwork.create', user_id=user.id) }}"
class="btn btn-xs btn-sm btn-outline-info py-0 px-2">
<i class="bi bi-plus"></i> New
</a>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Title</th><th>Type</th><th>Asset</th><th>Date</th><th></th></tr>
</thead>
<tbody>
{% for d in docs %}
<tr>
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
<td>{{ d.asset.serial_number if d.asset else '—' }}</td>
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
<td>
{% if d.pdf_filename %}
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-download"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No documents.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Mask confirmation modal -->
{% if not user.is_masked %}
<div class="modal fade" id="maskModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title text-danger"><i class="bi bi-eye-slash me-2"></i>Mask User Record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>This will <strong>permanently erase all PII</strong> (name, email, phone) for
<strong>{{ user.display_name }}</strong> (WID: <code>{{ user.windows_id }}</code>).</p>
<p class="mb-0 text-muted">Asset history and assignments will be retained, linked only to the Windows ID.
This action <strong>cannot be undone</strong>.</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('users.mask', user_id=user.id) }}" class="d-inline">
<button type="submit" class="btn btn-danger">
<i class="bi bi-eye-slash me-1"></i>Confirm Mask
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% block title %}{{ user.display_name if user else 'New User' }} IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('users.index') }}">Users</a></li>
<li class="breadcrumb-item active">{{ 'Edit' if user else 'New User' }}</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-person-{{ 'pencil' if user else 'plus' }} me-2"></i>
{{ 'Edit User' if user else 'Add User' }}
</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:700px;">
<div class="card-body">
<form method="POST" action="{{ url_for('users.edit', user_id=user.id) if user else url_for('users.create') }}">
<h6 class="text-uppercase text-muted mb-3 small">Identity</h6>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Windows ID <span class="text-danger">*</span></label>
<input type="text" name="windows_id" class="form-control"
value="{{ user.windows_id if user else '' }}"
{% if user %}readonly{% endif %} required>
<div class="form-text">Numeric ID e.g. 408525</div>
</div>
<div class="col-md-4">
<label class="form-label">First Name</label>
<input type="text" name="first_name" class="form-control"
value="{{ user.first_name or '' if user else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Last Name</label>
<input type="text" name="last_name" class="form-control"
value="{{ user.last_name or '' if user else '' }}">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control"
value="{{ user.email or '' if user else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="text" name="phone" class="form-control"
value="{{ user.phone or '' if user else '' }}">
</div>
</div>
<hr class="my-3">
<h6 class="text-uppercase text-muted mb-3 small">Organisation</h6>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Department</label>
<input type="text" name="department" class="form-control"
value="{{ user.department or '' if user else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Job Title</label>
<input type="text" name="job_title" class="form-control"
value="{{ user.job_title or '' if user else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Location / Office</label>
<input type="text" name="location" class="form-control"
value="{{ user.location or '' if user else '' }}">
</div>
</div>
{% if user %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="isActive"
{% if user.is_active %}checked{% endif %}>
<label class="form-check-label" for="isActive">Active employee</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{ 'Save Changes' if user else 'Create User' }}
</button>
<a href="{{ url_for('users.detail', user_id=user.id) if user else url_for('users.index') }}"
class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}Import Users IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('users.index') }}">Users</a></li>
<li class="breadcrumb-item active">Import</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-cloud-download me-2"></i>Import Users</h1>
</div>
<div class="row g-4">
<!-- CSV Import -->
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-filetype-csv me-2 text-success"></i>Import from CSV
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Upload a CSV file with employee data. The file must include a
<code>windows_id</code> column. Additional columns are matched by common aliases.
</p>
<div class="bg-light rounded p-2 mb-3" style="font-size:.78rem;">
<strong>Recognised column names:</strong><br>
<code>windows_id</code>, <code>first_name</code>, <code>last_name</code>,
<code>email</code>, <code>department</code>, <code>job_title</code>,
<code>phone</code>, <code>location</code>
<br><span class="text-muted">(case-insensitive, spaces or underscores)</span>
</div>
<form method="POST" action="{{ url_for('users.import_csv') }}" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" name="csv_file" class="form-control" accept=".csv" required>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-upload me-1"></i>Import CSV
</button>
</form>
</div>
</div>
</div>
<!-- LDAP / AD Sync -->
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-diagram-3 me-2 text-primary"></i>Sync from Active Directory
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Connects to the LDAP/AD server configured in Settings and upserts all
matching user accounts. Masked users are never overwritten.
</p>
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle me-1"></i>
Existing non-masked users will be updated with fresh AD data.
New accounts will be created. Masked records are skipped.
</div>
<form method="POST" action="{{ url_for('users.import_ldap') }}">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-arrow-repeat me-1"></i>Sync from AD Now
</button>
</form>
<hr>
<small class="text-muted d-block">
Configure LDAP server, bind credentials and base DN in
<a href="{{ url_for('settings.index') }}">Settings</a>.
</small>
</div>
</div>
</div>
</div>
<!-- CSV template download hint -->
<div class="card border-0 shadow-sm mt-4" style="max-width:500px;">
<div class="card-body py-3">
<h6 class="fw-semibold mb-2"><i class="bi bi-file-earmark-spreadsheet me-2"></i>CSV Template</h6>
<p class="small text-muted mb-2">Your CSV should look like this:</p>
<pre class="bg-light rounded p-2 small mb-0">windows_id,first_name,last_name,email,department,job_title,location
408525,John,Doe,john.doe@company.com,IT,Engineer,HQ
408526,Jane,Smith,jane.smith@company.com,HR,Manager,HQ</pre>
</div>
</div>
{% endblock %}
@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block title %}Users IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Users</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-people-fill me-2"></i>Users</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('users.import_page') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-cloud-download me-1"></i>Import
</a>
<a href="{{ url_for('users.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>Add User
</a>
</div>
</div>
<!-- Filters -->
<form method="GET" class="row g-2 mb-3">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="Search name, email, WID, dept…" value="{{ q }}">
</div>
</div>
<div class="col-auto">
<div class="form-check form-check-inline mt-1">
<input class="form-check-input" type="checkbox" name="masked" value="1" id="chkMasked"
{% if show_masked %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="chkMasked">Show masked users</label>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">Search</button>
<a href="{{ url_for('users.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Windows ID</th>
<th>Name</th>
<th>Email</th>
<th>Department</th>
<th>Job Title</th>
<th>Source</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in pagination.items %}
<tr {% if u.is_masked %}class="masked-row"{% endif %}>
<td><code>{{ u.windows_id }}</code></td>
<td>
<a href="{{ url_for('users.detail', user_id=u.id) }}">{{ u.display_name }}</a>
{% if u.is_masked %}<span class="badge badge-masked ms-1">MASKED</span>{% endif %}
</td>
<td>{{ u.display_email }}</td>
<td>{{ u.department or '—' }}</td>
<td>{{ u.job_title or '—' }}</td>
<td>
<span class="badge bg-secondary">{{ u.import_source }}</span>
</td>
<td>
{% if not u.is_masked and u.is_active %}
<span class="badge bg-success">Active</span>
{% elif not u.is_masked %}
<span class="badge bg-warning text-dark">Inactive</span>
{% else %}
<span class="badge badge-masked">Masked</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('users.detail', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">
Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('users.index', page=pagination.prev_num, q=q, masked='1' if show_masked else '0') }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('users.index', page=p, q=q, masked='1' if show_masked else '0') }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('users.index', page=pagination.next_num, q=q, masked='1' if show_masked else '0') }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,58 @@
"""
Portal SSO middleware for IT Asset Management.
When the umbrella nginx verifies the portal JWT it sets two headers:
X-Auth-Username — the portal username
X-Auth-Role — 'admin' or 'user'
This before_request handler reads those headers and auto-logs in the
corresponding local AdminUser, creating them on first access if needed.
The local session is then maintained normally by Flask-Login.
"""
import secrets
from flask import request
from flask_login import login_user, current_user
def init_portal_sso(app):
"""Register the SSO before_request handler on the given Flask app."""
@app.before_request
def _portal_sso():
if current_user.is_authenticated:
return
username = request.headers.get('X-Auth-Username', '').strip()
if not username:
return
role = request.headers.get('X-Auth-Role', 'user').strip()
user = _get_or_create_user(username, role)
if user:
login_user(user, remember=False)
def _get_or_create_user(username, role):
from app.models.admin_user import AdminUser
from app.extensions import db
target_role = 'admin' if role == 'admin' else 'readonly'
try:
user = AdminUser.query.filter_by(username=username).first()
if not user:
user = AdminUser(
username=username,
email=f'{username}@portal.local',
full_name=username,
role=target_role,
is_active=True,
)
user.set_password(secrets.token_hex(32))
db.session.add(user)
db.session.commit()
elif user.role != target_role:
user.role = target_role
db.session.commit()
return user
except Exception:
return None
@@ -0,0 +1,22 @@
"""
ScriptNameFix WSGI middleware.
When nginx strips the path prefix before forwarding to a Flask app it also
sets the X-Script-Name header (e.g. /itassets). This middleware reads
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
url_for() generates absolute URLs with the correct prefix.
"""
class ScriptNameFix:
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
if script_name:
environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '/')
if path_info.startswith(script_name):
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
return self.app(environ, start_response)
+77
View File
@@ -0,0 +1,77 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-this-secret-in-production-use-a-long-random-string')
# MySQL
MYSQL_HOST = os.environ.get('MYSQL_HOST', 'localhost')
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', 3306))
MYSQL_USER = os.environ.get('MYSQL_USER', 'itasset_user')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'itasset_pass')
MYSQL_DB = os.environ.get('MYSQL_DB', 'itasset_db')
# Allow SQLALCHEMY_DATABASE_URI env var to override (used for SQLite in local dev)
_mysql_uri = (
f"mysql+pymysql://{os.environ.get('MYSQL_USER', 'itasset_user')}:"
f"{os.environ.get('MYSQL_PASSWORD', 'itasset_pass')}@"
f"{os.environ.get('MYSQL_HOST', 'localhost')}:"
f"{os.environ.get('MYSQL_PORT', 3306)}/"
f"{os.environ.get('MYSQL_DB', 'itasset_db')}?charset=utf8mb4"
)
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI', _mysql_uri)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# LDAP / Active Directory
LDAP_SERVER = os.environ.get('LDAP_SERVER', '')
LDAP_PORT = int(os.environ.get('LDAP_PORT', 389))
LDAP_USE_SSL = os.environ.get('LDAP_USE_SSL', 'false').lower() == 'true'
LDAP_BIND_USER = os.environ.get('LDAP_BIND_USER', '')
LDAP_BIND_PASSWORD = os.environ.get('LDAP_BIND_PASSWORD', '')
LDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', '')
LDAP_USER_SEARCH_FILTER = os.environ.get(
'LDAP_USER_SEARCH_FILTER', '(&(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'
)
# AD attribute that stores the numeric Windows ID (e.g. employeeID)
LDAP_WINDOWS_ID_ATTR = os.environ.get('LDAP_WINDOWS_ID_ATTR', 'employeeID')
# File storage
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
PDF_FOLDER = os.environ.get('PDF_FOLDER', 'pdfs')
TEMPLATE_FOLDER = os.environ.get('TEMPLATE_FOLDER', 'doc_templates')
DOCX_FOLDER = os.environ.get('DOCX_FOLDER', 'docx_output')
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB
# Company info used in generated PDFs
COMPANY_NAME = os.environ.get('COMPANY_NAME', 'Your Company Name')
COMPANY_ADDRESS = os.environ.get('COMPANY_ADDRESS', '')
COMPANY_LOGO = os.environ.get('COMPANY_LOGO', '') # path to logo file
# Portal SSO
PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
# Pagination
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
# Dell TechDirect API (for service-tag auto-lookup)
# Register at https://tdm.dell.com → API Services → Create credentials
DELL_CLIENT_ID = os.environ.get('DELL_CLIENT_ID', '')
DELL_CLIENT_SECRET = os.environ.get('DELL_CLIENT_SECRET', '')
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
DEBUG = False
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig,
}
+43
View File
@@ -0,0 +1,43 @@
version: '3.9'
services:
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DB:-itasset_db}
MYSQL_USER: ${MYSQL_USER:-itasset_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-itasset_pass}
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}"]
interval: 10s
timeout: 5s
retries: 10
web:
build: .
restart: always
depends_on:
db:
condition: service_healthy
env_file:
- .env
environment:
MYSQL_HOST: db
ports:
- "5000:5000"
volumes:
- uploads_data:/app/uploads
- pdfs_data:/app/pdfs
command: >
sh -c "flask db upgrade && python init_db.py && python run.py"
volumes:
mysql_data:
uploads_data:
pdfs_data:
+6
View File
@@ -0,0 +1,6 @@
With free API key (~5 min setup):
The form fills completely automatically — model, warranty date, purchase date, serial number, all at once. To get it:
Go to tdm.dell.com → sign in with a free Dell account
Go to API Services → Create new application
Add DELL_CLIENT_ID=... and DELL_CLIENT_SECRET=... to your .env — done
+40
View File
@@ -0,0 +1,40 @@
"""
init_db.py — Run once after `flask db upgrade` to:
1. Create all tables (idempotent)
2. Create the default admin user if no admin exists
Usage:
python init_db.py
(or it runs automatically via Docker Compose CMD)
"""
import os
from app import create_app
from app.extensions import db
from app.models.admin_user import AdminUser
app = create_app(os.environ.get('FLASK_ENV', 'development'))
with app.app_context():
db.create_all()
# Only create default admin if none exists
if AdminUser.query.count() == 0:
default_user = os.environ.get('DEFAULT_ADMIN_USER', 'admin')
default_pass = os.environ.get('DEFAULT_ADMIN_PASS', 'ChangeMe123!')
default_email = os.environ.get('DEFAULT_ADMIN_EMAIL', 'admin@company.local')
admin = AdminUser(
username=default_user,
email=default_email,
full_name='System Administrator',
role='admin',
)
admin.set_password(default_pass)
db.session.add(admin)
db.session.commit()
print(f'[init_db] Default admin created — username: {default_user}')
print(f'[init_db] *** CHANGE THE DEFAULT PASSWORD IMMEDIATELY ***')
else:
print('[init_db] Admin users already exist — skipping default creation.')
print('[init_db] Database initialised.')
+16
View File
@@ -0,0 +1,16 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
Flask-WTF==1.2.1
WTForms==3.1.2
email-validator==2.2.0
PyMySQL==1.1.1
python-dotenv==1.0.1
ldap3==2.9.1
reportlab==4.2.2
Werkzeug==3.0.3
cryptography==42.0.8
requests==2.32.3
httpx[http2]
docxtpl
+8
View File
@@ -0,0 +1,8 @@
import os
from app import create_app
app = create_app(os.environ.get('FLASK_ENV', 'development'))
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5003))
app.run(host='0.0.0.0', port=port)
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.db
!data/.gitkeep
+117
View File
@@ -0,0 +1,117 @@
# NetworkView
A powerful, dark-themed network & homelab documentation application. Document sites, server rooms, racks and every device inside them — with a visual rack diagram, Markdown notes, port/connection tracking and full-text search.
## Features
- **Visual Rack Diagram** — graphical U-by-U rack view with color-coded device types. Click any empty slot to add a device there.
- **Hierarchical Structure** — Sites → Rooms → Racks → Components
- **10 Component Types** — Server, Switch, Router, Firewall, Patch Panel, UPS, PDU, KVM, Storage, Other
- **Port Documentation** — document each port on a device (RJ45, SFP, SFP+, LC, SC, etc.) with labels and patch-through connections
- **Markdown Notes** — every entity has a full Markdown editor with live split-pane preview (GFM: tables, checkboxes, code blocks)
- **Auto-save Notes** — notes save automatically as you type (1.5s debounce)
- **Full-text Search** — sidebar search across all sites, rooms, racks and components (name, model, IP, serial)
- **Dark Theme** — GitHub-style dark UI optimised for long documentation sessions
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 18 + TypeScript + Vite |
| Backend | Express.js (Node 20) |
| Database | SQLite via `better-sqlite3` (single file, zero config) |
| Markdown | `react-markdown` + `remark-gfm` |
## Getting Started
### Prerequisites
- Node.js 20+
- npm 10+
### Install & Run (Development)
```bash
# From the project root
npm install
# Start both backend (port 3001) and frontend (port 5173) with hot-reload
npm run dev
```
Open **http://localhost:5173** in your browser.
### Production Build
```bash
npm run build # builds frontend into frontend/dist/
npm start # serves API + built frontend on port 3001
```
Open **http://localhost:3001**
## Project Structure
```
NetworkView/
├── backend/
│ └── src/
│ ├── index.js # Express server + search endpoint
│ ├── db.js # SQLite init + schema
│ └── routes/
│ ├── sites.js
│ ├── rooms.js
│ ├── racks.js
│ └── components.js # includes ports sub-resource
├── frontend/
│ └── src/
│ ├── App.tsx # Router
│ ├── api.ts # Fetch wrapper for all endpoints
│ ├── types.ts # TypeScript types + component metadata
│ ├── components/
│ │ ├── Sidebar.tsx # Tree navigation + search
│ │ ├── RackVisual.tsx # Graphical rack diagram
│ │ ├── MarkdownEditor.tsx
│ │ └── AddItemModal.tsx
│ └── pages/
│ ├── HomePage.tsx
│ ├── SitePage.tsx
│ ├── RoomPage.tsx
│ ├── RackPage.tsx
│ └── ComponentPage.tsx
└── data/
└── networkview.db # Created automatically on first run
```
## Data Model
```
Site
└── Room(s)
└── Rack(s)
└── Component(s)
└── Port(s)
```
Every level has its own page with Markdown notes.
## API Reference
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/sites` | List all sites |
| POST | `/api/sites` | Create site |
| GET/PUT/DELETE | `/api/sites/:id` | Site CRUD |
| GET | `/api/rooms?siteId=` | Rooms in a site |
| POST | `/api/rooms` | Create room |
| GET/PUT/DELETE | `/api/rooms/:id` | Room CRUD |
| GET | `/api/racks?roomId=` | Racks in a room |
| POST | `/api/racks` | Create rack |
| GET/PUT/DELETE | `/api/racks/:id` | Rack CRUD |
| GET | `/api/components?rackId=` | Components in rack |
| POST | `/api/components` | Create component |
| GET/PUT/DELETE | `/api/components/:id` | Component CRUD |
| GET | `/api/components/:id/ports` | Ports on component |
| POST | `/api/components/:id/ports` | Add port |
| PUT/DELETE | `/api/components/:id/ports/:portId` | Port CRUD |
| GET | `/api/search?q=` | Full-text search |
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["node", "src/index.js"]
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "networkview-backend",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"better-sqlite3": "^9.4.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"morgan": "^1.10.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Audit logging helper.
*
* Every mutation route calls `logAudit(req, { action, entityType, entityId, entityName, changes })`
* where `req` already has `req.actor` attached by the actor middleware (see index.js).
*/
const { v4: uuidv4 } = require('uuid');
const db = require('./db');
const insert = db.prepare(`
INSERT INTO audit_log (id, user_id, username, action, entity_type, entity_id, entity_name, changes, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
/**
* @param {import('express').Request} req
* @param {{ action: string, entityType: string, entityId?: string, entityName?: string, changes?: object }} opts
*/
function logAudit(req, { action, entityType, entityId, entityName, changes }) {
const actor = req.actor ?? {};
const ip = (req.headers['x-forwarded-for'] ?? req.socket.remoteAddress ?? '').toString().split(',')[0].trim();
try {
insert.run(
uuidv4(),
actor.id ?? null,
actor.username ?? 'anonymous',
action,
entityType,
entityId ?? null,
entityName ?? null,
changes ? JSON.stringify(changes) : null,
ip || null,
);
} catch (e) {
// Never crash a request because audit failed
console.error('[audit] Failed to write log entry:', e.message);
}
}
module.exports = { logAudit };
+107
View File
@@ -0,0 +1,107 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const DB_PATH = path.join(__dirname, '../../../data/networkview.db');
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS racks (
id TEXT PRIMARY KEY,
room_id TEXT REFERENCES rooms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
total_units INTEGER DEFAULT 42,
manufacturer TEXT,
model TEXT,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS components (
id TEXT PRIMARY KEY,
rack_id TEXT REFERENCES racks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'other',
position INTEGER,
height_units INTEGER DEFAULT 1,
manufacturer TEXT,
model TEXT,
serial_number TEXT,
asset_tag TEXT,
ip_address TEXT,
mac_address TEXT,
status TEXT DEFAULT 'active',
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ports (
id TEXT PRIMARY KEY,
component_id TEXT NOT NULL REFERENCES components(id) ON DELETE CASCADE,
port_number INTEGER NOT NULL,
label TEXT,
port_type TEXT DEFAULT 'RJ45',
connected_to_port_id TEXT REFERENCES ports(id) ON DELETE SET NULL,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Migrate: add port_count column if it doesn't exist yet
try { db.exec('ALTER TABLE components ADD COLUMN port_count INTEGER DEFAULT NULL'); } catch (_) {}
try { db.exec('ALTER TABLE components ADD COLUMN sfp_count INTEGER DEFAULT NULL'); } catch (_) {}
// ── Users & Audit tables ─────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'viewer',
is_active INTEGER NOT NULL DEFAULT 1,
api_key TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
entity_name TEXT,
changes TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
`);
module.exports = db;
+72
View File
@@ -0,0 +1,72 @@
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
app.use(morgan('dev'));
// ── Actor middleware ──────────────────────────────────────────────────────────
// The external main app sets x-user-id + x-username after token verification.
// Direct calls can use x-api-key (validated against users.api_key).
const db = require('./db');
app.use((req, _res, next) => {
const userId = req.headers['x-user-id'];
const username = req.headers['x-username'];
const apiKey = req.headers['x-api-key'];
if (userId && username) {
req.actor = { id: userId, username: String(username) };
} else if (apiKey) {
const user = db.prepare('SELECT id, username FROM users WHERE api_key = ? AND is_active = 1').get(apiKey);
req.actor = user ? { id: user.id, username: user.username } : { id: null, username: 'api-key-invalid' };
} else {
req.actor = { id: null, username: 'anonymous' };
}
next();
});
// API routes
app.use('/api/sites', require('./routes/sites'));
app.use('/api/rooms', require('./routes/rooms'));
app.use('/api/racks', require('./routes/racks'));
app.use('/api/components', require('./routes/components'));
app.use('/api/graph', require('./routes/graph'));
app.use('/api/users', require('./routes/users'));
app.use('/api/audit', require('./routes/audit'));
app.use('/api/settings', require('./routes/settings'));
app.get('/api/search', (req, res) => {
const { q } = req.query;
if (!q || q.trim().length < 2) return res.json({ sites: [], rooms: [], racks: [], components: [] });
const term = `%${q.trim()}%`;
const sites = db.prepare('SELECT id, name, location FROM sites WHERE name LIKE ? OR location LIKE ? LIMIT 10').all(term, term);
const rooms = db.prepare('SELECT id, name, site_id FROM rooms WHERE name LIKE ? LIMIT 10').all(term);
const racks = db.prepare('SELECT id, name, room_id FROM racks WHERE name LIKE ? OR model LIKE ? LIMIT 10').all(term, term);
const components = db.prepare(`
SELECT id, name, type, rack_id, model, ip_address FROM components
WHERE name LIKE ? OR model LIKE ? OR ip_address LIKE ? OR serial_number LIKE ? LIMIT 20
`).all(term, term, term, term);
res.json({ sites, rooms, racks, components });
});
// Health check
app.get('/api/health', (_req, res) => res.json({ status: 'ok', time: new Date().toISOString() }));
// Serve built frontend in production
const FRONTEND_DIST = path.join(__dirname, '../../frontend/dist');
app.use(express.static(FRONTEND_DIST));
app.get('*', (_req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'), err => {
if (err) res.status(200).json({ message: 'NetworkView API running. Start frontend separately in dev mode.' });
});
});
app.listen(PORT, () => {
console.log(`NetworkView backend running on http://localhost:${PORT}`);
});
+46
View File
@@ -0,0 +1,46 @@
/**
* /api/audit
* Read-only audit log with filtering and pagination.
*/
const express = require('express');
const db = require('../db');
const router = express.Router();
// GET /api/audit?entity_type=&entity_id=&user_id=&action=&limit=50&offset=0
router.get('/', (req, res) => {
const { entity_type, entity_id, user_id, action, limit = 50, offset = 0 } = req.query;
const conditions = [];
const params = [];
if (entity_type) { conditions.push('entity_type = ?'); params.push(entity_type); }
if (entity_id) { conditions.push('entity_id = ?'); params.push(entity_id); }
if (user_id) { conditions.push('user_id = ?'); params.push(user_id); }
if (action) { conditions.push('action = ?'); params.push(action); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const lim = Math.min(Number(limit) || 50, 500);
const off = Number(offset) || 0;
const rows = db.prepare(`
SELECT * FROM audit_log ${where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(...params, lim, off);
const total = db.prepare(`
SELECT COUNT(*) AS n FROM audit_log ${where}
`).get(...params).n;
res.json({ total, limit: lim, offset: off, entries: rows });
});
// GET /api/audit/:id
router.get('/:id', (req, res) => {
const entry = db.prepare('SELECT * FROM audit_log WHERE id = ?').get(req.params.id);
if (!entry) return res.status(404).json({ error: 'Audit entry not found' });
res.json(entry);
});
module.exports = router;
@@ -0,0 +1,251 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
function checkPositionOverlap(rackId, position, heightUnits, excludeId = null) {
const end = position + heightUnits - 1;
const existing = db.prepare(`
SELECT id, name, position, height_units FROM components
WHERE rack_id = ?
AND id != ?
AND position IS NOT NULL
AND position <= ? AND (position + height_units - 1) >= ?
`).all(rackId, excludeId || '', end, position);
return existing;
}
// GET /api/components?rackId=
router.get('/', (req, res) => {
const { rackId } = req.query;
if (!rackId) return res.status(400).json({ error: 'rackId query param required' });
const components = db.prepare(
'SELECT * FROM components WHERE rack_id = ? ORDER BY position ASC NULLS LAST'
).all(rackId);
res.json(components);
});
// GET /api/components/:id
router.get('/:id', (req, res) => {
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' });
const rawPorts = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(req.params.id);
const ports = rawPorts.map(p => {
if (!p.connected_to_port_id) return p;
const lp = db.prepare('SELECT * FROM ports WHERE id = ?').get(p.connected_to_port_id);
if (!lp) return p;
const lc = db.prepare('SELECT id, name, type FROM components WHERE id = ?').get(lp.component_id);
return { ...p, linked_port: lc ? { id: lp.id, port_number: lp.port_number, label: lp.label, component_id: lc.id, component_name: lc.name, component_type: lc.type } : null };
});
let rack = null, room = null, site = null;
if (component.rack_id) {
rack = db.prepare('SELECT id, name, total_units, room_id FROM racks WHERE id = ?').get(component.rack_id);
if (rack?.room_id) {
room = db.prepare('SELECT id, name, site_id FROM rooms WHERE id = ?').get(rack.room_id);
if (room?.site_id) site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id);
}
}
res.json({ ...component, ports, rack, room, site });
});
// POST /api/components
router.post('/', (req, res) => {
const {
rack_id, name, type, position, height_units,
manufacturer, model, serial_number, asset_tag,
ip_address, mac_address, status, notes
} = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
if (rack_id) {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(rack_id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
if (position != null) {
const h = height_units || 1;
if (position < 1 || position + h - 1 > rack.total_units) {
return res.status(400).json({ error: `Position ${position} with ${h}U height exceeds rack size (${rack.total_units}U)` });
}
const overlapping = checkPositionOverlap(rack_id, position, h);
if (overlapping.length > 0) {
return res.status(400).json({
error: `Position overlaps with existing component: ${overlapping[0].name}`
});
}
}
}
const { port_count, sfp_count } = req.body;
const id = uuidv4();
db.prepare(`
INSERT INTO components
(id, rack_id, name, type, position, height_units, manufacturer, model,
serial_number, asset_tag, ip_address, mac_address, status, notes, port_count, sfp_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, rack_id || null, name, type || 'other',
position ?? null, height_units || 1,
manufacturer || null, model || null,
serial_number || null, asset_tag || null,
ip_address || null, mac_address || null,
status || 'active', notes || '',
port_count ?? null, sfp_count ?? null
);
res.status(201).json(db.prepare('SELECT * FROM components WHERE id = ?').get(id));
logAudit(req, { action: 'create', entityType: 'component', entityId: id, entityName: name });
});
// PUT /api/components/:id
router.put('/:id', (req, res) => {
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' });
const {
name, type, position, height_units, manufacturer, model,
serial_number, asset_tag, ip_address, mac_address, status, notes, port_count, sfp_count
} = req.body;
const newPos = position ?? component.position;
const newH = height_units ?? component.height_units;
if (component.rack_id && newPos != null) {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(component.rack_id);
if (newPos < 1 || newPos + newH - 1 > rack.total_units) {
return res.status(400).json({ error: `Position exceeds rack size (${rack.total_units}U)` });
}
const overlapping = checkPositionOverlap(component.rack_id, newPos, newH, req.params.id);
if (overlapping.length > 0) {
return res.status(400).json({ error: `Position overlaps with: ${overlapping[0].name}` });
}
}
db.prepare(`
UPDATE components SET
name = ?, type = ?, position = ?, height_units = ?,
manufacturer = ?, model = ?, serial_number = ?, asset_tag = ?,
ip_address = ?, mac_address = ?, status = ?, notes = ?,
port_count = ?, sfp_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
name ?? component.name,
type ?? component.type,
newPos,
newH,
manufacturer ?? component.manufacturer,
model ?? component.model,
serial_number ?? component.serial_number,
asset_tag ?? component.asset_tag,
ip_address ?? component.ip_address,
mac_address ?? component.mac_address,
status ?? component.status,
notes ?? component.notes,
port_count !== undefined ? port_count : component.port_count,
sfp_count !== undefined ? sfp_count : component.sfp_count,
req.params.id
);
logAudit(req, { action: 'update', entityType: 'component', entityId: component.id, entityName: name ?? component.name });
res.json(db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id));
});
// DELETE /api/components/:id
router.delete('/:id', (req, res) => {
const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' });
db.prepare('DELETE FROM components WHERE id = ?').run(req.params.id);
logAudit(req, { action: 'delete', entityType: 'component', entityId: component.id, entityName: component.name });
res.json({ ok: true });
});
// --- Ports sub-resource ---
// GET /api/components/:id/ports
router.get('/:id/ports', (req, res) => {
const ports = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(req.params.id);
res.json(ports);
});
// POST /api/components/:id/ports
router.post('/:id/ports', (req, res) => {
const component = db.prepare('SELECT id, type, port_count FROM components WHERE id = ?').get(req.params.id);
if (!component) return res.status(404).json({ error: 'Component not found' });
const { port_number, label, port_type, connected_to_port_id, notes } = req.body;
if (port_number == null) return res.status(400).json({ error: 'port_number is required' });
// Patch-panel specific: no duplicate port numbers, enforce port_count capacity
if (component.type === 'patch_panel') {
const existing = db.prepare('SELECT id FROM ports WHERE component_id = ? AND port_number = ?').get(req.params.id, port_number);
if (existing) return res.status(409).json({ error: `Port ${port_number} already exists on this patch panel.` });
if (component.port_count != null) {
const count = db.prepare('SELECT COUNT(*) as n FROM ports WHERE component_id = ?').get(req.params.id).n;
if (count >= component.port_count)
return res.status(409).json({ error: `Patch panel is full (${component.port_count} ports maximum).` });
if (port_number < 1 || port_number > component.port_count)
return res.status(400).json({ error: `Port number must be between 1 and ${component.port_count}.` });
}
}
const id = uuidv4();
db.prepare(`
INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null);
// Mirror link on the other side
if (connected_to_port_id) {
db.prepare('UPDATE ports SET connected_to_port_id = ? WHERE id = ?').run(id, connected_to_port_id);
}
res.status(201).json(db.prepare('SELECT * FROM ports WHERE id = ?').get(id));
});
// PUT /api/components/:componentId/ports/:portId
router.put('/:componentId/ports/:portId', (req, res) => {
const port = db.prepare('SELECT * FROM ports WHERE id = ? AND component_id = ?').get(req.params.portId, req.params.componentId);
if (!port) return res.status(404).json({ error: 'Port not found' });
const { label, port_type, connected_to_port_id, notes } = req.body;
const newLinkedId = connected_to_port_id !== undefined ? (connected_to_port_id || null) : port.connected_to_port_id;
const oldLinkedId = port.connected_to_port_id;
// Clear old reverse link if it changed
if (oldLinkedId && oldLinkedId !== newLinkedId) {
db.prepare('UPDATE ports SET connected_to_port_id = NULL WHERE id = ? AND connected_to_port_id = ?').run(oldLinkedId, port.id);
}
db.prepare(`
UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ? WHERE id = ?
`).run(
label ?? port.label,
port_type ?? port.port_type,
newLinkedId,
notes ?? port.notes,
req.params.portId
);
// Set new reverse link
if (newLinkedId && newLinkedId !== oldLinkedId) {
db.prepare('UPDATE ports SET connected_to_port_id = ? WHERE id = ?').run(port.id, newLinkedId);
}
res.json(db.prepare('SELECT * FROM ports WHERE id = ?').get(req.params.portId));
});
// DELETE /api/components/:componentId/ports/:portId
router.delete('/:componentId/ports/:portId', (req, res) => {
db.prepare('DELETE FROM ports WHERE id = ? AND component_id = ?').run(req.params.portId, req.params.componentId);
res.json({ ok: true });
});
module.exports = router;
+124
View File
@@ -0,0 +1,124 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
/**
* GET /api/graph/:level/:id
* level: site | room | rack
* Returns { nodes, edges } for a force-directed graph view.
*/
router.get('/:level/:id', (req, res) => {
const { level, id } = req.params;
const nodes = [];
const edges = [];
const compIds = [];
if (level === 'site') {
const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(id);
if (!site) return res.status(404).json({ error: 'Site not found' });
nodes.push({ id: site.id, label: site.name, type: 'site', url: `/sites/${site.id}` });
const rooms = db.prepare('SELECT id, name FROM rooms WHERE site_id = ?').all(id);
for (const room of rooms) {
nodes.push({ id: room.id, label: room.name, type: 'room', url: `/rooms/${room.id}` });
edges.push({ source: site.id, target: room.id, type: 'hierarchy' });
const racks = db.prepare('SELECT id, name FROM racks WHERE room_id = ?').all(room.id);
for (const rack of racks) {
nodes.push({ id: rack.id, label: rack.name, type: 'rack', url: `/racks/${rack.id}` });
edges.push({ source: room.id, target: rack.id, type: 'hierarchy' });
const components = db.prepare('SELECT id, name, type FROM components WHERE rack_id = ?').all(rack.id);
for (const comp of components) {
nodes.push({ id: comp.id, label: comp.name, type: comp.type, url: `/components/${comp.id}` });
edges.push({ source: rack.id, target: comp.id, type: 'hierarchy' });
compIds.push(comp.id);
}
}
}
} else if (level === 'room') {
const room = db.prepare('SELECT id, name FROM rooms WHERE id = ?').get(id);
if (!room) return res.status(404).json({ error: 'Room not found' });
nodes.push({ id: room.id, label: room.name, type: 'room', url: `/rooms/${room.id}` });
const racks = db.prepare('SELECT id, name FROM racks WHERE room_id = ?').all(id);
for (const rack of racks) {
nodes.push({ id: rack.id, label: rack.name, type: 'rack', url: `/racks/${rack.id}` });
edges.push({ source: room.id, target: rack.id, type: 'hierarchy' });
const components = db.prepare('SELECT id, name, type FROM components WHERE rack_id = ?').all(rack.id);
for (const comp of components) {
nodes.push({ id: comp.id, label: comp.name, type: comp.type, url: `/components/${comp.id}` });
edges.push({ source: rack.id, target: comp.id, type: 'hierarchy' });
compIds.push(comp.id);
}
}
} else if (level === 'rack') {
const rack = db.prepare('SELECT id, name FROM racks WHERE id = ?').get(id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
nodes.push({ id: rack.id, label: rack.name, type: 'rack', url: `/racks/${rack.id}` });
const components = db.prepare('SELECT id, name, type FROM components WHERE rack_id = ?').all(id);
for (const comp of components) {
nodes.push({ id: comp.id, label: comp.name, type: comp.type, url: `/components/${comp.id}` });
edges.push({ source: rack.id, target: comp.id, type: 'hierarchy' });
compIds.push(comp.id);
}
} else {
return res.status(400).json({ error: 'Invalid level. Use site, room, or rack.' });
}
// Port-to-port connections + port-level nodes (leaf level)
if (compIds.length > 1) {
const ph = compIds.map(() => '?').join(',');
const portLinks = db.prepare(`
SELECT
p1.id AS pid1, p1.port_number AS num1, p1.label AS lbl1, p1.component_id AS src,
p2.id AS pid2, p2.port_number AS num2, p2.label AS lbl2, p2.component_id AS tgt
FROM ports p1
JOIN ports p2 ON p1.connected_to_port_id = p2.id
WHERE p1.component_id IN (${ph})
AND p2.component_id IN (${ph})
AND p1.component_id != p2.component_id
`).all(...compIds, ...compIds);
const compEdgeSeen = new Set();
const portNodeSeen = new Set();
const portEdgeSeen = new Set();
for (const link of portLinks) {
// Component-to-component connection (high-level, deduplicated)
const compKey = [link.src, link.tgt].sort().join('|');
if (!compEdgeSeen.has(compKey)) {
compEdgeSeen.add(compKey);
edges.push({ source: link.src, target: link.tgt, type: 'connection' });
}
// Port nodes + hierarchy edges
const pid1 = `port:${link.pid1}`;
const pid2 = `port:${link.pid2}`;
if (!portNodeSeen.has(pid1)) {
portNodeSeen.add(pid1);
nodes.push({ id: pid1, label: link.lbl1 || `P${link.num1}`, type: 'port', url: `/components/${link.src}` });
edges.push({ source: link.src, target: pid1, type: 'hierarchy' });
}
if (!portNodeSeen.has(pid2)) {
portNodeSeen.add(pid2);
nodes.push({ id: pid2, label: link.lbl2 || `P${link.num2}`, type: 'port', url: `/components/${link.tgt}` });
edges.push({ source: link.tgt, target: pid2, type: 'hierarchy' });
}
// Port-to-port connection edge (deepest level)
const portEdgeKey = [pid1, pid2].sort().join('|');
if (!portEdgeSeen.has(portEdgeKey)) {
portEdgeSeen.add(portEdgeKey);
edges.push({ source: pid1, target: pid2, type: 'connection' });
}
}
}
res.json({ nodes, edges });
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// GET /api/racks?roomId=
router.get('/', (req, res) => {
const { roomId } = req.query;
if (!roomId) return res.status(400).json({ error: 'roomId query param required' });
const racks = db.prepare(`
SELECT ra.*, COUNT(c.id) as component_count
FROM racks ra
LEFT JOIN components c ON c.rack_id = ra.id
WHERE ra.room_id = ?
GROUP BY ra.id
ORDER BY ra.name
`).all(roomId);
res.json(racks);
});
// GET /api/racks/:id
router.get('/:id', (req, res) => {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
const components = db.prepare(`
SELECT * FROM components WHERE rack_id = ? ORDER BY position ASC NULLS LAST, name ASC
`).all(req.params.id);
// Attach ports to each component so the graphic view can show active ports
const componentsWithPorts = components.map(c => {
const ports = db.prepare('SELECT * FROM ports WHERE component_id = ? ORDER BY port_number').all(c.id);
return { ...c, ports };
});
let room = null, site = null;
if (rack.room_id) {
room = db.prepare('SELECT id, name, site_id FROM rooms WHERE id = ?').get(rack.room_id);
if (room) site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id);
}
res.json({ ...rack, components: componentsWithPorts, room, site });
});
// POST /api/racks
router.post('/', (req, res) => {
const { room_id, name, total_units, manufacturer, model, notes } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
if (room_id) {
const room = db.prepare('SELECT id FROM rooms WHERE id = ?').get(room_id);
if (!room) return res.status(404).json({ error: 'Room not found' });
}
const id = uuidv4();
db.prepare(`
INSERT INTO racks (id, room_id, name, total_units, manufacturer, model, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, room_id || null, name, total_units || 42, manufacturer || null, model || null, notes || '');
const created = db.prepare('SELECT * FROM racks WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'rack', entityId: id, entityName: name });
res.status(201).json(created);
});
// PUT /api/racks/:id
router.put('/:id', (req, res) => {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
const { name, total_units, manufacturer, model, notes } = req.body;
const changes = {};
if (name != null && name !== rack.name) changes.name = { from: rack.name, to: name };
if (total_units != null && total_units !== rack.total_units) changes.total_units = { from: rack.total_units, to: total_units };
if (manufacturer != null && manufacturer !== rack.manufacturer) changes.manufacturer = { from: rack.manufacturer, to: manufacturer };
if (model != null && model !== rack.model) changes.model = { from: rack.model, to: model };
db.prepare(`
UPDATE racks SET name = ?, total_units = ?, manufacturer = ?, model = ?, notes = ?,
updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(
name ?? rack.name,
total_units ?? rack.total_units,
manufacturer ?? rack.manufacturer,
model ?? rack.model,
notes ?? rack.notes,
req.params.id
);
logAudit(req, { action: 'update', entityType: 'rack', entityId: rack.id, entityName: name ?? rack.name, changes });
res.json(db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id));
});
// DELETE /api/racks/:id
router.delete('/:id', (req, res) => {
const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!rack) return res.status(404).json({ error: 'Rack not found' });
db.prepare('DELETE FROM racks WHERE id = ?').run(req.params.id);
logAudit(req, { action: 'delete', entityType: 'rack', entityId: rack.id, entityName: rack.name });
res.json({ ok: true });
});
module.exports = router;
+84
View File
@@ -0,0 +1,84 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// GET /api/rooms?siteId=
router.get('/', (req, res) => {
const { siteId } = req.query;
if (!siteId) return res.status(400).json({ error: 'siteId query param required' });
const rooms = db.prepare(`
SELECT r.*, COUNT(ra.id) as rack_count
FROM rooms r
LEFT JOIN racks ra ON ra.room_id = r.id
WHERE r.site_id = ?
GROUP BY r.id
ORDER BY r.name
`).all(siteId);
res.json(rooms);
});
// GET /api/rooms/:id
router.get('/:id', (req, res) => {
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' });
const racks = db.prepare(`
SELECT ra.*, COUNT(c.id) as component_count
FROM racks ra
LEFT JOIN components c ON c.rack_id = ra.id
WHERE ra.room_id = ?
GROUP BY ra.id
ORDER BY ra.name
`).all(req.params.id);
const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(room.site_id);
res.json({ ...room, racks, site });
});
// POST /api/rooms
router.post('/', (req, res) => {
const { site_id, name, notes } = req.body;
if (!site_id || !name) return res.status(400).json({ error: 'site_id and name are required' });
const site = db.prepare('SELECT id FROM sites WHERE id = ?').get(site_id);
if (!site) return res.status(404).json({ error: 'Site not found' });
const id = uuidv4();
db.prepare('INSERT INTO rooms (id, site_id, name, notes) VALUES (?, ?, ?, ?)').run(
id, site_id, name, notes || ''
);
const created = db.prepare('SELECT * FROM rooms WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'room', entityId: id, entityName: name });
res.status(201).json(created);
});
// PUT /api/rooms/:id
router.put('/:id', (req, res) => {
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' });
const { name, notes } = req.body;
const changes = {};
if (name != null && name !== room.name) changes.name = { from: room.name, to: name };
db.prepare(`
UPDATE rooms SET name = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(name ?? room.name, notes ?? room.notes, req.params.id);
logAudit(req, { action: 'update', entityType: 'room', entityId: room.id, entityName: name ?? room.name, changes });
res.json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id));
});
// DELETE /api/rooms/:id
router.delete('/:id', (req, res) => {
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' });
db.prepare('DELETE FROM rooms WHERE id = ?').run(req.params.id);
logAudit(req, { action: 'delete', entityType: 'room', entityId: room.id, entityName: room.name });
res.json({ ok: true });
});
module.exports = router;
@@ -0,0 +1,95 @@
/**
* /api/settings
* Administrative / danger-zone operations.
* All destructive actions require the header X-Confirm: yes
*/
const express = require('express');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// Guard: require explicit confirmation header for destructive ops
function requireConfirm(req, res, next) {
if ((req.headers['x-confirm'] ?? '').toLowerCase() !== 'yes') {
return res.status(400).json({ error: 'Send header X-Confirm: yes to confirm this destructive operation.' });
}
next();
}
// ── GET /api/settings/stats ───────────────────────────────────────────────────
// Overview counts — useful for the settings page dashboard
router.get('/stats', (req, res) => {
const stats = {
sites: db.prepare('SELECT COUNT(*) AS n FROM sites').get().n,
rooms: db.prepare('SELECT COUNT(*) AS n FROM rooms').get().n,
racks: db.prepare('SELECT COUNT(*) AS n FROM racks').get().n,
components: db.prepare('SELECT COUNT(*) AS n FROM components').get().n,
ports: db.prepare('SELECT COUNT(*) AS n FROM ports').get().n,
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
audit_entries: db.prepare('SELECT COUNT(*) AS n FROM audit_log').get().n,
};
res.json(stats);
});
// ── DELETE /api/settings/data/all ────────────────────────────────────────────
// Wipe ALL network data (sites, rooms, racks, components, ports, audit_log).
// Users are kept. Requires X-Confirm: yes.
router.delete('/data/all', requireConfirm, (req, res) => {
db.transaction(() => {
db.exec('DELETE FROM ports');
db.exec('DELETE FROM components');
db.exec('DELETE FROM racks');
db.exec('DELETE FROM rooms');
db.exec('DELETE FROM sites');
db.exec('DELETE FROM audit_log');
})();
logAudit(req, { action: 'delete_all', entityType: 'database', entityName: 'all network data' });
res.json({ ok: true, message: 'All network data deleted.' });
});
// ── DELETE /api/settings/data/audit ──────────────────────────────────────────
// Clear only the audit log.
router.delete('/data/audit', requireConfirm, (req, res) => {
db.exec('DELETE FROM audit_log');
logAudit(req, { action: 'delete_all', entityType: 'audit_log', entityName: 'audit log' });
res.json({ ok: true, message: 'Audit log cleared.' });
});
// ── DELETE /api/settings/sites/:id ───────────────────────────────────────────
router.delete('/sites/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Site not found' });
db.prepare('DELETE FROM sites WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'site', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
// ── DELETE /api/settings/rooms/:id ───────────────────────────────────────────
router.delete('/rooms/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Room not found' });
db.prepare('DELETE FROM rooms WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'room', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
// ── DELETE /api/settings/racks/:id ───────────────────────────────────────────
router.delete('/racks/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Rack not found' });
db.prepare('DELETE FROM racks WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'rack', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
// ── DELETE /api/settings/components/:id ──────────────────────────────────────
router.delete('/components/:id', requireConfirm, (req, res) => {
const row = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Component not found' });
db.prepare('DELETE FROM components WHERE id = ?').run(row.id);
logAudit(req, { action: 'delete', entityType: 'component', entityId: row.id, entityName: row.name });
res.json({ ok: true });
});
module.exports = router;
+78
View File
@@ -0,0 +1,78 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// GET /api/sites
router.get('/', (req, res) => {
const sites = db.prepare(`
SELECT s.*, COUNT(r.id) as room_count
FROM sites s
LEFT JOIN rooms r ON r.site_id = s.id
GROUP BY s.id
ORDER BY s.name
`).all();
res.json(sites);
});
// GET /api/sites/:id
router.get('/:id', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Site not found' });
const rooms = db.prepare(`
SELECT r.*, COUNT(ra.id) as rack_count
FROM rooms r
LEFT JOIN racks ra ON ra.room_id = r.id
WHERE r.site_id = ?
GROUP BY r.id
ORDER BY r.name
`).all(req.params.id);
res.json({ ...site, rooms });
});
// POST /api/sites
router.post('/', (req, res) => {
const { name, location, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const id = uuidv4();
db.prepare('INSERT INTO sites (id, name, location, notes) VALUES (?, ?, ?, ?)').run(
id, name, location || null, notes || ''
);
const created = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'site', entityId: id, entityName: name });
res.status(201).json(created);
});
// PUT /api/sites/:id
router.put('/:id', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Site not found' });
const { name, location, notes } = req.body;
const changes = {};
if (name != null && name !== site.name) changes.name = { from: site.name, to: name };
if (location != null && location !== site.location) changes.location = { from: site.location, to: location };
db.prepare(`
UPDATE sites SET name = ?, location = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(name ?? site.name, location ?? site.location, notes ?? site.notes, req.params.id);
logAudit(req, { action: 'update', entityType: 'site', entityId: site.id, entityName: name ?? site.name, changes });
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id));
});
// DELETE /api/sites/:id
router.delete('/:id', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Site not found' });
db.prepare('DELETE FROM sites WHERE id = ?').run(req.params.id);
logAudit(req, { action: 'delete', entityType: 'site', entityId: site.id, entityName: site.name });
res.json({ ok: true });
});
module.exports = router;
+138
View File
@@ -0,0 +1,138 @@
/**
* /api/users
*
* Manages NetworkView users. Authentication is expected to be handled by
* the external main app; this API accepts an `x-user-id` / `x-username`
* header (set by the gateway after verifying the bearer token) and an
* `x-api-key` header for direct service-to-service calls.
*
* Roles: admin | editor | viewer
*/
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const db = require('../db');
const { logAudit } = require('../audit');
const router = express.Router();
// ── Helpers ──────────────────────────────────────────────────────────────────
const safeUser = u => ({
id: u.id,
username: u.username,
email: u.email,
role: u.role,
is_active: u.is_active,
has_api_key: !!u.api_key,
created_at: u.created_at,
updated_at: u.updated_at,
});
// ── GET /api/users ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
const users = db.prepare('SELECT * FROM users ORDER BY username').all();
res.json(users.map(safeUser));
});
// ── GET /api/users/:id ────────────────────────────────────────────────────────
router.get('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(safeUser(user));
});
// ── POST /api/users ───────────────────────────────────────────────────────────
router.post('/', (req, res) => {
const { username, email, role = 'viewer' } = req.body;
if (!username) return res.status(400).json({ error: 'username is required' });
if (!['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'role must be admin | editor | viewer' });
}
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) {
return res.status(409).json({ error: 'Username already exists' });
}
if (email && db.prepare('SELECT id FROM users WHERE email = ?').get(email)) {
return res.status(409).json({ error: 'Email already in use' });
}
const id = uuidv4();
db.prepare(`
INSERT INTO users (id, username, email, role) VALUES (?, ?, ?, ?)
`).run(id, username, email ?? null, role);
const created = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
logAudit(req, { action: 'create', entityType: 'user', entityId: id, entityName: username });
res.status(201).json(safeUser(created));
});
// ── PUT /api/users/:id ────────────────────────────────────────────────────────
router.put('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const { username, email, role, is_active } = req.body;
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'role must be admin | editor | viewer' });
}
if (username && username !== user.username) {
if (db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, user.id)) {
return res.status(409).json({ error: 'Username already exists' });
}
}
const changes = {};
if (username != null && username !== user.username) changes.username = { from: user.username, to: username };
if (email != null && email !== user.email) changes.email = { from: user.email, to: email };
if (role != null && role !== user.role) changes.role = { from: user.role, to: role };
if (is_active != null && Number(is_active) !== user.is_active) changes.is_active = { from: user.is_active, to: Number(is_active) };
db.prepare(`
UPDATE users
SET username = ?, email = ?, role = ?, is_active = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
username ?? user.username,
email ?? user.email,
role ?? user.role,
is_active != null ? Number(is_active) : user.is_active,
user.id,
);
logAudit(req, { action: 'update', entityType: 'user', entityId: user.id, entityName: username ?? user.username, changes });
res.json(safeUser(db.prepare('SELECT * FROM users WHERE id = ?').get(user.id)));
});
// ── DELETE /api/users/:id ─────────────────────────────────────────────────────
router.delete('/:id', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
logAudit(req, { action: 'delete', entityType: 'user', entityId: user.id, entityName: user.username });
res.json({ ok: true });
});
// ── POST /api/users/:id/api-key (generate / rotate) ─────────────────────────
router.post('/:id/api-key', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const apiKey = `nvk_${crypto.randomBytes(24).toString('hex')}`;
db.prepare('UPDATE users SET api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(apiKey, user.id);
logAudit(req, { action: 'api_key_rotate', entityType: 'user', entityId: user.id, entityName: user.username });
// Return the key once — after this it is never exposed again
res.json({ api_key: apiKey });
});
// ── DELETE /api/users/:id/api-key (revoke) ──────────────────────────────────
router.delete('/:id/api-key', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('UPDATE users SET api_key = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
logAudit(req, { action: 'api_key_revoke', entityType: 'user', entityId: user.id, entityName: user.username });
res.json({ ok: true });
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
# Stage 1: build the React/Vite app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
# Build with /networkview/ as the base path so all asset URLs are prefixed
ENV VITE_BASE_PATH=/networkview/
ENV VITE_API_BASE=/networkview/api
RUN npm run build
# Stage 2: serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/rack-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetworkView — Lab Documentation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Serve static assets with long cache
location ~* \.(js|css|png|jpg|svg|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback: return index.html for any path
# React Router (with basename=/networkview) handles routing client-side
location / {
try_files $uri /index.html;
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "networkview-frontend",
"version": "1.0.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.22.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"rehype-highlight": "^7.0.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Sidebar from './components/Sidebar';
import HomePage from './pages/HomePage';
import SitePage from './pages/SitePage';
import RoomPage from './pages/RoomPage';
import RackPage from './pages/RackPage';
import ComponentPage from './pages/ComponentPage';
import GraphPage from './pages/GraphPage';
import SettingsPage from './pages/SettingsPage';
// import.meta.env.BASE_URL is set by Vite to the configured `base` value.
// Strip the trailing slash so React Router receives e.g. '/networkview'.
const routerBase = import.meta.env.BASE_URL.replace(/\/$/, '');
export default function App() {
return (
<BrowserRouter basename={routerBase}>
<div className="app-layout">
<Sidebar />
<main className="main-content">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/sites/:siteId" element={<SitePage />} />
<Route path="/rooms/:roomId" element={<RoomPage />} />
<Route path="/racks/:rackId" element={<RackPage />} />
<Route path="/components/:componentId" element={<ComponentPage />} />
<Route path="/graph/:level/:id" element={<GraphPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type {
Site, Room, Rack, Component, Port, SearchResults, User, AuditEntry, DbStats
} from './types';
// In production the API lives at /networkview/api (set via VITE_API_BASE at build time).
// In development it defaults to /api, proxied by the Vite dev server.
const BASE = import.meta.env.VITE_API_BASE ?? '/api';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
// --- Sites ---
export const getSites = () => request<Site[]>('/sites');
export const getSite = (id: string) => request<Site & { rooms: Room[] }>(`/sites/${id}`);
export const createSite = (data: Partial<Site>) =>
request<Site>('/sites', { method: 'POST', body: JSON.stringify(data) });
export const updateSite = (id: string, data: Partial<Site>) =>
request<Site>(`/sites/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteSite = (id: string) =>
request(`/sites/${id}`, { method: 'DELETE' });
// --- Rooms ---
export const getRooms = (siteId: string) => request<Room[]>(`/rooms?siteId=${siteId}`);
export const getRoom = (id: string) => request<Room>(`/rooms/${id}`);
export const createRoom = (data: Partial<Room>) =>
request<Room>('/rooms', { method: 'POST', body: JSON.stringify(data) });
export const updateRoom = (id: string, data: Partial<Room>) =>
request<Room>(`/rooms/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteRoom = (id: string) =>
request(`/rooms/${id}`, { method: 'DELETE' });
// --- Racks ---
export const getRacks = (roomId: string) => request<Rack[]>(`/racks?roomId=${roomId}`);
export const getRack = (id: string) => request<Rack>(`/racks/${id}`);
export const createRack = (data: Partial<Rack>) =>
request<Rack>('/racks', { method: 'POST', body: JSON.stringify(data) });
export const updateRack = (id: string, data: Partial<Rack>) =>
request<Rack>(`/racks/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteRack = (id: string) =>
request(`/racks/${id}`, { method: 'DELETE' });
// --- Components ---
export const getComponents = (rackId: string) => request<Component[]>(`/components?rackId=${rackId}`);
export const getComponent = (id: string) => request<Component>(`/components/${id}`);
export const createComponent = (data: Partial<Component>) =>
request<Component>('/components', { method: 'POST', body: JSON.stringify(data) });
export const updateComponent = (id: string, data: Partial<Component>) =>
request<Component>(`/components/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteComponent = (id: string) =>
request(`/components/${id}`, { method: 'DELETE' });
// --- Ports ---
export const getPorts = (componentId: string) =>
request<Port[]>(`/components/${componentId}/ports`);
export const createPort = (componentId: string, data: Partial<Port>) =>
request<Port>(`/components/${componentId}/ports`, { method: 'POST', body: JSON.stringify(data) });
export const updatePort = (componentId: string, portId: string, data: Partial<Port>) =>
request<Port>(`/components/${componentId}/ports/${portId}`, { method: 'PUT', body: JSON.stringify(data) });
export const deletePort = (componentId: string, portId: string) =>
request(`/components/${componentId}/ports/${portId}`, { method: 'DELETE' });
// --- Search ---
export const search = (q: string) => request<SearchResults>(`/search?q=${encodeURIComponent(q)}`);
// --- Users ---
export const getUsers = () => request<User[]>('/users');
export const getUser = (id: string) => request<User>(`/users/${id}`);
export const createUser = (data: { username: string; email?: string; role?: string }) =>
request<User>('/users', { method: 'POST', body: JSON.stringify(data) });
export const updateUser = (id: string, data: Partial<Pick<User, 'username' | 'email' | 'role' | 'is_active'>>) =>
request<User>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteUser = (id: string) =>
request(`/users/${id}`, { method: 'DELETE' });
export const rotateApiKey = (id: string) =>
request<{ api_key: string }>(`/users/${id}/api-key`, { method: 'POST' });
export const revokeApiKey = (id: string) =>
request(`/users/${id}/api-key`, { method: 'DELETE' });
// --- Audit ---
export const getAuditLog = (params: {
entity_type?: string; action?: string; user_id?: string;
limit?: number; offset?: number;
}) => {
const qs = new URLSearchParams(
Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => [k, String(v)])
).toString();
return request<{ total: number; limit: number; offset: number; entries: AuditEntry[] }>(
`/audit${qs ? `?${qs}` : ''}`
);
};
export const clearAuditLog = () =>
request('/settings/data/audit', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Confirm': 'yes' } });
// --- Settings ---
export const getDbStats = () => request<DbStats>('/settings/stats');
export const deleteAllData = () =>
request('/settings/data/all', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Confirm': 'yes' } });
@@ -0,0 +1,293 @@
import { useState, useEffect } from 'react';
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
import type { ComponentType, ComponentStatus } from '../types';
interface AddComponentProps {
rackId: string;
totalUnits: number;
initialPosition?: number;
onSave: (data: ComponentFormData) => Promise<void>;
onClose: () => void;
}
export interface ComponentFormData {
rack_id: string;
name: string;
type: ComponentType;
position?: number | null;
height_units: number;
port_count?: number | null;
sfp_count?: number | null;
manufacturer?: string;
model?: string;
serial_number?: string;
asset_tag?: string;
ip_address?: string;
mac_address?: string;
status: ComponentStatus;
notes: string;
}
export function AddComponentModal({ rackId, totalUnits, initialPosition, onSave, onClose }: AddComponentProps) {
const [form, setForm] = useState<ComponentFormData>({
rack_id: rackId,
name: '',
type: 'server',
position: initialPosition ?? null,
height_units: 1,
port_count: null,
sfp_count: null,
manufacturer: '',
model: '',
serial_number: '',
asset_tag: '',
ip_address: '',
mac_address: '',
status: 'active',
notes: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
function set(key: keyof ComponentFormData, value: unknown) {
setForm(f => ({ ...f, [key]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) { setError('Name is required'); return; }
setSaving(true);
setError('');
try {
await onSave({
...form,
position: form.position ? Number(form.position) : null,
height_units: Number(form.height_units),
port_count: (form.type === 'switch' || form.type === 'patch_panel') && form.port_count
? Number(form.port_count) : null,
sfp_count: form.type === 'switch' && form.sfp_count ? Number(form.sfp_count) : null,
manufacturer: form.manufacturer || undefined,
model: form.model || undefined,
serial_number: form.serial_number || undefined,
asset_tag: form.asset_tag || undefined,
ip_address: form.ip_address || undefined,
mac_address: form.mac_address || undefined,
});
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save');
setSaving(false);
}
}
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Add Component</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<form onSubmit={handleSubmit} className="modal-form">
<div className="form-row">
<div className="form-group form-group-lg">
<label>Name *</label>
<input
autoFocus
type="text"
value={form.name}
onChange={e => set('name', e.target.value)}
placeholder="e.g. Core Switch SW-01"
className="form-input"
/>
</div>
<div className="form-group">
<label>Type</label>
<select value={form.type} onChange={e => set('type', e.target.value)} className="form-input">
{COMPONENT_TYPES.map(t => (
<option key={t} value={t}>{COMPONENT_META[t].label}</option>
))}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Rack Position (U)</label>
<input
type="number"
min={1}
max={totalUnits}
value={form.position ?? ''}
onChange={e => set('position', e.target.value ? Number(e.target.value) : null)}
placeholder="auto"
className="form-input"
/>
<span className="form-hint">Leave empty to leave unpositioned</span>
</div>
<div className="form-group">
<label>Height (U)</label>
<input
type="number"
min={1}
max={totalUnits}
value={form.height_units}
onChange={e => set('height_units', Number(e.target.value))}
className="form-input"
/>
</div>
<div className="form-group">
<label>Status</label>
<select value={form.status} onChange={e => set('status', e.target.value)} className="form-input">
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="decommissioned">Decommissioned</option>
</select>
</div>
</div>
{(form.type === 'switch' || form.type === 'patch_panel') && (
<div className="form-row port-count-row">
<div className="form-group form-group-lg">
<label>
{form.type === 'patch_panel' ? '🔌 Available Patches (port count)' : '🔌 Number of Switch Ports'}
</label>
<input
type="number"
min={1}
max={96}
value={form.port_count ?? 24}
onChange={e => set('port_count', Number(e.target.value))}
className="form-input"
placeholder="24"
/>
<span className="form-hint">
{form.type === 'patch_panel'
? 'Number of RJ45 jacks on the front panel (e.g. 12, 24, 48)'
: 'Total ethernet ports shown on the rack diagram (e.g. 8, 16, 24, 48)'}
</span>
</div>
{form.type === 'switch' && (
<div className="form-group">
<label>🔶 Fiber / SFP Ports</label>
<input
type="number"
min={0}
max={16}
value={form.sfp_count ?? 0}
onChange={e => set('sfp_count', Number(e.target.value))}
className="form-input"
placeholder="0"
/>
<span className="form-hint">SFP / fiber uplink slots (016)</span>
</div>
)}
</div>
)}
<div className="form-row">
<div className="form-group">
<label>Manufacturer</label>
<input type="text" value={form.manufacturer} onChange={e => set('manufacturer', e.target.value)} placeholder="e.g. Cisco" className="form-input" />
</div>
<div className="form-group">
<label>Model</label>
<input type="text" value={form.model} onChange={e => set('model', e.target.value)} placeholder="e.g. Catalyst 2960" className="form-input" />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>IP Address</label>
<input type="text" value={form.ip_address} onChange={e => set('ip_address', e.target.value)} placeholder="192.168.1.1" className="form-input" />
</div>
<div className="form-group">
<label>MAC Address</label>
<input type="text" value={form.mac_address} onChange={e => set('mac_address', e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" className="form-input" />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Serial Number</label>
<input type="text" value={form.serial_number} onChange={e => set('serial_number', e.target.value)} className="form-input" />
</div>
<div className="form-group">
<label>Asset Tag</label>
<input type="text" value={form.asset_tag} onChange={e => set('asset_tag', e.target.value)} className="form-input" />
</div>
</div>
{error && <div className="form-error">{error}</div>}
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Add Component'}
</button>
</div>
</form>
</div>
</div>
);
}
// Generic modal for creating sites/rooms/racks
interface SimpleCreateProps {
title: string;
fields: { key: string; label: string; placeholder?: string; type?: string; defaultValue?: string | number }[];
onSave: (data: Record<string, string | number>) => Promise<void>;
onClose: () => void;
}
export function SimpleCreateModal({ title, fields, onSave, onClose }: SimpleCreateProps) {
const [values, setValues] = useState<Record<string, string | number>>(
Object.fromEntries(fields.map(f => [f.key, f.defaultValue ?? '']))
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError('');
try {
await onSave(values);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save');
setSaving(false);
}
}
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal modal-sm">
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<form onSubmit={handleSubmit} className="modal-form">
{fields.map(f => (
<div className="form-group" key={f.key}>
<label>{f.label}</label>
<input
autoFocus={fields[0].key === f.key}
type={f.type ?? 'text'}
value={values[f.key] as string}
onChange={e => setValues(v => ({ ...v, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))}
placeholder={f.placeholder}
className="form-input"
/>
</div>
))}
{error && <div className="form-error">{error}</div>}
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface Props {
value: string;
onChange: (val: string) => void;
onSave?: (val: string) => void;
autoSaveDelay?: number;
}
type EditorMode = 'edit' | 'split' | 'preview';
export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500 }: Props) {
const [mode, setMode] = useState<EditorMode>('split');
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSaved = useRef(value);
useEffect(() => {
if (!onSave) return;
if (value === lastSaved.current) { setSaveStatus('saved'); return; }
setSaveStatus('unsaved');
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
setSaveStatus('saving');
await onSave(value);
lastSaved.current = value;
setSaveStatus('saved');
}, autoSaveDelay);
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
}, [value, onSave, autoSaveDelay]);
return (
<div className="md-editor">
<div className="md-editor-toolbar">
<div className="md-mode-tabs">
{(['edit', 'split', 'preview'] as EditorMode[]).map(m => (
<button
key={m}
className={`md-mode-btn ${mode === m ? 'active' : ''}`}
onClick={() => setMode(m)}
>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
<div className="md-toolbar-actions">
{onSave && (
<span className={`save-status save-status-${saveStatus}`}>
{saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'}
</span>
)}
<span className="md-help-hint">Markdown supported · GFM tables, checkboxes</span>
</div>
</div>
<div className={`md-editor-body md-mode-${mode}`}>
{mode !== 'preview' && (
<textarea
className="md-textarea"
value={value}
onChange={e => onChange(e.target.value)}
placeholder="Write notes in Markdown…&#10;&#10;# Heading&#10;**bold**, *italic*, `code`&#10;&#10;- [ ] Checklist item&#10;&#10;| Port | Device |&#10;|------|--------|&#10;| 1 | PC-01 |"
spellCheck={false}
/>
)}
{mode !== 'edit' && (
<div className="md-preview">
{value.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
) : (
<div className="md-preview-empty">Nothing to preview yet. Start writing in the editor.</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,466 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import type { Rack, Component } from '../types';
import { COMPONENT_META } from '../types';
const UNIT_PX = 44;
interface SlotItem {
type: 'component' | 'empty';
position: number;
heightUnits: number;
component?: Component;
}
function buildSlots(totalUnits: number, components: Component[]): SlotItem[] {
const byPos = new Map<number, Component>();
for (const c of components) {
if (c.position != null) {
for (let u = c.position; u < c.position + c.height_units; u++) {
byPos.set(u, c);
}
}
}
const slots: SlotItem[] = [];
const seen = new Set<string>();
let u = 1;
while (u <= totalUnits) {
const comp = byPos.get(u);
if (comp && !seen.has(comp.id)) {
seen.add(comp.id);
slots.push({ type: 'component', position: u, heightUnits: comp.height_units, component: comp });
u += comp.height_units;
} else if (!comp) {
slots.push({ type: 'empty', position: u, heightUnits: 1 });
u++;
} else {
u++;
}
}
return slots;
}
// ─── Patch Panel ────────────────────────────────────────────────────────────
function PatchPanelFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const portCount = comp.port_count ?? 24;
const showNums = portCount <= 48;
const color = COMPONENT_META.patch_panel.color;
return (
<div className="hw-face hw-pp" style={{ height: heightPx }}>
<div className="hw-ports-main hw-pp-fullrow">
<div className="hw-pp-row">
{Array.from({ length: portCount }, (_, i) => (
<div key={i} className="hw-rj45-wrap" title={`Port ${i + 1}`}>
<div className="hw-rj45-jack" />
{showNums && <span className="hw-rj45-num">{i + 1}</span>}
</div>
))}
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>PATCH</span>
<span className="hw-lbl-name">{comp.name}</span>
<span className="hw-lbl-count" style={{ color }}>{portCount}P</span>
</div>
</div>
);
}
// ─── Switch ──────────────────────────────────────────────────────────────────
function SwitchFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const portCount = comp.port_count ?? 24;
const sfpCount = comp.sfp_count ?? 0;
const color = COMPONENT_META.switch.color;
const configuredNums = new Set((comp.ports ?? []).map(p => p.port_number));
const pairCount = Math.ceil(portCount / 2);
return (
<div className="hw-face hw-sw" style={{ height: heightPx }}>
{/* Main body: RJ45 pairs + optional SFP column */}
<div className="hw-sw-body">
{/* RJ45 area — fills all space left of the SFP column */}
<div className="hw-sw-rj45-area">
<div className="hw-sw-pairs-row">
{Array.from({ length: pairCount }, (_, col) => {
const top = col * 2 + 1;
const bot = col * 2 + 2;
const topActive = configuredNums.has(top);
const botActive = bot <= portCount && configuredNums.has(bot);
return (
<div key={col} className="hw-sw-pair">
<div className={`hw-sw-jack2${topActive ? ' hw-sw-jack2-active' : ''}`} title={`Port ${top}`}>
<div className={`hw-sw-led2 ${topActive ? 'hw-led-green' : 'hw-led-off'}`} />
</div>
{bot <= portCount ? (
<div className={`hw-sw-jack2${botActive ? ' hw-sw-jack2-active' : ''}`} title={`Port ${bot}`}>
<div className={`hw-sw-led2 ${botActive ? 'hw-led-green' : 'hw-led-off'}`} />
</div>
) : (
<div className="hw-sw-jack2 hw-sw-jack2-empty" />
)}
<span className="hw-sw-num2">{top}</span>
</div>
);
})}
</div>
</div>
{/* SFP column — only shown when sfp_count > 0 */}
{sfpCount > 0 && (
<div className="hw-sw-sfp-col" title={`${sfpCount} SFP/fiber uplink slots`}>
<span className="hw-sw-sfp-title">SFP</span>
{Array.from({ length: sfpCount }, (_, i) => (
<div key={i} className="hw-sw-sfp-slot" title={`SFP ${i + 1}`}>
<div className="hw-led hw-led-off" />
</div>
))}
</div>
)}
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>SWITCH</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
<span className="hw-lbl-count" style={{ color }}>{portCount}P{sfpCount > 0 ? ` +${sfpCount}F` : ''}</span>
</div>
</div>
);
}
// ─── Server ──────────────────────────────────────────────────────────────────
function ServerFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.server.color;
const driveCount = Math.min(8, comp.height_units * 4);
return (
<div className="hw-face hw-server" style={{ height: heightPx }}>
<div className="hw-ports-main hw-server-body">
<div className="hw-drive-bays">
{Array.from({ length: driveCount }, (_, i) => (
<div key={i} className="hw-drive-bay" title={`Drive ${i + 1}`} />
))}
</div>
<div className="hw-server-panel">
<div className="hw-power-btn" title="Power" />
<div className="hw-led hw-led-green" title="Online" />
<div className="hw-led hw-led-amber" title="Activity" />
<div className="hw-usb" title="USB" />
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>SERVER</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
</div>
</div>
);
}
// ─── Firewall ────────────────────────────────────────────────────────────────
function FirewallFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.firewall.color;
return (
<div className="hw-face hw-fw" style={{ height: heightPx }}>
<div className="hw-ports-main">
<div className="hw-sw-row">
<div className="hw-sw-port-wrap" title="WAN">
<div className="hw-led hw-led-amber" />
<div className="hw-eth-port hw-eth-wan" />
</div>
<div className="hw-sw-gap" />
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="hw-sw-port-wrap" title={`LAN ${i + 1}`}>
<div className="hw-led hw-led-green" />
<div className="hw-eth-port" />
</div>
))}
<div className="hw-sw-gap" />
<div className="hw-sw-port-wrap" title="DMZ">
<div className="hw-led hw-led-off" />
<div className="hw-eth-port" style={{ borderColor: '#6b7280' }} />
</div>
<div className="hw-sw-gap" />
<div className="hw-usb" title="Console" />
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>FIREWALL</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
</div>
</div>
);
}
// ─── Router ──────────────────────────────────────────────────────────────────
function RouterFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.router.color;
return (
<div className="hw-face hw-router" style={{ height: heightPx }}>
<div className="hw-ports-main">
<div className="hw-sw-row">
<div className="hw-sw-port-wrap" title="WAN">
<div className="hw-led hw-led-amber" />
<div className="hw-eth-port hw-eth-wan" />
</div>
<div className="hw-sw-gap" />
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="hw-sw-port-wrap" title={`LAN ${i + 1}`}>
<div className="hw-led hw-led-green" />
<div className="hw-eth-port" />
</div>
))}
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>ROUTER</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
</div>
</div>
);
}
// ─── UPS ─────────────────────────────────────────────────────────────────────
function UpsFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.ups.color;
return (
<div className="hw-face hw-ups" style={{ height: heightPx }}>
<div className="hw-ports-main hw-ups-body">
<div className="hw-ups-screen">
<div className="hw-ups-bar-outer">
<div className="hw-ups-bar-inner" style={{ width: '80%' }} />
</div>
<span className="hw-ups-pct">100%</span>
</div>
<div className="hw-ups-indicators">
<div className="hw-led hw-led-green" title="Online" />
<div className="hw-led hw-led-amber" title="Battery" />
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>UPS</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
</div>
</div>
);
}
// ─── PDU ─────────────────────────────────────────────────────────────────────
function PduFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.pdu.color;
return (
<div className="hw-face hw-pdu" style={{ height: heightPx }}>
<div className="hw-ports-main">
<div className="hw-sw-row">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="hw-pdu-outlet" title={`Outlet ${i + 1}`}>
<div className="hw-led hw-led-green" />
<div className="hw-outlet-socket" />
</div>
))}
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>PDU</span>
<span className="hw-lbl-name">{comp.name}</span>
</div>
</div>
);
}
// ─── KVM ─────────────────────────────────────────────────────────────────────
function KvmFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.kvm.color;
return (
<div className="hw-face hw-kvm" style={{ height: heightPx }}>
<div className="hw-ports-main hw-server-body">
<div className="hw-kvm-ports">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="hw-sw-port-wrap" title={`KVM ${i + 1}`}>
<div className="hw-led hw-led-off" />
<div className="hw-eth-port" />
</div>
))}
</div>
<div className="hw-server-panel">
<div className="hw-led hw-led-green" />
<div className="hw-usb" />
<div className="hw-usb" />
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>KVM</span>
<span className="hw-lbl-name">{comp.name}</span>
</div>
</div>
);
}
// ─── Storage ─────────────────────────────────────────────────────────────────
function StorageFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const color = COMPONENT_META.storage.color;
const driveCount = Math.min(12, comp.height_units * 4);
return (
<div className="hw-face hw-storage" style={{ height: heightPx }}>
<div className="hw-ports-main hw-server-body">
<div className="hw-drive-bays">
{Array.from({ length: driveCount }, (_, i) => (
<div key={i} className="hw-drive-bay hw-drive-bay-storage" title={`Disk ${i + 1}`} />
))}
</div>
<div className="hw-server-panel">
<div className="hw-power-btn" />
<div className="hw-led hw-led-green" />
<div className="hw-led hw-led-amber" />
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color }}>STORAGE</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
</div>
</div>
);
}
// ─── Default ─────────────────────────────────────────────────────────────────
function DefaultFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
const meta = COMPONENT_META[comp.type];
return (
<div className="hw-face" style={{ height: heightPx, background: meta.bg, borderLeft: `4px solid ${meta.color}` }}>
<div className="hw-ports-main hw-default-body">
<div className="hw-indicators-col">
<div className="hw-led hw-led-green" />
{comp.height_units > 1 && <div className="hw-led hw-led-amber" />}
</div>
</div>
<div className="hw-face-labels">
<span className="hw-lbl-type" style={{ color: meta.color }}>{meta.label.toUpperCase()}</span>
<span className="hw-lbl-name">{comp.name}</span>
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
</div>
</div>
);
}
function renderFace(comp: Component, heightPx: number): React.ReactNode {
switch (comp.type) {
case 'patch_panel': return <PatchPanelFace comp={comp} heightPx={heightPx} />;
case 'switch': return <SwitchFace comp={comp} heightPx={heightPx} />;
case 'server': return <ServerFace comp={comp} heightPx={heightPx} />;
case 'firewall': return <FirewallFace comp={comp} heightPx={heightPx} />;
case 'router': return <RouterFace comp={comp} heightPx={heightPx} />;
case 'ups': return <UpsFace comp={comp} heightPx={heightPx} />;
case 'pdu': return <PduFace comp={comp} heightPx={heightPx} />;
case 'kvm': return <KvmFace comp={comp} heightPx={heightPx} />;
case 'storage': return <StorageFace comp={comp} heightPx={heightPx} />;
default: return <DefaultFace comp={comp} heightPx={heightPx} />;
}
}
// ─── Main Component ──────────────────────────────────────────────────────────
interface Props {
rack: Rack;
components: Component[];
onAddAtSlot?: (position: number) => void;
}
export default function RackGraphicView({ rack, components, onAddAtSlot }: Props) {
const navigate = useNavigate();
const slots = buildSlots(rack.total_units, components);
return (
<div className="rack-gfx">
<div className="rack-gfx-cabinet">
{/* Top strip */}
<div className="rack-gfx-top">
<span className="rack-gfx-title">{rack.name}</span>
{rack.manufacturer && (
<span className="rack-gfx-sub">{rack.manufacturer} {rack.model}</span>
)}
<span className="rack-gfx-units-badge">{rack.total_units}U</span>
</div>
{/* Slots */}
<div className="rack-gfx-body">
{slots.map(slot => {
const heightPx = slot.heightUnits * UNIT_PX;
if (slot.type === 'empty') {
return (
<div
key={`e-${slot.position}`}
className="rack-gfx-slot rack-gfx-empty"
style={{ height: heightPx }}
onClick={() => onAddAtSlot?.(slot.position)}
>
<span className="rack-gfx-u">{slot.position}</span>
<span className="rack-gfx-empty-txt">· empty ·</span>
<span className="rack-gfx-add-hint">+ add</span>
</div>
);
}
const comp = slot.component!;
return (
<div
key={comp.id}
className="rack-gfx-slot rack-gfx-occupied"
style={{ height: heightPx }}
onClick={() => navigate(`/components/${comp.id}`)}
title={`${comp.name} — click to open`}
>
<span className="rack-gfx-u">{slot.position}</span>
<div className="rack-gfx-face">
{renderFace(comp, heightPx)}
</div>
<div className="rack-gfx-screws">
<div className="rack-gfx-screw" />
{slot.heightUnits > 1 && <div className="rack-gfx-screw" />}
</div>
</div>
);
})}
</div>
{/* Bottom strip */}
<div className="rack-gfx-bottom" />
</div>
{/* Unpositioned */}
{components.filter(c => c.position == null).length > 0 && (
<div className="rack-unpositioned" style={{ marginTop: 16 }}>
<div className="rack-unpositioned-title">Unpositioned Components</div>
<div className="rack-unpositioned-list">
{components.filter(c => c.position == null).map(comp => {
const meta = COMPONENT_META[comp.type];
return (
<div
key={comp.id}
className="rack-unpositioned-item"
style={{ borderLeft: `3px solid ${meta.color}` }}
onClick={() => navigate(`/components/${comp.id}`)}
>
<span style={{ color: meta.color, fontSize: 11 }}>{meta.label}</span>
<span className="rack-unpositioned-name">{comp.name}</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,186 @@
import { useNavigate } from 'react-router-dom';
import type { Rack, Component } from '../types';
import { COMPONENT_META } from '../types';
interface Props {
rack: Rack;
components: Component[];
onAddAtSlot?: (position: number) => void;
onRefresh?: () => void;
}
interface SlotItem {
type: 'component' | 'empty';
component?: Component;
position: number;
heightUnits: number;
}
function buildSlots(totalUnits: number, components: Component[]): SlotItem[] {
// Map each U position to the component occupying it
const byPosition = new Map<number, Component>();
for (const c of components) {
if (c.position != null) {
for (let u = c.position; u < c.position + c.height_units; u++) {
byPosition.set(u, c);
}
}
}
const items: SlotItem[] = [];
const seen = new Set<string>();
let u = 1;
while (u <= totalUnits) {
const comp = byPosition.get(u);
if (comp && !seen.has(comp.id)) {
seen.add(comp.id);
items.push({ type: 'component', component: comp, position: comp.position!, heightUnits: comp.height_units });
u += comp.height_units;
} else if (!comp) {
items.push({ type: 'empty', position: u, heightUnits: 1 });
u++;
} else {
// occupied by a comp already rendered
u++;
}
}
return items;
}
const UNIT_HEIGHT = 30; // px per U
export default function RackVisual({ rack, components, onAddAtSlot }: Props) {
const navigate = useNavigate();
const slots = buildSlots(rack.total_units, components);
const usedUnits = components.reduce((sum, c) => sum + c.height_units, 0);
const freeUnits = rack.total_units - usedUnits;
return (
<div className="rack-wrapper">
<div className="rack-stats-bar">
<span className="rack-stat"><strong>{rack.total_units}U</strong> total</span>
<span className="rack-stat rack-stat-used"><strong>{usedUnits}U</strong> used</span>
<span className="rack-stat rack-stat-free"><strong>{freeUnits}U</strong> free</span>
<span className="rack-stat">{components.length} devices</span>
</div>
<div className="rack-cabinet">
{/* Rack top strip */}
<div className="rack-top-strip">
<span className="rack-label-text">{rack.name}</span>
{rack.manufacturer && <span className="rack-label-sub">{rack.manufacturer} {rack.model}</span>}
</div>
{/* Rack body */}
<div className="rack-body">
{slots.map(slot => {
const heightPx = slot.heightUnits * UNIT_HEIGHT;
if (slot.type === 'empty') {
return (
<div
key={`empty-${slot.position}`}
className="rack-slot rack-slot-empty"
style={{ height: `${heightPx}px` }}
title={`Slot ${slot.position}U — click to add component`}
onClick={() => onAddAtSlot?.(slot.position)}
>
<span className="rack-unit-num">{slot.position}</span>
<span className="rack-empty-label">· empty ·</span>
<span className="rack-add-hint">+ add</span>
</div>
);
}
const comp = slot.component!;
const meta = COMPONENT_META[comp.type];
const statusDot = comp.status === 'active' ? '🟢' : comp.status === 'maintenance' ? '🟡' : '🔴';
return (
<div
key={comp.id}
className="rack-slot rack-slot-component"
style={{
height: `${heightPx}px`,
backgroundColor: meta.bg,
borderLeft: `4px solid ${meta.color}`,
borderBottom: `1px solid ${meta.border}`,
}}
title={`${comp.name}${meta.label} (${comp.height_units}U) — Click to view`}
onClick={() => navigate(`/components/${comp.id}`)}
>
<span className="rack-unit-num" style={{ color: meta.color }}>
{slot.position}
</span>
<div className="rack-component-body">
<div className="rack-component-top">
<span className="rack-component-type-badge" style={{ color: meta.color }}>
{meta.label}
</span>
<span className="rack-component-name" style={{ color: '#f1f5f9' }}>
{comp.name}
</span>
<span className="rack-component-status">{statusDot}</span>
</div>
{comp.height_units > 1 && (
<div className="rack-component-sub">
{comp.model && <span className="rack-component-model">{comp.model}</span>}
{comp.ip_address && <span className="rack-component-ip">{comp.ip_address}</span>}
<span className="rack-component-units" style={{ color: meta.color }}>{comp.height_units}U</span>
</div>
)}
{comp.height_units === 1 && (
<>
{comp.model && (
<span className="rack-component-model-inline"> · {comp.model}</span>
)}
{comp.ip_address && (
<span className="rack-component-ip-inline"> · {comp.ip_address}</span>
)}
<span className="rack-component-units-inline" style={{ color: meta.color }}>
{' '}{comp.height_units}U
</span>
</>
)}
</div>
{/* Rack screw holes */}
<div className="rack-screw-col">
<div className="rack-screw" />
{slot.heightUnits > 1 && <div className="rack-screw" />}
</div>
</div>
);
})}
</div>
{/* Rack bottom strip */}
<div className="rack-bottom-strip" />
</div>
{/* Unpositioned components */}
{components.filter(c => c.position == null).length > 0 && (
<div className="rack-unpositioned">
<h4 className="rack-unpositioned-title">Unpositioned Components</h4>
<div className="rack-unpositioned-list">
{components.filter(c => c.position == null).map(comp => {
const meta = COMPONENT_META[comp.type];
return (
<div
key={comp.id}
className="rack-unpositioned-item"
style={{ borderLeft: `3px solid ${meta.color}` }}
onClick={() => navigate(`/components/${comp.id}`)}
>
<span style={{ color: meta.color }}>{meta.label}</span>
<span className="rack-unpositioned-name">{comp.name}</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,235 @@
import { useState, useEffect, useCallback } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import * as api from '../api';
import type { Site, Room, Rack } from '../types';
interface TreeRoom extends Room {
racks: Rack[];
expanded: boolean;
}
interface TreeSite extends Site {
rooms: TreeRoom[];
expanded: boolean;
}
export default function Sidebar() {
const [tree, setTree] = useState<TreeSite[]>([]);
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<Awaited<ReturnType<typeof api.search>> | null>(null);
const [loading, setLoading] = useState(true);
const [addSiteName, setAddSiteName] = useState('');
const [showAddSite, setShowAddSite] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const loadTree = useCallback(async () => {
const sites = await api.getSites();
setTree(sites.map(s => ({ ...s, rooms: [], expanded: false })));
setLoading(false);
}, []);
useEffect(() => { loadTree(); }, [loadTree]);
const expandSite = async (siteId: string) => {
setTree(prev => prev.map(s => {
if (s.id !== siteId) return s;
if (s.expanded) return { ...s, expanded: false };
return { ...s, expanded: true };
}));
// Load rooms if not loaded
setTree(prev => {
const site = prev.find(s => s.id === siteId);
if (site && site.rooms.length === 0) {
api.getSite(siteId).then(full => {
setTree(p => p.map(s => s.id === siteId
? { ...s, rooms: (full.rooms ?? []).map(r => ({ ...r, racks: [], expanded: false })) }
: s
));
});
}
return prev;
});
};
const expandRoom = async (siteId: string, roomId: string) => {
setTree(prev => prev.map(s => s.id !== siteId ? s : {
...s,
rooms: s.rooms.map(r => {
if (r.id !== roomId) return r;
if (r.expanded) return { ...r, expanded: false };
// Load racks
if (r.racks.length === 0) {
api.getRacks(roomId).then(racks => {
setTree(p => p.map(ss => ss.id !== siteId ? ss : {
...ss,
rooms: ss.rooms.map(rr => rr.id === roomId ? { ...rr, racks } : rr),
}));
});
}
return { ...r, expanded: true };
}),
}));
};
const handleAddSite = async (e: React.FormEvent) => {
e.preventDefault();
if (!addSiteName.trim()) return;
const site = await api.createSite({ name: addSiteName.trim() });
setAddSiteName('');
setShowAddSite(false);
await loadTree();
navigate(`/sites/${site.id}`);
};
useEffect(() => {
if (!search.trim()) { setSearchResults(null); return; }
const t = setTimeout(async () => {
const r = await api.search(search);
setSearchResults(r);
}, 300);
return () => clearTimeout(t);
}, [search]);
const isActive = (path: string) => location.pathname === path;
return (
<aside className="sidebar">
<div className="sidebar-header">
<Link to="/" className="sidebar-logo">
<span className="logo-icon"></span>
<span className="logo-text">NetworkView</span>
</Link>
</div>
<div className="sidebar-search">
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="search-input"
/>
{searchResults && (
<div className="search-results">
{searchResults.sites.map(s => (
<Link key={s.id} to={`/sites/${s.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🏢</span> {s.name}
</Link>
))}
{searchResults.rooms.map(r => (
<Link key={r.id} to={`/rooms/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🚪</span> {r.name}
</Link>
))}
{searchResults.racks.map(r => (
<Link key={r.id} to={`/racks/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🗄</span> {r.name}
</Link>
))}
{searchResults.components.map(c => (
<Link key={c.id} to={`/components/${c.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🔧</span> {c.name}
{c.ip_address && <span className="search-result-sub"> {c.ip_address}</span>}
</Link>
))}
{!searchResults.sites.length && !searchResults.rooms.length &&
!searchResults.racks.length && !searchResults.components.length && (
<div className="search-no-results">No results found</div>
)}
</div>
)}
</div>
<nav className="sidebar-nav">
<div className="sidebar-section-header">
<span>Sites</span>
<button className="icon-btn" title="Add site" onClick={() => setShowAddSite(v => !v)}>+</button>
</div>
{showAddSite && (
<form onSubmit={handleAddSite} className="inline-add-form">
<input
autoFocus
type="text"
placeholder="Site name..."
value={addSiteName}
onChange={e => setAddSiteName(e.target.value)}
className="inline-input"
/>
<button type="submit" className="inline-btn">Add</button>
<button type="button" className="inline-btn secondary" onClick={() => setShowAddSite(false)}></button>
</form>
)}
{loading && <div className="sidebar-loading">Loading...</div>}
{tree.map(site => (
<div key={site.id} className="tree-node">
<div className={`tree-item tree-site ${isActive(`/sites/${site.id}`) ? 'active' : ''}`}>
<button className="tree-expand" onClick={() => expandSite(site.id)}>
{site.expanded ? '▾' : '▸'}
</button>
<Link to={`/sites/${site.id}`} className="tree-label">
<span className="tree-icon">🏢</span>
<span className="tree-name">{site.name}</span>
{site.room_count != null && (
<span className="tree-count">{site.room_count}</span>
)}
</Link>
</div>
{site.expanded && (
<div className="tree-children">
{site.rooms.map(room => (
<div key={room.id} className="tree-node">
<div className={`tree-item tree-room ${isActive(`/rooms/${room.id}`) ? 'active' : ''}`}>
<button className="tree-expand" onClick={() => expandRoom(site.id, room.id)}>
{room.expanded ? '▾' : '▸'}
</button>
<Link to={`/rooms/${room.id}`} className="tree-label">
<span className="tree-icon">🚪</span>
<span className="tree-name">{room.name}</span>
</Link>
</div>
{room.expanded && (
<div className="tree-children">
{room.racks.map(rack => (
<div key={rack.id} className={`tree-item tree-rack ${isActive(`/racks/${rack.id}`) ? 'active' : ''}`}>
<Link to={`/racks/${rack.id}`} className="tree-label">
<span className="tree-icon">🗄</span>
<span className="tree-name">{rack.name}</span>
<span className="tree-count">{rack.total_units}U</span>
</Link>
</div>
))}
</div>
)}
</div>
))}
{site.rooms.length === 0 && (
<div className="tree-empty">No rooms yet</div>
)}
</div>
)}
</div>
))}
{!loading && tree.length === 0 && (
<div className="sidebar-empty">
<p>No sites yet.</p>
<button className="btn-primary btn-sm" onClick={() => setShowAddSite(true)}>
+ Add your first site
</button>
</div>
)}
</nav>
<div className="sidebar-footer">
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
Settings
</Link>
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,647 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import * as api from '../api';
import type { Component, Port } from '../types';
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
import MarkdownEditor from '../components/MarkdownEditor';
const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const;
export default function ComponentPage() {
const { componentId } = useParams<{ componentId: string }>();
const navigate = useNavigate();
const [component, setComponent] = useState<Component | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [notes, setNotes] = useState('');
const [form, setForm] = useState<Partial<Component>>({});
const [saving, setSaving] = useState(false);
type PortModalState = { editPort: Port | null };
const BLANK_PORT_FORM = { port_number: '', label: '', port_type: 'RJ45', notes: '', connected_to_port_id: '' };
const [portModal, setPortModal] = useState<PortModalState | null>(null);
const [portForm, setPortForm] = useState(BLANK_PORT_FORM);
const [patchPanels, setPatchPanels] = useState<Component[]>([]);
const [switches, setSwitches] = useState<Component[]>([]);
const [swLink, setSwLink] = useState<{ switchId: string; portNumber: string; portId: string | null } | null>(null);
const [portError, setPortError] = useState<string | null>(null);
const loadComponent = useCallback(async () => {
if (!componentId) return;
const c = await api.getComponent(componentId);
setComponent(c);
setNotes(c.notes ?? '');
setForm({
name: c.name, type: c.type, position: c.position, height_units: c.height_units,
manufacturer: c.manufacturer, model: c.model, serial_number: c.serial_number,
asset_tag: c.asset_tag, ip_address: c.ip_address, mac_address: c.mac_address, status: c.status,
port_count: c.port_count,
sfp_count: c.sfp_count,
});
setLoading(false);
if (c.type === 'switch' && c.rack?.id) {
const all = await api.getComponents(c.rack.id);
setPatchPanels(all.filter(comp => comp.type === 'patch_panel'));
}
if (c.type === 'patch_panel' && c.rack?.id) {
const all = await api.getComponents(c.rack.id);
setSwitches(all.filter(comp => comp.type === 'switch'));
}
}, [componentId]);
useEffect(() => { loadComponent(); }, [loadComponent]);
const saveNotes = async (val: string) => {
if (!componentId) return;
await api.updateComponent(componentId, { notes: val });
};
const saveMeta = async () => {
if (!componentId) return;
setSaving(true);
try {
await api.updateComponent(componentId, form);
await loadComponent();
setEditing(false);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!componentId || !component || !confirm(`Delete component "${component.name}"?`)) return;
await api.deleteComponent(componentId);
navigate(component.rack ? `/racks/${component.rack.id}` : '/');
};
const handleDeletePort = async (portId: string) => {
if (!componentId || !confirm('Delete this port?')) return;
await api.deletePort(componentId, portId);
await loadComponent();
};
const openPortModal = (port?: Port) => {
setPortError(null);
setPortModal({ editPort: port ?? null });
setPortForm(port ? {
port_number: String(port.port_number),
label: port.label ?? '',
port_type: port.port_type,
notes: port.notes ?? '',
connected_to_port_id: port.connected_to_port_id ?? '',
} : BLANK_PORT_FORM);
if (port?.linked_port?.component_type === 'switch') {
setSwLink({ switchId: port.linked_port.component_id, portNumber: String(port.linked_port.port_number), portId: port.linked_port.id });
} else {
setSwLink(null);
}
};
const closePortModal = () => { setPortModal(null); setSwLink(null); setPortError(null); };
const handleSavePort = async (e: React.FormEvent) => {
e.preventDefault();
if (!componentId || !portForm.port_number) return;
setPortError(null);
// Patch-panel only: validate no duplicate port number and capacity limit
if (component?.type === 'patch_panel' && !portModal?.editPort) {
const existingPorts: Port[] = component.ports ?? [];
const portNum = Number(portForm.port_number);
const maxPorts = component.port_count ?? Infinity;
if (existingPorts.some(p => p.port_number === portNum)) {
setPortError(`Port ${portNum} is already added. Each port number can only appear once.`);
return;
}
if (existingPorts.length >= maxPorts) {
setPortError(`This patch panel is full (${maxPorts} ports). Delete an existing port to add a new one.`);
return;
}
if (portNum < 1 || portNum > maxPorts) {
setPortError(`Port number must be between 1 and ${maxPorts}.`);
return;
}
}
// Resolve connected_to_port_id for patch panels via swLink
let connectedToPortId: string | null | undefined = portForm.connected_to_port_id || null;
if (component?.type === 'patch_panel') {
if (swLink?.switchId && swLink?.portNumber) {
if (swLink.portId) {
connectedToPortId = swLink.portId;
} else {
// Switch port record doesn't exist yet create it first
const newSwPort = await api.createPort(swLink.switchId, {
port_number: Number(swLink.portNumber),
port_type: 'RJ45',
});
connectedToPortId = newSwPort.id;
}
} else {
connectedToPortId = null;
}
}
if (portModal?.editPort) {
await api.updatePort(componentId, portModal.editPort.id, {
label: portForm.label || undefined,
port_type: portForm.port_type,
notes: portForm.notes || undefined,
connected_to_port_id: connectedToPortId,
});
} else {
await api.createPort(componentId, {
port_number: Number(portForm.port_number),
label: portForm.label || undefined,
port_type: portForm.port_type,
notes: portForm.notes || undefined,
connected_to_port_id: connectedToPortId ?? undefined,
});
}
setPortModal(null);
setSwLink(null);
setPortError(null);
await loadComponent();
};
const setField = (key: keyof Component, val: unknown) =>
setForm(f => ({ ...f, [key]: val }));
if (loading || !component) return <div className="page-loading">Loading</div>;
const meta = COMPONENT_META[component.type];
const ports: Port[] = component.ports ?? [];
return (
<div className="page">
<div className="page-header">
<div className="breadcrumb">
<Link to="/">Dashboard</Link>
{component.site && <><span> / </span><Link to={`/sites/${component.site.id}`}>{component.site.name}</Link></>}
{component.room && <><span> / </span><Link to={`/rooms/${component.room.id}`}>{component.room.name}</Link></>}
{component.rack && <><span> / </span><Link to={`/racks/${component.rack.id}`}>{component.rack.name}</Link></>}
<span> / </span>
<span>{component.name}</span>
</div>
<div className="page-actions">
{component.rack && (
<button className="btn-back btn-sm" onClick={() => navigate(`/racks/${component.rack!.id}`)}> Back to Rack</button>
)}
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
{editing ? 'Cancel' : '✎ Edit'}
</button>
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
</div>
</div>
{/* Component Header */}
<div className="component-header" style={{ borderLeft: `6px solid ${meta.color}` }}>
<div className="component-type-badge" style={{ background: meta.bg, color: meta.color }}>
{meta.label}
</div>
<h1 className="entity-title">{component.name}</h1>
<span className={`status-badge status-${component.status}`}>{component.status}</span>
</div>
{/* Meta fields */}
<section className="content-section">
{editing ? (
<div className="edit-meta-form">
<div className="form-row">
<div className="form-group form-group-lg">
<label>Name</label>
<input className="form-input" value={form.name ?? ''} onChange={e => setField('name', e.target.value)} />
</div>
<div className="form-group">
<label>Type</label>
<select className="form-input" value={form.type ?? 'other'} onChange={e => setField('type', e.target.value)}>
{COMPONENT_TYPES.map(t => <option key={t} value={t}>{COMPONENT_META[t].label}</option>)}
</select>
</div>
<div className="form-group">
<label>Status</label>
<select className="form-input" value={form.status ?? 'active'} onChange={e => setField('status', e.target.value)}>
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Position (U)</label>
<input type="number" className="form-input" value={form.position ?? ''} onChange={e => setField('position', e.target.value ? Number(e.target.value) : null)} />
</div>
<div className="form-group">
<label>Height (U)</label>
<input type="number" className="form-input" value={form.height_units ?? 1} onChange={e => setField('height_units', Number(e.target.value))} />
</div>
{(form.type === 'switch' || form.type === 'patch_panel') && (
<div className="form-group">
<label>{form.type === 'patch_panel' ? 'Available Patches' : 'Switch Ports'}</label>
<input
type="number" min={1} max={96} className="form-input"
value={form.port_count ?? 24}
onChange={e => setField('port_count', Number(e.target.value))}
/>
<span className="form-hint">
{form.type === 'patch_panel' ? 'RJ45 jacks on front panel' : 'Ethernet port count'}
</span>
</div>
)}
{form.type === 'switch' && (
<div className="form-group">
<label>Fiber / SFP Ports</label>
<input
type="number" min={0} max={16} className="form-input"
value={form.sfp_count ?? 0}
onChange={e => setField('sfp_count', Number(e.target.value))}
/>
<span className="form-hint">SFP / fiber uplink slots</span>
</div>
)}
</div>
<div className="form-row">
<div className="form-group">
<label>Manufacturer</label>
<input className="form-input" value={form.manufacturer ?? ''} onChange={e => setField('manufacturer', e.target.value)} />
</div>
<div className="form-group">
<label>Model</label>
<input className="form-input" value={form.model ?? ''} onChange={e => setField('model', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>IP Address</label>
<input className="form-input" value={form.ip_address ?? ''} onChange={e => setField('ip_address', e.target.value)} />
</div>
<div className="form-group">
<label>MAC Address</label>
<input className="form-input" value={form.mac_address ?? ''} onChange={e => setField('mac_address', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Serial Number</label>
<input className="form-input" value={form.serial_number ?? ''} onChange={e => setField('serial_number', e.target.value)} />
</div>
<div className="form-group">
<label>Asset Tag</label>
<input className="form-input" value={form.asset_tag ?? ''} onChange={e => setField('asset_tag', e.target.value)} />
</div>
</div>
<div className="form-actions">
<button className="btn-primary btn-sm" onClick={saveMeta} disabled={saving}>
{saving ? 'Saving…' : 'Save Changes'}
</button>
<button className="btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
</div>
</div>
) : (
<div className="component-meta-grid">
<MetaField label="Type" value={meta.label} />
<MetaField label="Status" value={component.status} />
<MetaField label="Rack Position" value={component.position != null ? `${component.position}U` : '—'} />
<MetaField label="Height" value={`${component.height_units}U`} />
<MetaField label="Manufacturer" value={component.manufacturer} />
<MetaField label="Model" value={component.model} />
<MetaField label="IP Address" value={component.ip_address} />
<MetaField label="MAC Address" value={component.mac_address} />
<MetaField label="Serial Number" value={component.serial_number} />
<MetaField label="Asset Tag" value={component.asset_tag} />
{(component.type === 'switch' || component.type === 'patch_panel') && (
<MetaField
label={component.type === 'patch_panel' ? 'Available Patches' : 'Port Count'}
value={component.port_count != null ? `${component.port_count} ports` : null}
/>
)}
{component.type === 'switch' && (
<MetaField
label="Fiber / SFP Ports"
value={component.sfp_count != null ? `${component.sfp_count} SFP slots` : null}
/>
)}
</div>
)}
</section>
{/* Ports */}
<section className="content-section">
<div className="section-header">
<h2 className="section-title">Ports / Connections</h2>
<button className="btn-primary btn-sm" onClick={() => openPortModal()}>
+ {component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port'}
</button>
</div>
{ports.length === 0 ? (
<div className="empty-state-sm">No ports documented yet.</div>
) : (
<div className="ports-grid">
{ports.map(port => (
<div key={port.id} className="port-item">
<div className="port-number">{port.port_number}</div>
<div className="port-info">
<div className="port-label">{port.label || '—'}</div>
{component.type !== 'patch_panel' && (
<div className="port-type">{port.port_type}</div>
)}
{port.notes && <div className="port-notes">{port.notes}</div>}
{port.linked_port && (
<div className="port-link-chain">
{component.type === 'switch'
? `${port.linked_port.component_name} : P${port.linked_port.port_number}${port.linked_port.label ? `${port.linked_port.label}` : ''}`
: `${port.linked_port.component_name} : P${port.linked_port.port_number}`
}
</div>
)}
</div>
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port"></button>
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port"></button>
</div>
))}
</div>
)}
</section>
{/* Port Modal */}
{portModal !== null && (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && closePortModal()}>
<div className="modal">
<div className="modal-header">
<span className="modal-title">
{portModal.editPort
? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}`
: `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`}
</span>
<button className="modal-close" onClick={closePortModal}></button>
</div>
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-row">
<div className="form-group">
<label>{component.type === 'patch_panel' ? 'PP Port #' : 'SW Port #'}</label>
<input
type="number" className="form-input"
min={1}
max={component.type === 'patch_panel' && component.port_count ? component.port_count : undefined}
value={portForm.port_number}
onChange={e => setPortForm(f => ({ ...f, port_number: e.target.value }))}
required
disabled={!!portModal.editPort}
/>
{component.type === 'patch_panel' && component.port_count && !portModal.editPort && (
<span className="form-hint">Port 1{component.port_count}</span>
)}
</div>
<div className="form-group form-group-lg">
<label>{component.type === 'patch_panel' ? 'End Device' : 'Label'}</label>
<input
className="form-input"
value={portForm.label}
onChange={e => setPortForm(f => ({ ...f, label: e.target.value }))}
placeholder={component.type === 'patch_panel' ? 'e.g. Desktop 1, Printer, IP Camera' : 'e.g. Uplink-01'}
autoFocus={!portModal.editPort}
/>
</div>
{component.type !== 'patch_panel' && (
<div className="form-group">
<label>Type</label>
<select className="form-input" value={portForm.port_type}
onChange={e => setPortForm(f => ({ ...f, port_type: e.target.value }))}>
{['RJ45', 'SFP', 'SFP+', 'QSFP', 'LC', 'SC', 'Serial', 'USB', 'HDMI'].map(t =>
<option key={t} value={t}>{t}</option>
)}
</select>
</div>
)}
<div className="form-group">
<label>Notes</label>
<input
className="form-input"
value={portForm.notes}
onChange={e => setPortForm(f => ({ ...f, notes: e.target.value }))}
placeholder={component.type === 'patch_panel' ? 'e.g. Room A, Floor 2' : 'e.g. VLAN 10'}
/>
</div>
</div>
{component.type === 'switch' && patchPanels.length > 0 && (
<PpLinkPicker
key={portModal.editPort?.id ?? 'add'}
patchPanels={patchPanels}
initialPpId={
portModal.editPort?.linked_port?.component_type === 'patch_panel'
? portModal.editPort.linked_port.component_id
: undefined
}
value={portForm.connected_to_port_id}
onChange={id => setPortForm(f => ({ ...f, connected_to_port_id: id }))}
/>
)}
{component.type === 'patch_panel' && (
<SwLinkPicker
key={portModal.editPort?.id ?? 'add'}
switches={switches}
initialSwitchId={swLink?.switchId}
initialPortNumber={swLink?.portNumber}
currentLinkedPortId={swLink?.portId}
onChange={setSwLink}
/>
)}
{portError && (
<div style={{ color: '#f87171', fontSize: 13, background: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 6, padding: '8px 12px' }}>
{portError}
</div>
)}
<div className="form-actions">
<button type="submit" className="btn-primary btn-sm">
{portModal.editPort ? 'Save Changes' : (component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port')}
</button>
<button type="button" className="btn-secondary btn-sm" onClick={closePortModal}>Cancel</button>
</div>
</form>
</div>
</div>
)}
{/* Notes */}
<section className="content-section">
<h2 className="section-title">Documentation</h2>
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
</section>
</div>
);
}
function MetaField({ label, value }: { label: string; value?: string | number | null }) {
if (!value && value !== 0) return null;
return (
<div className="meta-field">
<span className="meta-field-label">{label}</span>
<span className="meta-field-value">{value}</span>
</div>
);
}
function PpLinkPicker({
patchPanels,
value,
initialPpId,
onChange,
}: {
patchPanels: Component[];
value: string;
initialPpId?: string;
onChange: (portId: string) => void;
}) {
const [selectedPpId, setSelectedPpId] = useState(initialPpId ?? '');
const [ppPorts, setPpPorts] = useState<Port[]>([]);
const [loadingPp, setLoadingPp] = useState(false);
useEffect(() => {
if (!selectedPpId) { setPpPorts([]); return; }
setLoadingPp(true);
api.getComponent(selectedPpId).then(pp => {
setPpPorts(pp.ports ?? []);
setLoadingPp(false);
});
}, [selectedPpId]);
const selectedPort = ppPorts.find(p => p.id === value);
return (
<div className="pp-link-picker">
<span className="pp-link-label">🔗 Link to Patch Panel</span>
<div className="pp-link-row">
<select
className="form-input form-input-sm"
value={selectedPpId}
onChange={e => { setSelectedPpId(e.target.value); onChange(''); }}
>
<option value=""> no patch panel </option>
{patchPanels.map(pp => (
<option key={pp.id} value={pp.id}>{pp.name}</option>
))}
</select>
{selectedPpId && (
<select
className="form-input form-input-sm"
value={value}
onChange={e => onChange(e.target.value)}
disabled={loadingPp}
>
<option value=""> select port </option>
{ppPorts.map(p => (
<option key={p.id} value={p.id}>
Port {p.port_number}{p.label ? `${p.label}` : ''}
</option>
))}
</select>
)}
</div>
{selectedPort?.label && (
<div className="pp-link-device-hint">
End device: <strong>{selectedPort.label}</strong>
</div>
)}
{value && (
<button
type="button"
className="pp-link-clear"
onClick={() => { onChange(''); setSelectedPpId(''); }}
>
Clear link
</button>
)}
</div>
);
}
function SwLinkPicker({
switches,
initialSwitchId,
initialPortNumber,
currentLinkedPortId,
onChange,
}: {
switches: Component[];
initialSwitchId?: string;
initialPortNumber?: string;
currentLinkedPortId?: string | null;
onChange: (link: { switchId: string; portNumber: string; portId: string | null } | null) => void;
}) {
const [selectedSwitchId, setSelectedSwitchId] = useState(initialSwitchId ?? '');
const [switchData, setSwitchData] = useState<Component | null>(null);
const [loadingSwitch, setLoadingSwitch] = useState(false);
const [selectedPortNumber, setSelectedPortNumber] = useState(initialPortNumber ?? '');
useEffect(() => {
if (!selectedSwitchId) { setSwitchData(null); return; }
setLoadingSwitch(true);
api.getComponent(selectedSwitchId).then(sw => {
setSwitchData(sw);
setLoadingSwitch(false);
});
}, [selectedSwitchId]);
const portCount = switchData?.port_count ?? 24;
const handleSwitchChange = (swId: string) => {
setSelectedSwitchId(swId);
setSelectedPortNumber('');
onChange(null);
};
const handlePortChange = (portNumberStr: string) => {
setSelectedPortNumber(portNumberStr);
if (!portNumberStr || !selectedSwitchId) { onChange(null); return; }
const existingPort = (switchData?.ports ?? []).find(p => p.port_number === Number(portNumberStr));
onChange({ switchId: selectedSwitchId, portNumber: portNumberStr, portId: existingPort?.id ?? null });
};
return (
<div className="pp-link-picker">
<span className="pp-link-label">🔗 Link to Switch Port <span style={{ color: 'var(--text3)', fontWeight: 400 }}>(optional)</span></span>
<div className="pp-link-row">
<select
className="form-input form-input-sm"
value={selectedSwitchId}
onChange={e => handleSwitchChange(e.target.value)}
>
<option value=""> no switch </option>
{switches.map(sw => (
<option key={sw.id} value={sw.id}>{sw.name}</option>
))}
</select>
{selectedSwitchId && !loadingSwitch && switchData && (
<select
className="form-input form-input-sm"
value={selectedPortNumber}
onChange={e => handlePortChange(e.target.value)}
>
<option value=""> select port </option>
{Array.from({ length: portCount }, (_, i) => i + 1).map(n => {
const existingPort = (switchData.ports ?? []).find(p => p.port_number === n);
const isOccupied = existingPort?.connected_to_port_id && existingPort.id !== currentLinkedPortId;
return (
<option key={n} value={String(n)} disabled={!!isOccupied}>
Port {n}{existingPort?.label ? `${existingPort.label}` : ''}{isOccupied ? ' (in use)' : ''}
</option>
);
})}
</select>
)}
{loadingSwitch && <span style={{ color: 'var(--text3)', fontSize: 12 }}>Loading</span>}
{switches.length === 0 && (
<span style={{ color: 'var(--text3)', fontSize: 12 }}>No switches in this rack</span>
)}
</div>
{selectedSwitchId && selectedPortNumber && (
<button
type="button"
className="pp-link-clear"
onClick={() => { setSelectedSwitchId(''); setSelectedPortNumber(''); onChange(null); }}
>
Clear link
</button>
)}
</div>
);
}
@@ -0,0 +1,444 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { COMPONENT_META } from '../types';
// ── Types ────────────────────────────────────────────────────────────────────
interface GNode {
id: string;
label: string;
type: string;
url: string;
x: number; y: number;
vx: number; vy: number;
tx: number; ty: number;
pinned: boolean;
}
interface GEdge {
source: string;
target: string;
type: 'hierarchy' | 'connection';
}
type VB = { x: number; y: number; w: number; h: number };
// ── Constants ────────────────────────────────────────────────────────────────
const HOME_K = 0.14;
const REPULSION = 900;
const DAMPING = 0.78;
const JITTER = 0.18;
const V_GAP = 170;
const NODE_R: Record<string, number> = { site: 36, room: 30, rack: 27, port: 12 };
const DEFAULT_R = 22;
const ICONS: Record<string, string> = {
site: '🏢', room: '🚪', rack: '🗄️',
server: '💻', switch: '🔀', router: '🌐',
firewall: '🛡️', patch_panel: '🔌', ups: '🔋',
pdu: '⚡', kvm: '🖥️', storage: '💾', other: '📦',
port: '◉',
};
const TYPE_LABELS: Record<string, string> = {
site: 'Site', room: 'Room', rack: 'Rack',
server: 'Server', switch: 'Switch', router: 'Router',
firewall: 'Firewall', patch_panel: 'Patch Panel', ups: 'UPS',
pdu: 'PDU', kvm: 'KVM', storage: 'Storage', other: 'Other',
port: 'Port',
};
function nodeColors(type: string) {
if (type === 'site') return { fill: '#1c2d1e', stroke: '#3fb950', text: '#3fb950' };
if (type === 'room') return { fill: '#1a2744', stroke: '#58a6ff', text: '#58a6ff' };
if (type === 'rack') return { fill: '#21262d', stroke: '#8b949e', text: '#8b949e' };
if (type === 'port') return { fill: '#1a1f2e', stroke: '#f59e0b', text: '#f59e0b' };
const m = COMPONENT_META[type as keyof typeof COMPONENT_META];
return m
? { fill: m.bg, stroke: m.color, text: m.color }
: { fill: '#21262d', stroke: '#6e7681', text: '#6e7681' };
}
// ── Tree layout (BFS top-down) ───────────────────────────────────────────────
function computeTreeLayout(
nodes: { id: string }[],
edges: GEdge[],
): Map<string, { tx: number; ty: number }> {
if (nodes.length === 0) return new Map();
const hierEdges = edges.filter(e => e.type === 'hierarchy');
const children: Record<string, string[]> = {};
const hasParent = new Set<string>();
for (const e of hierEdges) {
(children[e.source] ??= []).push(e.target);
hasParent.add(e.target);
}
const root = nodes.find(n => !hasParent.has(n.id))?.id ?? nodes[0].id;
const depth: Record<string, number> = { [root]: 0 };
const queue = [root];
while (queue.length) {
const cur = queue.shift()!;
for (const child of (children[cur] ?? [])) {
if (depth[child] == null) {
depth[child] = depth[cur] + 1;
queue.push(child);
}
}
}
const byLevel: Record<number, string[]> = {};
for (const n of nodes) {
const lv = depth[n.id] ?? 0;
(byLevel[lv] ??= []).push(n.id);
}
const sortedByLevel: Record<number, string[]> = Object.fromEntries(
Object.entries(byLevel).map(([lv, ids]) => [
lv,
ids.slice().sort((a, b) => {
const pa = hierEdges.find(e => e.target === a)?.source;
const pb = hierEdges.find(e => e.target === b)?.source;
if (!pa || !pb || pa === pb) return 0;
const lpa = depth[pa] ?? 0;
const levelNodes = byLevel[lpa] ?? [];
return levelNodes.indexOf(pa) - levelNodes.indexOf(pb);
}),
])
);
const maxLevel = Math.max(0, ...Object.keys(sortedByLevel).map(Number));
const positions = new Map<string, { tx: number; ty: number }>();
for (const [lvStr, ids] of Object.entries(sortedByLevel)) {
const lv = Number(lvStr);
const count = ids.length;
const spacing = count === 1 ? 0 : Math.max(115, 800 / count);
const totalW = (count - 1) * spacing;
const baseY = lv * V_GAP - (maxLevel * V_GAP) / 2;
ids.forEach((nid, i) => {
positions.set(nid, {
tx: -totalW / 2 + i * spacing,
ty: baseY,
});
});
}
return positions;
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function GraphPage() {
const { level, id } = useParams<{ level: string; id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [errMsg, setErrMsg] = useState<string | null>(null);
const [pageTitle, setPageTitle] = useState('Graph View');
const [renderNodes, setRenderNodes] = useState<GNode[]>([]);
const [edges, setEdges] = useState<GEdge[]>([]);
const nodesRef = useRef<GNode[]>([]);
const edgesRef = useRef<GEdge[]>([]);
const rafRef = useRef<number>(0);
const alphaRef = useRef(0.5);
const hasMoved = useRef(false);
const dragging = useRef<{ id: string; ox: number; oy: number } | null>(null);
const panning = useRef<{ x0: number; y0: number; vb0: VB } | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const initVbRef = useRef<VB>({ x: -500, y: -350, w: 1000, h: 700 });
const [vb, setVb] = useState<VB>(initVbRef.current);
useEffect(() => {
if (!level || !id) return;
setLoading(true);
setErrMsg(null);
fetch(`/api/graph/${level}/${id}`)
.then(r => r.ok ? r.json() : r.json().then((b: { error: string }) => Promise.reject(b.error)))
.then((data: { nodes: Omit<GNode, 'x' | 'y' | 'vx' | 'vy' | 'tx' | 'ty' | 'pinned'>[]; edges: GEdge[] }) => {
const positions = computeTreeLayout(data.nodes, data.edges);
const placed: GNode[] = data.nodes.map(node => {
const pos = positions.get(node.id) ?? { tx: 0, ty: 0 };
return { ...node, x: pos.tx, y: pos.ty, vx: 0, vy: 0, tx: pos.tx, ty: pos.ty, pinned: false };
});
nodesRef.current = placed;
edgesRef.current = data.edges;
setEdges(data.edges);
setRenderNodes([...placed]);
alphaRef.current = 0.45;
if (data.nodes.length > 0) {
const root = data.nodes[0];
setPageTitle(`${ICONS[root.type] ?? ''} ${root.label} — Graph`);
}
if (placed.length > 0) {
const xs = placed.map(n => n.x);
const ys = placed.map(n => n.y);
const pad = 100;
const fitVb = {
x: Math.min(...xs) - pad,
y: Math.min(...ys) - pad,
w: Math.max(Math.max(...xs) - Math.min(...xs) + pad * 2, 600),
h: Math.max(Math.max(...ys) - Math.min(...ys) + pad * 2, 400),
};
initVbRef.current = fitVb;
setVb(fitVb);
}
setLoading(false);
})
.catch((e: unknown) => { setErrMsg(String(e)); setLoading(false); });
}, [level, id]);
useEffect(() => {
if (loading || nodesRef.current.length === 0) return;
const tick = () => {
const ns = nodesRef.current;
const a = alphaRef.current;
for (const n of ns) {
if (n.pinned) continue;
n.vx += (n.tx - n.x) * HOME_K * a;
n.vy += (n.ty - n.y) * HOME_K * a;
}
for (let i = 0; i < ns.length; i++) {
for (let j = i + 1; j < ns.length; j++) {
const dx = ns[j].x - ns[i].x || 0.01;
const dy = ns[j].y - ns[i].y || 0.01;
const d2 = dx * dx + dy * dy;
if (d2 > 32000) continue;
const d = Math.sqrt(d2);
const f = (REPULSION * a) / d2;
const fx = (dx / d) * f, fy = (dy / d) * f;
ns[i].vx -= fx; ns[i].vy -= fy;
ns[j].vx += fx; ns[j].vy += fy;
}
}
for (const n of ns) {
if (n.pinned) { n.vx = 0; n.vy = 0; continue; }
n.vx *= DAMPING;
n.vy *= DAMPING;
if (a <= 0.06) {
n.vx += (Math.random() - 0.5) * JITTER;
n.vy += (Math.random() - 0.5) * JITTER;
}
n.x += n.vx;
n.y += n.vy;
}
alphaRef.current = Math.max(a * 0.994, 0.05);
setRenderNodes([...ns]);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [loading, edges]);
const toSvg = useCallback((cx: number, cy: number) => {
const svg = svgRef.current;
if (!svg) return { x: cx, y: cy };
const pt = svg.createSVGPoint();
pt.x = cx; pt.y = cy;
return pt.matrixTransform(svg.getScreenCTM()!.inverse());
}, []);
const onNodeDown = useCallback((e: React.MouseEvent, nid: string) => {
e.stopPropagation();
hasMoved.current = false;
const sp = toSvg(e.clientX, e.clientY);
const node = nodesRef.current.find(n => n.id === nid);
if (!node) return;
dragging.current = { id: nid, ox: sp.x - node.x, oy: sp.y - node.y };
nodesRef.current = nodesRef.current.map(n => n.id === nid ? { ...n, pinned: true } : n);
alphaRef.current = Math.max(alphaRef.current, 0.25);
}, [toSvg]);
const onBgDown = useCallback((e: React.MouseEvent) => {
panning.current = { x0: e.clientX, y0: e.clientY, vb0: { ...vb } };
}, [vb]);
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (dragging.current) {
hasMoved.current = true;
const sp = toSvg(e.clientX, e.clientY);
const { id: nid, ox, oy } = dragging.current;
nodesRef.current = nodesRef.current.map(n =>
n.id === nid ? { ...n, x: sp.x - ox, y: sp.y - oy } : n
);
return;
}
if (panning.current) {
const { x0, y0, vb0 } = panning.current;
const scale = vb0.w / (svgRef.current?.clientWidth || 900);
setVb(v => ({ ...v, x: vb0.x - (e.clientX - x0) * scale, y: vb0.y - (e.clientY - y0) * scale }));
}
}, [toSvg]);
const onMouseUp = useCallback(() => {
if (dragging.current) {
const nid = dragging.current.id;
nodesRef.current = nodesRef.current.map(n =>
n.id === nid ? { ...n, pinned: false, tx: n.x, ty: n.y } : n
);
dragging.current = null;
alphaRef.current = Math.max(alphaRef.current, 0.15);
}
panning.current = null;
}, []);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const sp = toSvg(e.clientX, e.clientY);
const f = e.deltaY > 0 ? 1.12 : 0.89;
setVb(v => ({
x: sp.x - (sp.x - v.x) * f,
y: sp.y - (sp.y - v.y) * f,
w: v.w * f,
h: v.h * f,
}));
}, [toSvg]);
if (loading) return <div className="page-loading">Building graph</div>;
if (errMsg) return <div className="page-loading" style={{ color: 'var(--danger)' }}>Error: {errMsg}</div>;
return (
<div className="graph-page">
<div className="graph-toolbar">
<button className="btn-back btn-sm" onClick={() => navigate(-1)}> Back</button>
<span className="graph-title">{pageTitle}</span>
<div className="graph-legend">
<span className="graph-legend-item">
<svg width="26" height="10" style={{ flexShrink: 0 }}>
<line x1="0" y1="5" x2="26" y2="5" stroke="#444d58" strokeWidth="2.5" />
</svg>
Hierarchy
</span>
<span className="graph-legend-item">
<svg width="26" height="10" style={{ flexShrink: 0 }}>
<line x1="0" y1="5" x2="26" y2="5" stroke="#58a6ff" strokeWidth="2" strokeDasharray="5,3" />
</svg>
Port connection
</span>
</div>
<button
className="btn-secondary btn-sm"
style={{ marginLeft: 'auto' }}
onClick={() => setVb(initVbRef.current)}
>
Fit View
</button>
<span className="graph-hint">Scroll = zoom · Drag node · Drag bg = pan · Click = open</span>
</div>
<svg
ref={svgRef}
className="graph-canvas"
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onMouseDown={onBgDown}
onWheel={onWheel}
>
{/* Level guide bands */}
<g opacity="0.07">
{Array.from(new Set(renderNodes.map(n => Math.round(n.ty)))).map(ty => (
<line key={ty} x1={vb.x - 200} y1={ty} x2={vb.x + vb.w + 200} y2={ty}
stroke="#8b949e" strokeWidth="40" />
))}
</g>
{/* Edges */}
<g>
{edges.map((e, i) => {
const src = renderNodes.find(n => n.id === e.source);
const tgt = renderNodes.find(n => n.id === e.target);
if (!src || !tgt) return null;
if (e.type === 'connection') {
const mx = (src.x + tgt.x) / 2;
const my = (src.y + tgt.y) / 2 - 50;
return (
<path key={i}
d={`M${src.x.toFixed(1)},${src.y.toFixed(1)} Q${mx.toFixed(1)},${my.toFixed(1)} ${tgt.x.toFixed(1)},${tgt.y.toFixed(1)}`}
fill="none" stroke="#58a6ff" strokeWidth="1.5"
strokeDasharray="5,3" strokeOpacity="0.8"
/>
);
}
const midY = (src.y + tgt.y) / 2;
return (
<path key={i}
d={`M${src.x.toFixed(1)},${src.y.toFixed(1)} L${src.x.toFixed(1)},${midY.toFixed(1)} L${tgt.x.toFixed(1)},${midY.toFixed(1)} L${tgt.x.toFixed(1)},${tgt.y.toFixed(1)}`}
fill="none" stroke="#3d4650" strokeWidth="1.5" strokeOpacity="0.85"
/>
);
})}
</g>
{/* Nodes */}
<g>
{renderNodes.map(node => {
const r = NODE_R[node.type] ?? DEFAULT_R;
const c = nodeColors(node.type);
const isStr = ['site', 'room', 'rack'].includes(node.type);
const isPort = node.type === 'port';
const label = node.label.length > 14 ? node.label.slice(0, 12) + '…' : node.label;
return (
<g key={node.id} className="graph-node"
transform={`translate(${node.x.toFixed(1)},${node.y.toFixed(1)})`}
onMouseDown={ev => onNodeDown(ev, node.id)}
onClick={ev => { ev.stopPropagation(); if (!hasMoved.current) navigate(node.url); }}
style={{ cursor: 'pointer' }}
>
{isStr && (
<circle r={r + 6} fill={c.fill} fillOpacity="0.3"
stroke={c.stroke} strokeWidth="1" strokeOpacity="0.25" />
)}
<circle r={r} fill={c.fill} stroke={c.stroke} strokeWidth={isStr ? 2.5 : isPort ? 1 : 1.5} />
{!isPort && (
<text textAnchor="middle" dominantBaseline="central"
fontSize={isStr ? 18 : 14}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{ICONS[node.type] ?? '📦'}
</text>
)}
<text y={r + (isPort ? 10 : 14)} textAnchor="middle" fontSize={isStr ? 12 : isPort ? 9 : 10}
fill={c.text} fontWeight={isStr ? '600' : '400'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{label}
</text>
{isStr && (
<text y={r + 26} textAnchor="middle" fontSize={9} fill="#6e7681"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{TYPE_LABELS[node.type] ?? node.type}
</text>
)}
</g>
);
})}
</g>
</svg>
{renderNodes.length === 0 && !loading && (
<div className="graph-empty">
Nothing to show yet add rooms, racks and components first.
</div>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import * as api from '../api';
import type { Site } from '../types';
export default function HomePage() {
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getSites().then(s => { setSites(s); setLoading(false); });
}, []);
if (loading) return <div className="page-loading">Loading</div>;
if (sites.length === 0) {
return (
<div className="welcome-screen">
<div className="welcome-icon">🗄</div>
<h1 className="welcome-title">Welcome to NetworkView</h1>
<p className="welcome-sub">
Document your home lab or enterprise network.<br />
Create sites, add server rooms, build rack layouts, and keep Markdown notes on every device.
</p>
<div className="welcome-steps">
<div className="step">
<div className="step-num">1</div>
<div>Click <strong>+</strong> next to "Sites" in the sidebar to create your first site.</div>
</div>
<div className="step">
<div className="step-num">2</div>
<div>Add a <strong>Room</strong> to the site (e.g. "Server Closet").</div>
</div>
<div className="step">
<div className="step-num">3</div>
<div>Create a <strong>Rack</strong> in the room and start adding devices.</div>
</div>
</div>
</div>
);
}
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Dashboard</h1>
</div>
<div className="dashboard-grid">
{sites.map(site => (
<Link key={site.id} to={`/sites/${site.id}`} className="dashboard-card">
<div className="dashboard-card-icon">🏢</div>
<div className="dashboard-card-body">
<div className="dashboard-card-name">{site.name}</div>
{site.location && <div className="dashboard-card-sub">{site.location}</div>}
<div className="dashboard-card-meta">{site.room_count ?? 0} rooms</div>
</div>
</Link>
))}
</div>
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import * as api from '../api';
import type { Rack, Component } from '../types';
import { COMPONENT_META } from '../types';
import RackGraphicView from '../components/RackGraphicView';
import MarkdownEditor from '../components/MarkdownEditor';
import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal';
import type { ComponentFormData } from '../components/AddItemModal';
// ── Rack Layout Table ─────────────────────────────────────────────────────────
function RackLayoutView({ rack, components, onAddAtSlot }: {
rack: Rack;
components: Component[];
onAddAtSlot?: (position: number) => void;
}) {
const navigate = useNavigate();
// Map each unit number → component (for multi-U components, repeated)
const byPos = new Map<number, Component>();
for (const c of components) {
if (c.position != null) {
for (let u = c.position; u < c.position + c.height_units; u++) {
byPos.set(u, c);
}
}
}
// Build rows for the table
interface RowInfo { unit: number; comp?: Component; isFirst: boolean; span: number }
const rows: RowInfo[] = [];
const seen = new Set<string>();
for (let u = 1; u <= rack.total_units; u++) {
const comp = byPos.get(u);
if (comp) {
if (!seen.has(comp.id)) {
seen.add(comp.id);
rows.push({ unit: u, comp, isFirst: true, span: comp.height_units });
} else {
rows.push({ unit: u, comp, isFirst: false, span: 0 });
}
} else {
rows.push({ unit: u, comp: undefined, isFirst: true, span: 1 });
}
}
return (
<div className="rack-layout">
<table className="rack-layout-table">
<tbody>
{rows.map(row => (
<tr key={row.unit} className="rack-layout-row">
<td className="rack-layout-u">{row.unit}</td>
{row.isFirst && (
row.comp ? (
<td
rowSpan={row.span}
className="rack-layout-comp"
style={{
background: COMPONENT_META[row.comp.type].bg,
borderLeft: `4px solid ${COMPONENT_META[row.comp.type].color}`,
}}
onClick={() => navigate(`/components/${row.comp!.id}`)}
>
<span className="rack-layout-comp-name">{row.comp.name}</span>
<span className="rack-layout-comp-type" style={{ color: COMPONENT_META[row.comp.type].color }}>
{COMPONENT_META[row.comp.type].label}
</span>
{row.comp.model && <span className="rack-layout-comp-model">{row.comp.model}</span>}
{row.comp.ip_address && <span className="rack-layout-comp-ip">{row.comp.ip_address}</span>}
{row.span > 1 && <span className="rack-layout-comp-height">{row.span}U</span>}
</td>
) : (
<td
className="rack-layout-empty"
onClick={() => onAddAtSlot?.(row.unit)}
>
<span className="rack-layout-empty-txt">· empty · click to add</span>
</td>
)
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function RackPage() {
const { rackId } = useParams<{ rackId: string }>();
const navigate = useNavigate();
const [rack, setRack] = useState<Rack | null>(null);
const [components, setComponents] = useState<Component[]>([]);
const [loading, setLoading] = useState(true);
const [notes, setNotes] = useState('');
const [showAddComponent, setShowAddComponent] = useState(false);
const [addAtPosition, setAddAtPosition] = useState<number | undefined>(undefined);
const [showEditRack, setShowEditRack] = useState(false);
const [compView, setCompView] = useState<'table' | 'layout'>('table');
const loadRack = useCallback(async () => {
if (!rackId) return;
const data = await api.getRack(rackId);
setRack(data);
setComponents(data.components ?? []);
setNotes(data.notes ?? '');
setLoading(false);
}, [rackId]);
useEffect(() => { loadRack(); }, [loadRack]);
const saveNotes = async (val: string) => {
if (!rackId) return;
await api.updateRack(rackId, { notes: val });
};
const handleAddComponent = async (formData: ComponentFormData) => {
await api.createComponent(formData);
setShowAddComponent(false);
await loadRack();
};
const handleAddAtSlot = (position: number) => {
setAddAtPosition(position);
setShowAddComponent(true);
};
const handleEditRack = async (data: Record<string, string | number>) => {
if (!rackId) return;
await api.updateRack(rackId, {
name: data.name as string,
total_units: Number(data.total_units),
manufacturer: data.manufacturer as string,
model: data.model as string,
});
setShowEditRack(false);
await loadRack();
};
const handleDelete = async () => {
if (!rackId || !rack || !confirm(`Delete rack "${rack.name}" and all its components?`)) return;
await api.deleteRack(rackId);
navigate(rack.room ? `/rooms/${rack.room.id}` : '/');
};
if (loading || !rack) return <div className="page-loading">Loading</div>;
const usedUnits = components.reduce((s, c) => s + (c.position != null ? c.height_units : 0), 0);
const freeUnits = rack.total_units - usedUnits;
return (
<div className="page page-rack">
<div className="page-header">
<div className="breadcrumb">
<Link to="/">Dashboard</Link>
{rack.site && <><span> / </span><Link to={`/sites/${rack.site.id}`}>{rack.site.name}</Link></>}
{rack.room && <><span> / </span><Link to={`/rooms/${rack.room.id}`}>{rack.room.name}</Link></>}
<span> / </span>
<span>{rack.name}</span>
</div>
<div className="page-actions">
{rack.room && (
<button className="btn-back btn-sm" onClick={() => navigate(`/rooms/${rack.room!.id}`)}> Back to Room</button>
)}
<button className="btn-graph btn-sm" onClick={() => navigate(`/graph/rack/${rackId}`)}> Graph View</button>
<button className="btn-secondary btn-sm" onClick={() => setShowEditRack(true)}> Edit</button>
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
+ Add Component
</button>
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
</div>
</div>
<div className="entity-header">
<h1 className="entity-title">
<span className="entity-icon">🗄</span> {rack.name}
</h1>
<div className="entity-meta-row">
<span className="entity-meta-chip">{rack.total_units}U total</span>
<span className="entity-meta-chip" style={{ color: '#f59e0b' }}>{usedUnits}U used</span>
<span className="entity-meta-chip" style={{ color: '#34d399' }}>{freeUnits}U free</span>
{rack.manufacturer && <span className="entity-meta-chip">{rack.manufacturer}</span>}
{rack.model && <span className="entity-meta-chip">{rack.model}</span>}
</div>
</div>
{/* Two-column rack layout */}
<div className="rack-page-columns">
{/* LEFT: Component management */}
<div className="rack-page-left">
<section className="content-section">
<div className="section-header">
<h2 className="section-title">Components</h2>
<div className="section-view-toggle">
<button
className={`btn-sm ${compView === 'table' ? 'btn-secondary' : 'btn-ghost'}`}
title="Table view"
onClick={() => setCompView('table')}
> Table</button>
<button
className={`btn-sm ${compView === 'layout' ? 'btn-secondary' : 'btn-ghost'}`}
title="Rack layout view"
onClick={() => setCompView('layout')}
> Rack Map</button>
</div>
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
+ Add Component
</button>
</div>
{compView === 'layout' ? (
components.length === 0 ? (
<div className="empty-state"><p>No components yet.</p></div>
) : (
<RackLayoutView rack={rack} components={components} onAddAtSlot={handleAddAtSlot} />
)
) : (
components.length === 0 ? (
<div className="empty-state">
<p>No components yet.</p>
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
Click <strong>+ Add Component</strong> or click an empty slot in the rack diagram
</p>
</div>
) : (
<table className="component-table">
<thead>
<tr>
<th>U</th>
<th>Name</th>
<th>Type</th>
<th>Model</th>
<th>IP</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{[...components]
.sort((a, b) => (a.position ?? 9999) - (b.position ?? 9999))
.map(c => {
const meta = COMPONENT_META[c.type];
return (
<tr key={c.id} className="component-table-row" onClick={() => navigate(`/components/${c.id}`)} style={{ borderLeft: `3px solid ${meta.color}` }}>
<td className="td-position">{c.position ?? '—'}</td>
<td className="td-name">{c.name}</td>
<td className="td-type" style={{ color: meta.color }}>{meta.label}</td>
<td className="td-model">{c.model ?? '—'}</td>
<td className="td-ip">{c.ip_address ?? '—'}</td>
<td className="td-status"><span className={`status-badge status-${c.status}`}>{c.status}</span></td>
</tr>
);
})}
</tbody>
</table>
)
)}
</section>
<section className="content-section">
<h2 className="section-title">Rack Notes</h2>
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
</section>
</div>
{/* RIGHT: Graphical front panel */}
<div className="rack-page-right">
<div className="rack-page-right-scroll">
<div className="rack-page-right-header">
<span className="rack-page-right-title">Front Panel View</span>
<span className="rack-page-right-hint">Click slot to add · Click device to open</span>
</div>
<RackGraphicView
rack={rack}
components={components}
onAddAtSlot={handleAddAtSlot}
/>
</div>
</div>
</div>
{showAddComponent && rackId && (
<AddComponentModal
rackId={rackId}
totalUnits={rack.total_units}
initialPosition={addAtPosition}
onSave={handleAddComponent}
onClose={() => setShowAddComponent(false)}
/>
)}
{showEditRack && (
<SimpleCreateModal
title="Edit Rack"
fields={[
{ key: 'name', label: 'Rack Name', defaultValue: rack.name },
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: rack.total_units },
{ key: 'manufacturer', label: 'Manufacturer', defaultValue: rack.manufacturer ?? '' },
{ key: 'model', label: 'Model', defaultValue: rack.model ?? '' },
]}
onSave={handleEditRack}
onClose={() => setShowEditRack(false)}
/>
)}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import * as api from '../api';
import type { Room, Rack } from '../types';
import MarkdownEditor from '../components/MarkdownEditor';
import { SimpleCreateModal } from '../components/AddItemModal';
export default function RoomPage() {
const { roomId } = useParams<{ roomId: string }>();
const navigate = useNavigate();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState('');
const [notes, setNotes] = useState('');
const [showAddRack, setShowAddRack] = useState(false);
useEffect(() => {
if (!roomId) return;
api.getRoom(roomId).then(r => {
setRoom(r);
setNotes(r.notes ?? '');
setEditName(r.name);
setLoading(false);
});
}, [roomId]);
const saveNotes = async (val: string) => {
if (!roomId) return;
await api.updateRoom(roomId, { notes: val });
};
const saveMeta = async () => {
if (!roomId) return;
await api.updateRoom(roomId, { name: editName });
setRoom(r => r ? { ...r, name: editName } : r);
setEditing(false);
};
const handleDelete = async () => {
if (!roomId || !confirm(`Delete room "${room?.name}" and all its racks?`)) return;
await api.deleteRoom(roomId);
navigate(room?.site?.id ? `/sites/${room.site.id}` : '/');
};
const handleAddRack = async (data: Record<string, string | number>) => {
if (!roomId) return;
const rack = await api.createRack({
room_id: roomId,
name: data.name as string,
total_units: Number(data.total_units) || 42,
});
setShowAddRack(false);
navigate(`/racks/${rack.id}`);
};
if (loading || !room) return <div className="page-loading">Loading</div>;
const racks = room.racks ?? [];
return (
<div className="page">
<div className="page-header">
<div className="breadcrumb">
<Link to="/">Dashboard</Link>
{room.site && <><span> / </span><Link to={`/sites/${room.site.id}`}>{room.site.name}</Link></>}
<span> / </span>
<span>{room.name}</span>
</div>
<div className="page-actions">
{room.site && (
<button className="btn-back btn-sm" onClick={() => navigate(`/sites/${room.site!.id}`)}> Back to Site</button>
)}
<button className="btn-graph btn-sm" onClick={() => navigate(`/graph/room/${roomId}`)}> Graph View</button>
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
{editing ? 'Cancel' : '✎ Edit'}
</button>
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
</div>
</div>
{editing ? (
<div className="edit-meta-form">
<div className="form-group">
<label>Room Name</label>
<input className="form-input" value={editName} onChange={e => setEditName(e.target.value)} />
</div>
<button className="btn-primary btn-sm" onClick={saveMeta}>Save</button>
</div>
) : (
<div className="entity-header">
<h1 className="entity-title">
<span className="entity-icon">🚪</span> {room.name}
</h1>
</div>
)}
{/* Racks */}
<section className="content-section">
<div className="section-header">
<h2 className="section-title">Racks</h2>
<button className="btn-primary btn-sm" onClick={() => setShowAddRack(true)}>+ Add Rack</button>
</div>
{racks.length === 0 ? (
<div className="empty-state">
<p>No racks yet. Add a rack to start building your layout.</p>
</div>
) : (
<div className="racks-grid">
{racks.map((rack: Rack) => (
<Link key={rack.id} to={`/racks/${rack.id}`} className="rack-card">
<div className="rack-card-visual">
{Array.from({ length: Math.min(rack.total_units, 8) }).map((_, i) => (
<div key={i} className="rack-card-unit" />
))}
</div>
<div className="rack-card-info">
<div className="rack-card-name">{rack.name}</div>
<div className="rack-card-meta">
{rack.total_units}U
{rack.manufacturer && ` · ${rack.manufacturer}`}
{rack.model && ` ${rack.model}`}
</div>
<div className="rack-card-count">{rack.component_count ?? 0} devices</div>
</div>
</Link>
))}
</div>
)}
</section>
<section className="content-section">
<h2 className="section-title">Notes</h2>
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
</section>
{showAddRack && (
<SimpleCreateModal
title="Add Rack"
fields={[
{ key: 'name', label: 'Rack Name', placeholder: 'e.g. RACK-01' },
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: 42, placeholder: '42' },
]}
onSave={handleAddRack}
onClose={() => setShowAddRack(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,415 @@
import { useEffect, useState, useCallback } from 'react';
import * as api from '../api';
import type { AuditEntry, User, DbStats } from '../types';
// ── Helpers ───────────────────────────────────────────────────────────────────
const ACTION_COLORS: Record<string, string> = {
create: '#3fb950',
update: '#58a6ff',
delete: '#f87171',
delete_all: '#f87171',
api_key_rotate: '#f59e0b',
api_key_revoke: '#f59e0b',
};
const ROLE_COLORS: Record<string, string> = {
admin: '#f87171',
editor: '#f59e0b',
viewer: '#8b949e',
};
function formatTs(ts: string) {
return new Date(ts).toLocaleString();
}
// ── Sub-section: DB Stats ─────────────────────────────────────────────────────
function StatsPanel({ stats }: { stats: DbStats }) {
return (
<div className="settings-stats">
{Object.entries(stats).map(([k, v]) => (
<div key={k} className="settings-stat-card">
<span className="settings-stat-val">{v}</span>
<span className="settings-stat-key">{k.replace(/_/g, ' ')}</span>
</div>
))}
</div>
);
}
// ── Sub-section: Users ────────────────────────────────────────────────────────
function UsersPanel() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ username: '', email: '', role: 'viewer' });
const [err, setErr] = useState<string | null>(null);
const [apiKeyResult, setApiKeyResult] = useState<{ userId: string; key: string } | null>(null);
const load = useCallback(async () => {
setLoading(true);
setUsers(await api.getUsers());
setLoading(false);
}, []);
useEffect(() => { load(); }, [load]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
try {
await api.createUser(form);
setForm({ username: '', email: '', role: 'viewer' });
setShowAdd(false);
await load();
} catch (ex: unknown) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
};
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
await api.deleteUser(u.id);
await load();
};
const handleToggleActive = async (u: User) => {
await api.updateUser(u.id, { is_active: !u.is_active });
await load();
};
const handleRotateKey = async (u: User) => {
if (!confirm(`Rotate API key for "${u.username}"? The old key will stop working immediately.`)) return;
const result = await api.rotateApiKey(u.id);
setApiKeyResult({ userId: u.id, key: result.api_key });
};
const handleRevokeKey = async (u: User) => {
if (!confirm(`Revoke API key for "${u.username}"?`)) return;
await api.revokeApiKey(u.id);
await load();
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h2 className="settings-section-title">Users</h2>
<button className="btn-primary btn-sm" onClick={() => setShowAdd(v => !v)}>+ Add User</button>
</div>
<p className="settings-desc">
User accounts for this NetworkView instance. Authentication is delegated to your central app
set <code>X-User-Id</code> + <code>X-Username</code> headers on API requests, or use per-user API keys.
</p>
{showAdd && (
<form className="settings-user-form" onSubmit={handleAdd}>
<input
className="modal-input" placeholder="Username *" required
value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
/>
<input
className="modal-input" placeholder="Email" type="email"
value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
/>
<select
className="modal-select"
value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
{err && <div className="settings-error">{err}</div>}
<div className="form-actions">
<button type="submit" className="btn-primary btn-sm">Create</button>
<button type="button" className="btn-secondary btn-sm" onClick={() => setShowAdd(false)}>Cancel</button>
</div>
</form>
)}
{apiKeyResult && (
<div className="settings-api-key-reveal">
<strong>New API Key (copy now shown only once):</strong>
<code className="settings-api-key-code">{apiKeyResult.key}</code>
<button className="btn-secondary btn-sm" onClick={() => { navigator.clipboard.writeText(apiKeyResult.key); }}>
📋 Copy
</button>
<button className="btn-ghost btn-sm" onClick={() => setApiKeyResult(null)}>Dismiss</button>
</div>
)}
{loading ? <div className="page-loading">Loading</div> : (
<table className="settings-user-table">
<thead>
<tr>
<th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>API Key</th><th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ opacity: u.is_active ? 1 : 0.5 }}>
<td><strong>{u.username}</strong></td>
<td style={{ color: 'var(--text3)', fontSize: 12 }}>{u.email ?? '—'}</td>
<td>
<span className="status-badge" style={{ color: ROLE_COLORS[u.role] ?? '#8b949e', borderColor: ROLE_COLORS[u.role] ?? '#8b949e' }}>
{u.role}
</span>
</td>
<td>
<span className={`status-badge status-${u.is_active ? 'active' : 'decommissioned'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
</td>
<td style={{ fontSize: 11, color: 'var(--text3)' }}>
{u.has_api_key ? '●●●●●●●●' : '—'}
</td>
<td className="settings-user-actions">
<button className="btn-ghost btn-sm" onClick={() => handleToggleActive(u)}
title={u.is_active ? 'Deactivate' : 'Activate'}>
{u.is_active ? '⏸' : '▶'}
</button>
<button className="btn-ghost btn-sm" onClick={() => handleRotateKey(u)} title="Rotate API key">🔑</button>
{u.has_api_key && (
<button className="btn-ghost btn-sm" onClick={() => handleRevokeKey(u)} title="Revoke API key">🚫</button>
)}
<button className="btn-danger btn-sm" onClick={() => handleDelete(u)}>🗑</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No users yet</td></tr>
)}
</tbody>
</table>
)}
</div>
);
}
// ── Sub-section: Audit Log ────────────────────────────────────────────────────
function AuditPanel() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState({ entity_type: '', action: '' });
const [offset, setOffset] = useState(0);
const LIMIT = 50;
const load = useCallback(async () => {
setLoading(true);
const result = await api.getAuditLog({ ...filter, limit: LIMIT, offset });
setEntries(result.entries);
setTotal(result.total);
setLoading(false);
}, [filter, offset]);
useEffect(() => { load(); }, [load]);
const handleClearAudit = async () => {
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
await api.clearAuditLog();
setOffset(0);
await load();
};
return (
<div className="settings-section">
<div className="settings-section-header">
<h2 className="settings-section-title">Audit Log</h2>
<button className="btn-danger btn-sm" onClick={handleClearAudit}>🗑 Clear Log</button>
</div>
<div className="settings-audit-filters">
<select className="modal-select" style={{ width: 'auto' }}
value={filter.entity_type}
onChange={e => { setFilter(f => ({ ...f, entity_type: e.target.value })); setOffset(0); }}>
<option value="">All types</option>
{['site','room','rack','component','port','user','database','audit_log'].map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select className="modal-select" style={{ width: 'auto' }}
value={filter.action}
onChange={e => { setFilter(f => ({ ...f, action: e.target.value })); setOffset(0); }}>
<option value="">All actions</option>
{['create','update','delete','delete_all','api_key_rotate','api_key_revoke'].map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
<span style={{ color: 'var(--text3)', fontSize: 12 }}>{total} entries</span>
</div>
{loading ? <div className="page-loading" style={{ height: 80 }}>Loading</div> : (
<>
<table className="settings-audit-table">
<thead>
<tr><th>Time</th><th>User</th><th>Action</th><th>Type</th><th>Entity</th><th>Changes</th></tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id}>
<td style={{ whiteSpace: 'nowrap', fontSize: 11, color: 'var(--text3)' }}>{formatTs(e.created_at)}</td>
<td style={{ fontSize: 12 }}>{e.username ?? '—'}</td>
<td>
<span className="audit-action-badge" style={{ color: ACTION_COLORS[e.action] ?? '#8b949e' }}>
{e.action}
</span>
</td>
<td style={{ fontSize: 12, color: 'var(--text3)' }}>{e.entity_type}</td>
<td style={{ fontSize: 12 }}>{e.entity_name ?? e.entity_id ?? '—'}</td>
<td style={{ fontSize: 11, color: 'var(--text3)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{e.changes ?? '—'}
</td>
</tr>
))}
{entries.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No entries</td></tr>
)}
</tbody>
</table>
<div className="settings-audit-pager">
<button className="btn-secondary btn-sm" disabled={offset === 0}
onClick={() => setOffset(o => Math.max(0, o - LIMIT))}> Previous</button>
<span style={{ color: 'var(--text3)', fontSize: 12 }}>
{offset + 1}{Math.min(offset + LIMIT, total)} of {total}
</span>
<button className="btn-secondary btn-sm" disabled={offset + LIMIT >= total}
onClick={() => setOffset(o => o + LIMIT)}>Next </button>
</div>
</>
)}
</div>
);
}
// ── Sub-section: Danger Zone ──────────────────────────────────────────────────
function DangerZone({ onAction }: { onAction: () => void }) {
const [confirmText, setConfirmText] = useState('');
const [busy, setBusy] = useState(false);
const deleteAll = async () => {
if (confirmText !== 'DELETE ALL') {
alert('Type DELETE ALL exactly to confirm.');
return;
}
setBusy(true);
try {
await api.deleteAllData();
setConfirmText('');
onAction();
alert('All network data deleted.');
} catch (e: unknown) {
alert(e instanceof Error ? e.message : String(e));
}
setBusy(false);
};
return (
<div className="settings-section settings-danger-zone">
<h2 className="settings-section-title" style={{ color: '#f87171' }}> Danger Zone</h2>
<p className="settings-desc">
These operations are irreversible. All cascading data (rooms, racks, components, ports) will be permanently deleted.
</p>
<div className="danger-action">
<div>
<strong>Delete all network data</strong>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '4px 0 0' }}>
Removes all sites, rooms, racks, components, ports and audit entries. User accounts are kept.
</p>
</div>
<div className="danger-confirm-row">
<input
className="modal-input danger-input"
placeholder='Type "DELETE ALL" to confirm'
value={confirmText}
onChange={e => setConfirmText(e.target.value)}
/>
<button
className="btn-danger btn-sm"
disabled={busy || confirmText !== 'DELETE ALL'}
onClick={deleteAll}
>
{busy ? '…' : '🗑 Delete All'}
</button>
</div>
</div>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
type Tab = 'overview' | 'users' | 'audit' | 'danger';
export default function SettingsPage() {
const [tab, setTab] = useState<Tab>('overview');
const [stats, setStats] = useState<DbStats | null>(null);
const loadStats = useCallback(async () => {
setStats(await api.getDbStats());
}, []);
useEffect(() => { loadStats(); }, [loadStats]);
const tabs: { key: Tab; label: string }[] = [
{ key: 'overview', label: '📊 Overview' },
{ key: 'users', label: '👤 Users' },
{ key: 'audit', label: '📋 Audit Log' },
{ key: 'danger', label: '⚠ Danger' },
];
return (
<div className="page page-settings">
<div className="page-header">
<h1 className="entity-title" style={{ fontSize: 22 }}> Settings</h1>
</div>
<div className="settings-tabs">
{tabs.map(t => (
<button
key={t.key}
className={`settings-tab ${tab === t.key ? 'settings-tab-active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
<div className="settings-body">
{tab === 'overview' && (
<div className="settings-section">
<h2 className="settings-section-title">Database Overview</h2>
{stats ? <StatsPanel stats={stats} /> : <div className="page-loading">Loading</div>}
<div className="settings-api-info">
<h3 style={{ marginBottom: 8, color: 'var(--text2)' }}>API Integration</h3>
<p className="settings-desc">
This app exposes a REST API designed to be consumed by a central management app.
Use the following headers on every request:
</p>
<table className="settings-api-table">
<thead><tr><th>Header</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>X-User-Id</code></td><td>UUID of the authenticated user (set by your auth gateway)</td></tr>
<tr><td><code>X-Username</code></td><td>Display name of the authenticated user</td></tr>
<tr><td><code>X-Api-Key</code></td><td>Per-user API key (alternative to gateway headers)</td></tr>
<tr><td><code>X-Confirm: yes</code></td><td>Required for destructive operations</td></tr>
</tbody>
</table>
<p className="settings-desc" style={{ marginTop: 12 }}>
<strong>Key endpoints:</strong>{' '}
<code>GET /api/users</code> · <code>GET /api/audit</code> ·
<code>GET /api/settings/stats</code> · <code>DELETE /api/settings/data/all</code>
</p>
</div>
</div>
)}
{tab === 'users' && <UsersPanel />}
{tab === 'audit' && <AuditPanel />}
{tab === 'danger' && <DangerZone onAction={loadStats} />}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More