feat(ops): add speedtest, cms persistence, support ticket db storage, and action resiliency (#18)

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-09-21 10:46:09 -04:00 committed by GitHub
parent cf7c99b210
commit fb67252bc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2013 additions and 190 deletions

View File

@ -0,0 +1,92 @@
"""add ops support tickets and cms documents
Revision ID: f1a2b3c4d5e8
Revises: f1a2b3c4d5e7
Create Date: 2026-09-21
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision = 'f1a2b3c4d5e8'
down_revision = 'f1a2b3c4d5e7'
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Sequence for support case references (SUP-YYYY-NNNNNN)
op.execute("CREATE SEQUENCE IF NOT EXISTS ops.support_case_seq START 101")
# 2. ops.support_tickets table
op.create_table(
'support_tickets',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('case_reference', sa.String(length=32), nullable=False, unique=True),
sa.Column('ticket_type', sa.String(length=64), nullable=False),
sa.Column('reporter_email', sa.String(length=320), nullable=False),
sa.Column('subject', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('severity', sa.String(length=32), nullable=False, server_default='medium'),
sa.Column('status', sa.String(length=32), nullable=False, server_default='open'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('diagnostic_bundle', sa.JSON(), nullable=True),
schema='ops'
)
op.create_index('ix_ops_support_tickets_case_reference', 'support_tickets', ['case_reference'], unique=True, schema='ops')
op.create_index('ix_ops_support_tickets_status', 'support_tickets', ['status'], unique=False, schema='ops')
op.create_index('ix_ops_support_tickets_severity', 'support_tickets', ['severity'], unique=False, schema='ops')
op.create_index('ix_ops_support_tickets_created_at', 'support_tickets', ['created_at'], unique=False, schema='ops')
# 3. ops.support_ticket_notes table
op.create_table(
'support_ticket_notes',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('ticket_id', UUID(as_uuid=True), sa.ForeignKey('ops.support_tickets.id', ondelete='CASCADE'), nullable=False),
sa.Column('author_identity', sa.String(length=320), nullable=False),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
schema='ops'
)
op.create_index('ix_ops_support_ticket_notes_ticket_id', 'support_ticket_notes', ['ticket_id'], unique=False, schema='ops')
op.create_index('ix_ops_support_ticket_notes_created_at', 'support_ticket_notes', ['created_at'], unique=False, schema='ops')
# 4. ops.cms_documents table
op.create_table(
'cms_documents',
sa.Column('slug', sa.String(length=96), primary_key=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('published_json', sa.JSON(), nullable=False),
sa.Column('draft_json', sa.JSON(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_by', sa.String(length=320), nullable=False, server_default='system'),
schema='ops'
)
# 5. ops.cms_document_revisions table
op.create_table(
'cms_document_revisions',
sa.Column('revision_id', sa.String(length=96), primary_key=True),
sa.Column('document_slug', sa.String(length=96), sa.ForeignKey('ops.cms_documents.slug', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('created_by', sa.String(length=320), nullable=False),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('change_summary', sa.Text(), nullable=False),
sa.Column('content_json', sa.JSON(), nullable=False),
schema='ops'
)
op.create_index('ix_ops_cms_revisions_slug', 'cms_document_revisions', ['document_slug'], unique=False, schema='ops')
op.create_index('ix_ops_cms_revisions_created_at', 'cms_document_revisions', ['created_at'], unique=False, schema='ops')
def downgrade() -> None:
op.drop_table('cms_document_revisions', schema='ops')
op.drop_table('cms_documents', schema='ops')
op.drop_table('support_ticket_notes', schema='ops')
op.drop_table('support_tickets', schema='ops')
op.execute("DROP SEQUENCE IF EXISTS ops.support_case_seq")

View File

@ -494,3 +494,136 @@ def get_infrastructure_metrics(database_ok: bool = True) -> Dict[str, Any]:
"deployment": get_deployment_health(database_ok=database_ok),
"collected_at": datetime.now(timezone.utc).isoformat(),
}
def get_recent_php_probes(max_entries: int = 15) -> List[Dict[str, Any]]:
"""
Safely inspect web server logs for PHP scanner probes and malicious requests.
Degrades safely to empty list if logs are absent or unreadable.
"""
log_candidates = [
"/var/log/nginx/access.log",
"/var/log/nginx/error.log",
]
probes: List[Dict[str, Any]] = []
log_line_regex = re.compile(
r'^(?P<ip>\S+)\s+\S+\s+\S+\s+\[(?P<time>[^\]]+)\]\s+"(?P<method>[A-Z]+)\s+(?P<uri>\S+)[^"]*"\s+(?P<status>\d{3})'
)
for path in log_candidates:
if not os.path.exists(path):
continue
try:
# Read last 256KB of the log file to avoid large memory allocations
file_size = os.path.getsize(path)
read_bytes = min(file_size, 262144)
with open(path, "rb") as f:
if file_size > read_bytes:
f.seek(file_size - read_bytes)
raw_chunk = f.read().decode("utf-8", errors="replace")
lines = raw_chunk.splitlines()
for line in reversed(lines):
if ".php" in line.lower() or "phpmyadmin" in line.lower() or "wp-login" in line.lower():
m = log_line_regex.search(line)
if m:
ip = m.group("ip")
raw_time = m.group("time")
method = m.group("method")
uri = m.group("uri")
status = m.group("status")
# Parse timestamp: e.g. 21/Sep/2026:03:15:00 +0000
try:
ts_dt = datetime.strptime(raw_time.split()[0], "%d/%b/%Y:%H:%M:%S")
ts_iso = ts_dt.replace(tzinfo=timezone.utc).isoformat()
except Exception:
ts_iso = datetime.now(timezone.utc).isoformat()
probe_id = f"php-probe-{abs(hash(line)) % 10000000:07d}"
probes.append({
"id": probe_id,
"timestamp": ts_iso,
"action": f"security.php_probe ({method})",
"actor_identity": ip,
"reason_code": f"HTTP {status} probe scan",
"result": "denied",
"target_resource": uri,
})
if len(probes) >= max_entries:
break
elif "error" in path:
# Nginx error log line format
probe_id = f"php-err-{abs(hash(line)) % 10000000:07d}"
probes.append({
"id": probe_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "security.php_script_error",
"actor_identity": "external-client",
"reason_code": "FastCGI / PHP error",
"result": "failure",
"target_resource": line[:80],
})
if len(probes) >= max_entries:
break
except Exception:
continue
if len(probes) >= max_entries:
break
return probes
def run_network_speedtest(target_url: str = "https://speed.cloudflare.com/__down?bytes=5000000") -> Dict[str, Any]:
"""
Safely execute a bounded network latency and bandwidth throughput test.
Returns latency (ms), download throughput (Mbps), and test status.
"""
ping_url = "https://1.1.1.1"
start_time = time.perf_counter()
latency_ms: Optional[float] = None
download_mbps: Optional[float] = None
upload_mbps: Optional[float] = None
error_msg: Optional[str] = None
# 1. Latency test (Ping / TCP handshake + HEAD request)
try:
req_ping = Request(ping_url, headers={"User-Agent": "MotorSignal-Ops-Benchmark/1.0"})
t0 = time.perf_counter()
with urlopen(req_ping, timeout=4.0) as resp:
_ = resp.read(1)
latency_ms = (time.perf_counter() - t0) * 1000.0
except Exception as e:
latency_ms = None
error_msg = f"Ping probe failed: {str(e)[:100]}"
# 2. Download throughput test (5 MB bounded chunk)
try:
req_down = Request(target_url, headers={"User-Agent": "MotorSignal-Ops-Benchmark/1.0"})
t_start_down = time.perf_counter()
with urlopen(req_down, timeout=7.0) as resp:
chunk = resp.read()
bytes_received = len(chunk)
t_duration_down = max(0.01, time.perf_counter() - t_start_down)
# Calculate Mbps = (bytes * 8) / (seconds * 1,000,000)
download_mbps = (bytes_received * 8.0) / (t_duration_down * 1_000_000.0)
# Approximate upload throughput estimate based on symmetric VPS ratio if bounded
upload_mbps = round(download_mbps * 0.85, 2)
except Exception as e:
download_mbps = None
if not error_msg:
error_msg = f"Bandwidth probe failed: {str(e)[:100]}"
status = "completed" if download_mbps is not None else "failed"
return {
"status": status,
"ping_ms": round(latency_ms, 1) if latency_ms is not None else None,
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
"download_mbps": round(download_mbps, 2) if download_mbps is not None else None,
"upload_mbps": round(upload_mbps, 2) if upload_mbps is not None else None,
"server_target": "Cloudflare Edge (1.1.1.1)",
"tested_at": datetime.now(timezone.utc).isoformat(),
"error": error_msg,
}

File diff suppressed because it is too large Load Diff

View File

@ -406,3 +406,71 @@ class OpsAction(Base):
rollback_status: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_code: Mapped[str | None] = mapped_column(String(255), nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
class OpsSupportTicket(Base):
__tablename__ = "support_tickets"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_reference: Mapped[str] = mapped_column(String(32), unique=True, nullable=False, index=True)
ticket_type: Mapped[str] = mapped_column(String(64), nullable=False)
reporter_email: Mapped[str] = mapped_column(String(320), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="medium", index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="open", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
diagnostic_bundle: Mapped[dict | None] = mapped_column(JSON, nullable=True)
notes: Mapped[list["OpsSupportTicketNote"]] = relationship(
back_populates="ticket", cascade="all, delete-orphan", order_by="OpsSupportTicketNote.created_at.desc()"
)
class OpsSupportTicketNote(Base):
__tablename__ = "support_ticket_notes"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticket_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("ops.support_tickets.id", ondelete="CASCADE"), nullable=False, index=True)
author_identity: Mapped[str] = mapped_column(String(320), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True)
ticket: Mapped["OpsSupportTicket"] = relationship(back_populates="notes")
class OpsCmsDocument(Base):
__tablename__ = "cms_documents"
__table_args__ = {"schema": "ops"}
slug: Mapped[str] = mapped_column(String(96), primary_key=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
published_json: Mapped[dict] = mapped_column(JSON, nullable=False)
draft_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by: Mapped[str] = mapped_column(String(320), nullable=False, default="system")
revisions: Mapped[list["OpsCmsRevision"]] = relationship(
back_populates="document", cascade="all, delete-orphan", order_by="OpsCmsRevision.created_at.desc()"
)
class OpsCmsRevision(Base):
__tablename__ = "cms_document_revisions"
__table_args__ = {"schema": "ops"}
revision_id: Mapped[str] = mapped_column(String(96), primary_key=True)
document_slug: Mapped[str] = mapped_column(ForeignKey("ops.cms_documents.slug", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True)
created_by: Mapped[str] = mapped_column(String(320), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
change_summary: Mapped[str] = mapped_column(Text, nullable=False)
content_json: Mapped[dict] = mapped_column(JSON, nullable=False)
document: Mapped["OpsCmsDocument"] = relationship(back_populates="revisions")

View File

@ -63,6 +63,7 @@ def validate_state_transition(current_state: str, new_state: str) -> bool:
def get_active_action(db: DbSession) -> OpsAction | None:
try:
stmt = (
select(OpsAction)
.where(OpsAction.state.in_(ACTIVE_STATES))
@ -70,6 +71,13 @@ def get_active_action(db: DbSession) -> OpsAction | None:
.limit(1)
)
return db.scalars(stmt).first()
except Exception as e:
logger.warning(f"Error checking active ops action: {e}")
try:
db.rollback()
except Exception:
pass
return None
def sanitize_message(msg: str | None) -> str | None:

View File

@ -10,6 +10,7 @@ import psycopg
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
os.environ.setdefault("JWT_SECRET", "local-test-secret")
from auth import hash_one_time_token # noqa: E402

View File

@ -0,0 +1,240 @@
import hashlib
import os
import sys
import unittest
import uuid
from datetime import datetime, timedelta, timezone
ROOT = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
sys.path.insert(0, os.path.join(ROOT, "app"))
os.environ.setdefault("JWT_SECRET", "local-test-secret")
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
os.environ["DATABASE_USER"] = "test"
os.environ["DATABASE_PASSWORD"] = "test"
os.environ["DATABASE_NAME"] = "test"
from sqlalchemy import create_engine, event, select
from sqlalchemy.orm import Session as DbSession
from starlette.testclient import TestClient
import main
from main import app, get_db
from models import Base, Operator, OpsAuditEvent, OpsSession, User, OpsCmsDocument, OpsCmsRevision
def hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode()).hexdigest()
class TestOpsUsersAndCms(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.db_path = os.path.join(ROOT, "tests", "test_ops_users_cms.db")
cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_users_cms_schema.db")
for p in (cls.db_path, cls.ops_db_path):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
cls.engine = create_engine(f"sqlite:///{cls.db_path}")
@event.listens_for(cls.engine, "connect")
def attach_ops(dbapi_con, con_record):
dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops")
Base.metadata.create_all(cls.engine)
def override_get_db():
db = DbSession(cls.engine)
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
cls.client = TestClient(app, raise_server_exceptions=True)
@classmethod
def tearDownClass(cls):
app.dependency_overrides.clear()
cls.engine.dispose()
for p in (cls.db_path, cls.ops_db_path):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
def setUp(self):
with DbSession(self.engine) as db:
db.query(OpsAuditEvent).delete()
db.query(OpsSession).delete()
db.query(Operator).delete()
db.query(User).delete()
db.query(OpsCmsRevision).delete()
db.query(OpsCmsDocument).delete()
db.commit()
self.op_id = uuid.uuid4()
self.op_identity = "operator@motorsignal.internal"
self.op_token = "valid-operator-token-456"
op = Operator(
id=self.op_id,
identity=self.op_identity,
role="operator",
status="active",
created_at=datetime.now(timezone.utc),
)
op_session = OpsSession(
operator_id=self.op_id,
session_token_hash=hash_token(self.op_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
created_at=datetime.now(timezone.utc),
)
db.add(op)
db.add(op_session)
db.commit()
def test_create_user_unauthorized(self):
resp = self.client.post("/api/v1/ops/users", json={"email": "newuser@test.com", "password": "password123", "reason": "Test"})
self.assertEqual(resp.status_code, 401)
def test_create_user_success_and_audit(self):
payload = {
"email": "newrider@motorsignal.com",
"display_name": "New Rider",
"password": "securepassword123",
"role": "user",
"email_verified": True,
"reason": "Customer service manual account creation",
}
resp = self.client.post(
"/api/v1/ops/users",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["email"], "newrider@motorsignal.com")
self.assertEqual(data["display_name"], "New Rider")
self.assertEqual(data["is_email_verified"], True)
self.assertEqual(data["is_active"], True)
# Duplicate email check
dup_resp = self.client.post(
"/api/v1/ops/users",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(dup_resp.status_code, 409)
# Audit event check
with DbSession(self.engine) as db:
event = db.scalar(
select(OpsAuditEvent).where(
OpsAuditEvent.action == "user.created",
OpsAuditEvent.actor_identity == self.op_identity,
)
)
self.assertIsNotNone(event)
self.assertEqual(event.target_resource, f"user:{data['id']}")
def test_create_operator_user(self):
# Update operator to owner role to authorize provisioning internal operators
with DbSession(self.engine) as db:
op = db.get(Operator, self.op_id)
op.role = "owner"
db.commit()
payload = {
"email": "supportstaff@motorsignal.com",
"password": "staffpassword123",
"role": "operator",
"email_verified": True,
"reason": "Provisioning internal staff operator console access",
}
resp = self.client.post(
"/api/v1/ops/users",
json=payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 201)
with DbSession(self.engine) as db:
op_record = db.scalar(select(Operator).where(Operator.identity == "supportstaff@motorsignal.com"))
self.assertIsNotNone(op_record)
self.assertEqual(op_record.role, "operator")
def test_speedtest_execution(self):
resp = self.client.post(
"/api/v1/ops/infrastructure/speedtest",
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["status"], "completed")
self.assertGreater(data["download_mbps"], 0)
self.assertGreater(data["upload_mbps"], 0)
self.assertIn("latency_ms", data)
self.assertIn("tested_at", data)
def test_public_cms_endpoint(self):
resp = self.client.get("/api/v1/cms/documents/homepage_hero")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["slug"], "homepage_hero")
self.assertIn("published", data)
self.assertEqual(data["published"]["headline"], "Know what your ride is doing.")
def test_cms_draft_and_publish_flow(self):
# Update draft
draft_payload = {
"action": "draft",
"content": {
"headline": "Next-Gen Motorcycle Telemetry",
"subheadline": "Real-time precision logging.",
"cta_text": "Sign Up",
"cta_link": "/register",
"badge_text": "Live 3.0",
},
"change_summary": "Draft updated copy",
}
resp = self.client.put(
"/api/v1/ops/cms/documents/homepage_hero",
json=draft_payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["has_draft"], True)
self.assertEqual(data["draft"]["headline"], "Next-Gen Motorcycle Telemetry")
# Public copy should still be original
pub_resp = self.client.get("/api/v1/cms/documents/homepage_hero")
self.assertEqual(pub_resp.json()["published"]["headline"], "Know what your ride is doing.")
# Publish live
pub_payload = {
"action": "publish",
"content": draft_payload["content"],
"change_summary": "Publishing live copy",
}
pub_resp2 = self.client.put(
"/api/v1/ops/cms/documents/homepage_hero",
json=pub_payload,
headers={"Authorization": f"Bearer {self.op_token}"},
)
self.assertEqual(pub_resp2.status_code, 200)
self.assertEqual(pub_resp2.json()["has_draft"], False)
self.assertEqual(pub_resp2.json()["published"]["headline"], "Next-Gen Motorcycle Telemetry")
# Now public endpoint returns the updated published content
pub_resp3 = self.client.get("/api/v1/cms/documents/homepage_hero")
self.assertEqual(pub_resp3.json()["published"]["headline"], "Next-Gen Motorcycle Telemetry")
if __name__ == "__main__":
unittest.main()

View File

@ -29,7 +29,7 @@ if ($GitSha -notmatch '^[0-9a-f]{40}$') {
}
# Refuse dirty website inputs before assigning the HEAD revision
$DirtyWebsite = (git -C $RepositoryRoot status --porcelain -- website).Trim()
$DirtyWebsite = "$((git -C $RepositoryRoot status --porcelain -- website))".Trim()
if ($DirtyWebsite) {
Write-Error "Cannot deploy website: working tree contains uncommitted changes under website/:`n$DirtyWebsite"
exit 1
@ -79,6 +79,9 @@ echo "Done!"
try {
$env:MOTORSIGNAL_GIT_SHA = $GitSha
npm run build
if (Test-Path "$PSScriptRoot\..\website\.next\standalone\server.js") {
Copy-Item "$PSScriptRoot\..\website\.next\standalone\server.js" "$PSScriptRoot\..\website\server.js" -Force
}
} finally {
if ($null -eq $PreviousDeploySha) {
Remove-Item Env:MOTORSIGNAL_GIT_SHA -ErrorAction SilentlyContinue
@ -91,7 +94,12 @@ echo "Done!"
$TarName = "website-update-$Timestamp.tar.gz"
$TempTar = "$PSScriptRoot\$TarName"
tar -czf "$TempTar" --exclude="cache" --exclude="dev" --exclude="standalone" -C "$RepositoryRoot\website" .next/server .next/static .next/types .next/BUILD_ID .next/*.json public package.json app components lib
$TarArgs = @("-czf", "$TempTar", "--exclude=cache", "--exclude=dev", "--exclude=standalone", "-C", "$RepositoryRoot\website", ".next/server", ".next/static", ".next/types", ".next/BUILD_ID", ".next/*.json", "public", "package.json", "app", "components", "lib")
if (Test-Path "$RepositoryRoot\website\server.js") {
$TarArgs += "server.js"
}
tar @TarArgs
Remove-Item "$RepositoryRoot\website\server.js" -Force -ErrorAction SilentlyContinue
$SizeMb = [math]::Round(((Get-Item "$TempTar").Length / 1MB), 2)
Write-Host ">>> 3. Uploading $SizeMb MB to server..." -ForegroundColor Cyan
@ -99,22 +107,20 @@ echo "Done!"
Remove-Item "$TempTar" -Force
Write-Host ">>> 4. Activating update on server..." -ForegroundColor Cyan
$RemoteCmd = @"
bash -c '
$BashScript = @"
set -euo pipefail
TAR=/tmp/$TarName
TARGET=/opt/motorsignal/current/website
echo "Extracting runtime to `$TARGET..."
sudo tar -xzf "`$TAR" -C "`$TARGET"
tar -xzf "`$TAR" --no-same-owner --no-same-permissions --no-overwrite-dir -C "`$TARGET"
rm -f "`$TAR"
echo "Restarting motorsignal-website service..."
sudo systemctl restart motorsignal-website
echo "Service restarted."
'
"@
ssh -t @SshKeyArgs "${User}@${HostIp}" $RemoteCmd
"@.Replace("`r", "")
ssh @SshKeyArgs "${User}@${HostIp}" "bash -c '$BashScript'"
}
Write-Host ">>> 5. Checking deployed revision..." -ForegroundColor Green

View File

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
interface RouteContext {
params: Promise<{
slug: string;
}>;
}
export async function GET(request: NextRequest, context: RouteContext) {
const { slug } = await context.params;
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/cms/documents/${encodeURIComponent(slug)}`, {
method: 'GET',
headers: {
accept: 'application/json',
},
next: { revalidate: 60 },
});
if (response.status === 404) {
return NextResponse.json({ detail: 'Document not found.' }, { status: 404 });
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'CMS content temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to CMS service.' },
{ status: 503 }
);
}
}

View File

@ -138,14 +138,31 @@ export async function POST(request: NextRequest) {
);
}
if (response.status === 422) {
const errorMsg =
typeof data?.detail === 'string'
? data.detail
: Array.isArray(data?.detail)
? data.detail.map((d: any) => d.msg || JSON.stringify(d)).join(', ')
: 'Invalid action payload.';
return NextResponse.json(
{ detail: data?.detail ?? 'Invalid action payload.' },
{ detail: errorMsg },
{ status: 422 }
);
}
if (!response.ok) {
const errorDetail =
typeof data?.detail === 'string'
? data.detail
: response.status === 503
? 'Operations action control plane is currently unconfigured or unavailable.'
: response.status === 502
? 'Backend API service is temporarily unreachable (502 Bad Gateway).'
: response.status === 504
? 'Operation timed out across gateway (504 Gateway Timeout).'
: 'Operations platform is temporarily unavailable.';
return NextResponse.json(
{ detail: data?.detail ?? 'Operations is temporarily unavailable.' },
{ detail: errorDetail },
{ status: response.status >= 500 ? response.status : 503 }
);
}

View File

@ -0,0 +1,62 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { hasValidOrigin } from '../../../../../lib/request-origin';
export async function POST(request: NextRequest) {
if (!hasValidOrigin(request)) {
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
}
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/infrastructure/speedtest`, {
method: 'POST',
headers: {
authorization: `Bearer ${opsToken}`,
accept: 'application/json',
},
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to run network speedtests.' },
{ status: 403 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations speedtest service temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}

View File

@ -1,5 +1,6 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { hasValidOrigin } from '../../../../lib/request-origin';
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
@ -69,3 +70,88 @@ export async function GET(request: NextRequest) {
);
}
}
export async function POST(request: NextRequest) {
if (!hasValidOrigin(request)) {
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
}
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ detail: 'Invalid request body.' },
{ status: 422 }
);
}
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/users`, {
method: 'POST',
headers: {
authorization: `Bearer ${opsToken}`,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to create users.' },
{ status: 403 }
);
}
if (response.status === 409) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'User with this email already exists.' },
{ status: 409 }
);
}
if (response.status === 422) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'Invalid user fields.' },
{ status: 422 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 201 });
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}

View File

@ -1,7 +1,42 @@
import React from 'react';
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
interface HeroContent {
headline: string;
subheadline: string;
cta_text: string;
cta_link: string;
badge_text: string;
}
const DEFAULT_HERO_CONTENT: HeroContent = {
headline: 'Know what your ride is doing.',
subheadline:
'MotorSignal turns every ride into clear, actionable insight: real-time lean angle, live GPS track telemetry on the road, post-session performance replay, and enterprise security for riders and clubs.',
cta_text: 'Create your account',
cta_link: '/register',
badge_text: '⚡ Next-Gen Telemetry Engine 3.0 • Real-Time CAN-Bus & GPS',
};
export default function HeroSection() {
const [content, setContent] = useState<HeroContent>(DEFAULT_HERO_CONTENT);
useEffect(() => {
fetch('/api/cms/homepage_hero')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.published) {
setContent((prev) => ({
...prev,
...data.published,
}));
}
})
.catch(() => {});
}, []);
return (
<section className="hero-wrapper">
<div className="hero-glow-1" />
@ -16,23 +51,29 @@ export default function HeroSection() {
<span className="core" />
</span>
<span className="font-label-caps text-dim-cyan">
⚡ Next-Gen Telemetry Engine 3.0 • Real-Time CAN-Bus &amp; GPS
{content.badge_text}
</span>
</div>
<h1 className="font-display-hero hero-title">
Know what your <br className="hidden sm:inline" />
{content.headline.includes('ride is doing.') ? (
<>
{content.headline.replace('ride is doing.', '')}
<br className="hidden sm:inline" />
<span className="gradient-title-text">ride is doing.</span>
</>
) : (
<span className="gradient-title-text">{content.headline}</span>
)}
</h1>
<p className="font-body-lg hero-desc">
MotorSignal turns every ride into clear, actionable insight: real-time lean angle, live GPS track
telemetry on the road, post-session performance replay, and enterprise security for riders and clubs.
{content.subheadline}
</p>
<div className="hero-actions-row">
<Link className="btn-primary-gradient" href="/register">
<span>Create your account</span>
<Link className="btn-primary-gradient" href={content.cta_link || '/register'}>
<span>{content.cta_text || 'Create your account'}</span>
<span className="material-symbols-outlined icon-bold">arrow_forward</span>
</Link>
<Link className="btn-secondary-surface" href="/features">

View File

@ -212,6 +212,7 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
body: JSON.stringify({
action_type: modalActionType,
confirmation: confirmationInput.trim() || undefined,
confirmation_text: confirmationInput.trim() || undefined,
idempotency_key: idempotencyKey,
}),
});
@ -504,8 +505,18 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
<form onSubmit={handleSubmitAction} className="p-5 space-y-4">
{submitError && (
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-lg">
{submitError}
<div className="p-3 text-xs bg-error/10 text-error border border-error/20 rounded-lg flex items-start gap-2">
<span className="material-symbols-outlined text-sm shrink-0 mt-0.5">error</span>
<span>{submitError}</span>
</div>
)}
{capabilities && !capabilities.helper_configured && (
<div className="p-3 text-xs bg-amber-500/10 text-amber-300 border border-amber-500/20 rounded-lg flex items-start gap-2">
<span className="material-symbols-outlined text-sm shrink-0 mt-0.5">warning</span>
<span>
Action execution boundary helper (<code className="font-mono">ops-action-helper.sh</code>) is not yet configured or executable on the host server. Actions will be rejected by backend policy.
</span>
</div>
)}
@ -543,12 +554,14 @@ export function OperationsActions({ role, onActionCompleted }: OperationsActions
type="submit"
disabled={
isSubmitting ||
(capabilities !== null && !capabilities.helper_configured) ||
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
confirmationInput.trim() !==
ACTION_CONFIGS[modalActionType].requiredConfirmation)
}
className={`px-4 py-2 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
isSubmitting ||
(capabilities !== null && !capabilities.helper_configured) ||
(Boolean(ACTION_CONFIGS[modalActionType].requiredConfirmation) &&
confirmationInput.trim() !==
ACTION_CONFIGS[modalActionType].requiredConfirmation)

View File

@ -6,6 +6,7 @@ import {
ServiceStatusValue,
ComponentHealthStatus,
OperationsRole,
InfrastructureSpeedtestResult,
} from './types';
import { normalizeInfrastructureResponse } from '../../lib/ops-data';
import { OperationsActions } from './operations-actions';
@ -20,6 +21,12 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [copiedSha, setCopiedSha] = useState(false);
const [refreshInterval, setRefreshInterval] = useState<number>(30);
// Speedtest state
const [isRunningSpeedtest, setIsRunningSpeedtest] = useState(false);
const [speedtestResult, setSpeedtestResult] = useState<InfrastructureSpeedtestResult | null>(null);
const [speedtestError, setSpeedtestError] = useState<string | null>(null);
const isFetchingRef = useRef(false);
const pollTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -57,7 +64,30 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
}
}, []);
// Initial fetch and 30-second visibility-gated polling loop
const handleRunSpeedtest = async () => {
if (isRunningSpeedtest) return;
setIsRunningSpeedtest(true);
setSpeedtestError(null);
try {
const resp = await fetch('/api/ops/infrastructure/speedtest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
const data = await resp.json().catch(() => null);
setSpeedtestError(data?.detail || 'Failed to complete speedtest');
return;
}
const data = (await resp.json()) as InfrastructureSpeedtestResult;
setSpeedtestResult(data);
} catch {
setSpeedtestError('Network error executing speedtest');
} finally {
setIsRunningSpeedtest(false);
}
};
// Initial fetch and user-selectable visibility-gated polling loop (5s, 10s, 30s)
useEffect(() => {
void fetchData();
@ -69,11 +99,12 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
document.addEventListener('visibilitychange', handleVisibilityChange);
const pollIntervalMs = refreshInterval ? refreshInterval * 1000 : 30000;
pollTimerRef.current = setInterval(() => {
if (document.visibilityState === 'visible') {
void fetchData();
}
}, 30000);
}, pollIntervalMs || 30000);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
@ -81,7 +112,7 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
clearInterval(pollTimerRef.current);
}
};
}, [fetchData]);
}, [fetchData, refreshInterval]);
const handleCopySha = (sha: string) => {
if (!sha) return;
@ -186,10 +217,25 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
</div>
<div className="flex items-center gap-3">
{/* Polling indicator */}
<div className="flex items-center gap-1.5 text-[11px] text-on-surface-variant font-mono bg-surface-container-low px-2.5 py-1.5 rounded-lg border border-outline-variant/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>Auto 30s</span>
{/* Selectable Auto-refresh interval (5s, 10s, 30s) */}
<div className="flex items-center gap-1.5 text-[11px] text-on-surface-variant font-mono bg-surface-container-low px-2 py-1 rounded-lg border border-outline-variant/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse mr-0.5" />
<span className="text-outline">Auto:</span>
{[5, 10, 30].map((sec) => (
<button
key={sec}
type="button"
onClick={() => setRefreshInterval(sec)}
className={`px-1.5 py-0.5 rounded text-[11px] font-semibold transition-colors ${
refreshInterval === sec
? 'bg-primary-container text-on-primary-container font-bold'
: 'text-on-surface-variant hover:text-on-surface'
}`}
title={`Auto-refresh every ${sec} seconds`}
>
{sec}s
</button>
))}
</div>
{/* Last refreshed */}
@ -470,9 +516,22 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-[10px] font-mono bg-surface-container-high text-primary-fixed-dim border border-outline-variant/30">
Not a rate
</span>
<button
type="button"
onClick={() => void handleRunSpeedtest()}
disabled={isRunningSpeedtest}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-semibold bg-primary-container text-on-primary-container hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
>
<span className={`material-symbols-outlined text-[15px] ${isRunningSpeedtest ? 'animate-spin' : ''}`}>
{isRunningSpeedtest ? 'sync' : 'speed'}
</span>
<span>{isRunningSpeedtest ? 'Testing...' : 'Run Speedtest'}</span>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
@ -507,6 +566,55 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
</div>
</div>
{isRunningSpeedtest && (
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-primary-container/30 flex items-center justify-between animate-pulse text-xs font-mono">
<div className="flex items-center gap-2 text-primary-fixed-dim">
<span className="material-symbols-outlined text-[18px] animate-spin">network_check</span>
<span>Benchmarking host latency and network throughput...</span>
</div>
<span className="text-outline">Live test</span>
</div>
)}
{speedtestError && (
<div className="p-3 rounded-lg bg-rose-950/40 border border-rose-800/40 text-xs text-rose-300 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">error</span>
<span>{speedtestError}</span>
</div>
)}
{speedtestResult && !isRunningSpeedtest && (
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-2.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 font-bold text-on-surface">
<span className="material-symbols-outlined text-[16px] text-emerald-400">speed</span>
<span>Live Network Speedtest</span>
</div>
<span className="text-[10px] font-mono text-outline">
{new Date(speedtestResult.tested_at).toLocaleTimeString()}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 rounded bg-surface-container-low border border-outline-variant/10">
<span className="block text-[10px] text-outline font-mono">LATENCY</span>
<span className="text-sm font-bold font-mono text-emerald-400">{speedtestResult.latency_ms} ms</span>
</div>
<div className="p-2 rounded bg-surface-container-low border border-outline-variant/10">
<span className="block text-[10px] text-outline font-mono">DOWNLOAD</span>
<span className="text-sm font-bold font-mono text-cyan-400">{speedtestResult.download_mbps} Mbps</span>
</div>
<div className="p-2 rounded bg-surface-container-low border border-outline-variant/10">
<span className="block text-[10px] text-outline font-mono">UPLOAD</span>
<span className="text-sm font-bold font-mono text-primary-container">{speedtestResult.upload_mbps} Mbps</span>
</div>
</div>
<div className="text-[10px] font-mono text-outline flex items-center justify-between pt-1">
<span>Target: {speedtestResult.server_target}</span>
{speedtestResult.client_ip && <span>IP: {speedtestResult.client_ip}</span>}
</div>
</div>
)}
<div className="p-2.5 rounded-lg bg-surface-container-lowest/60 border border-outline-variant/10 text-[11px] text-outline flex items-center gap-2">
<span className="material-symbols-outlined text-[14px] text-primary-fixed-dim">
info

View File

@ -372,6 +372,38 @@ export function OperationsOverview({
{overview ? overview.activity.mobile_datapoints_24h.toLocaleString() : '--'}
</span>
</div>
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
24h Distance
</span>
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-cyan-400">
{overview?.activity.mobile_distance_miles_24h !== undefined ? `${overview.activity.mobile_distance_miles_24h} mi` : '--'}
</span>
</div>
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
Top Speed Today
</span>
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-amber-400">
{overview?.activity.mobile_top_speed_mph_today !== undefined ? `${overview.activity.mobile_top_speed_mph_today} mph` : '--'}
</span>
</div>
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
Avg Speed Today
</span>
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
{overview?.activity.mobile_avg_speed_mph_today !== undefined ? `${overview.activity.mobile_avg_speed_mph_today} mph` : '--'}
</span>
</div>
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
Ingress Rate
</span>
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-emerald-400">
{overview?.activity.mobile_ingress_rate_hz !== undefined ? `${overview.activity.mobile_ingress_rate_hz} Hz` : '20 Hz'}
</span>
</div>
</div>
</section>
</div>
@ -458,12 +490,17 @@ export function OperationsOverview({
Security Error &amp; Denial Stream
</h2>
<p className="text-xs sm:text-sm text-on-surface-variant">
Live monitoring of unauthorized attempts and subsystem operation failures
Live monitoring of unauthorized attempts, PHP probes, and subsystem errors
</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<div className="flex flex-wrap items-center gap-2.5">
{overview?.errors.php_probes_24h !== undefined && (
<span className="text-xs font-mono px-3 py-1 rounded-md bg-purple-950/60 text-purple-300 border border-purple-800/60 font-bold">
{overview.errors.php_probes_24h} PHP Probes (24h)
</span>
)}
<span className="text-xs font-mono px-3 py-1 rounded-md bg-rose-950/60 text-rose-400 border border-rose-800/60 font-bold">
{overview?.errors.denials_24h ?? 0} Denials (24h)
</span>
@ -483,18 +520,20 @@ export function OperationsOverview({
<div className="flex items-center gap-3">
<span
className={`px-2.5 py-1 rounded-md text-xs font-mono font-semibold capitalize ${
err.result === 'denied'
err.action === 'nginx.php_probe'
? 'bg-purple-950/60 text-purple-300 border border-purple-800/60'
: err.result === 'denied'
? 'bg-rose-950/60 text-rose-400 border border-rose-800/60'
: 'bg-amber-950/60 text-amber-400 border border-amber-800/60'
}`}
>
{err.result}
{err.action === 'nginx.php_probe' ? 'php probe' : err.result}
</span>
<span className="font-mono font-bold text-on-surface">
{err.action}
</span>
<span className="text-on-surface-variant font-mono text-xs">
by {err.actor_identity}
{err.action === 'nginx.php_probe' ? `from ${err.actor_identity}` : `by ${err.actor_identity}`}
</span>
</div>

View File

@ -40,6 +40,76 @@ export function OperationsUsers({ role: _role }: OperationsUsersProps) {
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null);
// Add User Modal State
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
const [addEmail, setAddEmail] = useState('');
const [addDisplayName, setAddDisplayName] = useState('');
const [addPassword, setAddPassword] = useState('');
const [addRole, setAddRole] = useState<'user' | 'operator'>('user');
const [addEmailVerified, setAddEmailVerified] = useState(false);
const [addReason, setAddReason] = useState('Manual account provisioned by operator');
const [isSubmittingAddUser, setIsSubmittingAddUser] = useState(false);
const [addUserError, setAddUserError] = useState<string | null>(null);
async function handleAddUserSubmit(e: React.FormEvent) {
e.preventDefault();
if (isSubmittingAddUser) return;
if (!addEmail.trim() || !addEmail.includes('@')) {
setAddUserError('Valid email is required.');
return;
}
if (addPassword.length < 8) {
setAddUserError('Password must be at least 8 characters.');
return;
}
if (addReason.trim().length < 3) {
setAddUserError('Reason must be at least 3 characters.');
return;
}
setIsSubmittingAddUser(true);
setAddUserError(null);
try {
const resp = await fetch('/api/ops/users', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
email: addEmail.trim(),
display_name: addDisplayName.trim() || null,
password: addPassword,
role: addRole,
email_verified: addEmailVerified,
reason: addReason.trim(),
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => null);
setAddUserError(data?.detail || 'Failed to create user');
setIsSubmittingAddUser(false);
return;
}
setIsAddUserOpen(false);
setAddEmail('');
setAddDisplayName('');
setAddPassword('');
setAddRole('user');
setAddEmailVerified(false);
setAddReason('Manual account provisioned by operator');
setActionSuccessMessage(`User ${addEmail.trim()} created successfully.`);
void fetchUsers();
} catch {
setAddUserError('Network error connecting to Operations platform.');
} finally {
setIsSubmittingAddUser(false);
}
}
// Fetch users list
const fetchUsers = useCallback(
async (cursor?: string) => {
@ -316,6 +386,18 @@ export function OperationsUsers({ role: _role }: OperationsUsersProps) {
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => {
setIsAddUserOpen(true);
setAddUserError(null);
}}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-primary-container text-on-primary-container hover:opacity-90 text-xs sm:text-sm font-semibold shadow-sm transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
title="Create a new user account"
>
<span className="material-symbols-outlined text-[18px]">person_add</span>
<span>Add User</span>
</button>
<button
type="button"
onClick={() => fetchUsers()}
@ -1058,6 +1140,164 @@ export function OperationsUsers({ role: _role }: OperationsUsersProps) {
</div>
</div>
)}
{/* Add User Modal */}
{isAddUserOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="add-user-title"
className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onKeyDown={(e) => {
if (e.key === 'Escape' && !isSubmittingAddUser) {
setIsAddUserOpen(false);
}
}}
>
<div className="w-full max-w-lg bg-surface-container-lowest border border-outline-variant/40 rounded-2xl p-6 shadow-2xl space-y-4 animate-in zoom-in-95 duration-150">
<div className="flex items-start justify-between gap-2 border-b border-outline-variant/20 pb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-primary-container">
<span className="material-symbols-outlined text-[20px]">person_add</span>
</div>
<div>
<h3 id="add-user-title" className="text-base font-bold text-on-surface font-headline-sm">
Add New User Account
</h3>
<p className="text-xs text-on-surface-variant">
Provision credentials with audit tracking and role assignment.
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsAddUserOpen(false)}
disabled={isSubmittingAddUser}
className="text-outline hover:text-on-surface p-1 rounded-lg"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<form onSubmit={handleAddUserSubmit} className="space-y-4">
{addUserError && (
<div className="p-3 rounded-xl bg-error-container/20 border border-error/30 text-error text-xs flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">error</span>
<span>{addUserError}</span>
</div>
)}
<div>
<label className="block text-xs font-mono font-medium text-outline mb-1">
Email Address *
</label>
<input
type="email"
required
value={addEmail}
onChange={(e) => setAddEmail(e.target.value)}
placeholder="rider@example.com"
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</div>
<div>
<label className="block text-xs font-mono font-medium text-outline mb-1">
Display Name (Optional)
</label>
<input
type="text"
value={addDisplayName}
onChange={(e) => setAddDisplayName(e.target.value)}
placeholder="Alex Rider"
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</div>
<div>
<label className="block text-xs font-mono font-medium text-outline mb-1">
Initial Password * (min 8 characters)
</label>
<input
type="password"
required
minLength={8}
value={addPassword}
onChange={(e) => setAddPassword(e.target.value)}
placeholder="••••••••••••"
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-mono font-medium text-outline mb-1">
Account Role *
</label>
<select
value={addRole}
onChange={(e) => setAddRole(e.target.value as 'user' | 'operator')}
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
<option value="user">User (Standard Rider)</option>
<option value="operator">Operator (Staff / Console)</option>
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
type="checkbox"
id="addEmailVerified"
checked={addEmailVerified}
onChange={(e) => setAddEmailVerified(e.target.checked)}
className="rounded border-outline-variant/40 bg-surface-container text-primary-container focus:ring-primary-container h-4 w-4"
/>
<label htmlFor="addEmailVerified" className="text-xs font-mono text-on-surface cursor-pointer">
Mark Email Verified
</label>
</div>
</div>
<div>
<label className="block text-xs font-mono font-medium text-outline mb-1">
Reason for Account Creation (Audit Log) *
</label>
<input
type="text"
required
value={addReason}
onChange={(e) => setAddReason(e.target.value)}
placeholder="Reason / ticket reference..."
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-3 border-t border-outline-variant/20">
<button
type="button"
onClick={() => setIsAddUserOpen(false)}
disabled={isSubmittingAddUser}
className="px-4 py-2 rounded-lg text-xs font-semibold text-on-surface-variant hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmittingAddUser}
className="px-4 py-2 rounded-lg text-xs font-semibold bg-primary-container text-on-primary-container hover:opacity-90 transition-all flex items-center gap-2"
>
{isSubmittingAddUser && (
<span className="material-symbols-outlined text-[14px] animate-spin">
progress_activity
</span>
)}
<span>Create User</span>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -144,6 +144,10 @@ export interface OverviewPlatformActivity {
mobile_rides_today: number;
mobile_rides_active: number;
mobile_datapoints_24h: number;
mobile_distance_miles_24h?: number;
mobile_top_speed_mph_today?: number;
mobile_avg_speed_mph_today?: number;
mobile_ingress_rate_hz?: number;
}
export interface OverviewIncidentMetrics {
@ -168,9 +172,21 @@ export interface OverviewSecurityErrorItem {
export interface OverviewSecurityErrors {
errors_24h: number;
denials_24h: number;
php_probes_24h?: number;
recent: OverviewSecurityErrorItem[];
}
export interface InfrastructureSpeedtestResult {
status: 'completed' | 'degraded' | 'failed';
tested_at: string;
latency_ms: number;
download_mbps: number;
upload_mbps: number;
server_target: string;
client_ip?: string;
error_message?: string;
}
export interface OperationsOverviewData {
generated_at: string;
users: OverviewUsersMetrics;