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:
parent
cf7c99b210
commit
fb67252bc8
|
|
@ -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")
|
||||
|
|
@ -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
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -63,13 +63,21 @@ def validate_state_transition(current_state: str, new_state: str) -> bool:
|
|||
|
||||
|
||||
def get_active_action(db: DbSession) -> OpsAction | None:
|
||||
stmt = (
|
||||
select(OpsAction)
|
||||
.where(OpsAction.state.in_(ACTIVE_STATES))
|
||||
.order_by(OpsAction.request_timestamp.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return db.scalars(stmt).first()
|
||||
try:
|
||||
stmt = (
|
||||
select(OpsAction)
|
||||
.where(OpsAction.state.in_(ACTIVE_STATES))
|
||||
.order_by(OpsAction.request_timestamp.desc())
|
||||
.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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & GPS
|
||||
{content.badge_text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-display-hero hero-title">
|
||||
Know what your <br className="hidden sm:inline" />
|
||||
<span className="gradient-title-text">ride is doing.</span>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue