feat(ops): add dual speedtest options, support ticket fixes, cms sync, and overview card redesign (#23)
Co-authored-by: Developer <dev@example.com>
This commit is contained in:
parent
e8cde3a9a1
commit
54886753b7
|
|
@ -0,0 +1,27 @@
|
|||
"""add assigned_to to ops incidents
|
||||
|
||||
Revision ID: f1a2b3c4d5ea
|
||||
Revises: f1a2b3c4d5e9
|
||||
Create Date: 2026-09-22
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f1a2b3c4d5ea'
|
||||
down_revision = 'f1a2b3c4d5e9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"incidents",
|
||||
sa.Column("assigned_to", sa.String(length=320), nullable=True),
|
||||
schema="ops",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("incidents", "assigned_to", schema="ops")
|
||||
|
|
@ -4,15 +4,19 @@ import os
|
|||
import smtplib
|
||||
import secrets
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
||||
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
import copy
|
||||
|
||||
from auth import (
|
||||
create_access_token,
|
||||
|
|
@ -1065,6 +1069,7 @@ class OpsIncidentPatch(BaseModel):
|
|||
severity: str | None = None
|
||||
status: str | None = None
|
||||
resolution: str | None = Field(default=None, max_length=4000)
|
||||
assigned_to: str | None = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class OpsIncidentNoteCreate(BaseModel):
|
||||
|
|
@ -1172,7 +1177,7 @@ def ops_get_me(
|
|||
|
||||
|
||||
@app.get("/api/v1/ops/health")
|
||||
def ops_get_health():
|
||||
def ops_get_health(db: DbSession = Depends(get_db)):
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db_ok = False
|
||||
try:
|
||||
|
|
@ -1180,7 +1185,23 @@ def ops_get_health():
|
|||
except Exception:
|
||||
db_ok = False
|
||||
|
||||
overall = "available" if db_ok else "degraded"
|
||||
missing_action_outcomes = 0
|
||||
if db_ok and db is not None:
|
||||
try:
|
||||
five_mins_ago = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
missing_action_outcomes = db.scalar(
|
||||
select(func.count()).select_from(OpsAction)
|
||||
.where(
|
||||
OpsAction.completion_timestamp.is_(None),
|
||||
OpsAction.state.in_(["queued", "running"]),
|
||||
OpsAction.request_timestamp < five_mins_ago,
|
||||
)
|
||||
) or 0
|
||||
except Exception:
|
||||
missing_action_outcomes = 0
|
||||
|
||||
audit_status = "degraded" if missing_action_outcomes > 0 else ("available" if db_ok else "degraded")
|
||||
overall = "available" if (db_ok and missing_action_outcomes == 0) else "degraded"
|
||||
return {
|
||||
"status": overall,
|
||||
"checked_at": now_iso,
|
||||
|
|
@ -1195,11 +1216,11 @@ def ops_get_health():
|
|||
},
|
||||
"session_validation": {
|
||||
"status": "available" if db_ok else "degraded",
|
||||
"description": "__Host-motorsignal_ops token verification & authority",
|
||||
"description": "Checks staff sessions and enforces owner/operator permissions.",
|
||||
},
|
||||
"audit_persistence": {
|
||||
"status": "available" if db_ok else "degraded",
|
||||
"description": "Immutable operational security activity persistence",
|
||||
"status": audit_status,
|
||||
"description": "Records administrative actions and their outcomes.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -1221,11 +1242,47 @@ def ops_get_infrastructure(
|
|||
|
||||
@app.post("/api/v1/ops/infrastructure/speedtest")
|
||||
def ops_run_speedtest(
|
||||
payload: Optional[dict[str, Any]] = Body(default=None),
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
operator, _ = auth
|
||||
if payload and payload.get("mode") == "client_to_server":
|
||||
ping_val = payload.get("latency_ms") or payload.get("ping_ms")
|
||||
dl_val = payload.get("download_mbps")
|
||||
ul_val = payload.get("upload_mbps")
|
||||
client_ip = payload.get("client_ip")
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="infrastructure.speedtest.client",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="host:network:client",
|
||||
reason_code="client_network_benchmark",
|
||||
result="success",
|
||||
metadata={
|
||||
"ping_ms": ping_val,
|
||||
"download_mbps": dl_val,
|
||||
"upload_mbps": ul_val,
|
||||
"client_ip": client_ip,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"status": "completed",
|
||||
"mode": "client_to_server",
|
||||
"latency_ms": ping_val,
|
||||
"download_mbps": dl_val,
|
||||
"upload_mbps": ul_val,
|
||||
"server_target": "MotorSignal Host Edge",
|
||||
"client_ip": client_ip,
|
||||
"direction_label": "Operator Browser ➔ MotorSignal Server",
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
result = run_network_speedtest()
|
||||
result["mode"] = "server_to_external"
|
||||
result["direction_label"] = "MotorSignal Server ➔ Upstream Edge (1.1.1.1)"
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="infrastructure.speedtest",
|
||||
|
|
@ -1253,17 +1310,30 @@ def ops_get_overview(
|
|||
now = datetime.now(timezone.utc)
|
||||
t_24h = now - timedelta(hours=24)
|
||||
t_7d = now - timedelta(days=7)
|
||||
t_30d = now - timedelta(days=30)
|
||||
now_iso = now.isoformat()
|
||||
|
||||
# Exclude staff Operations sessions from customer activity counts
|
||||
staff_emails = [op.identity for op in db.scalars(select(Operator)).all()]
|
||||
staff_user_ids = db.scalars(select(User.id).where(User.email.in_(staff_emails))).all() if staff_emails else []
|
||||
|
||||
total_users = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
new_users_24h = db.scalar(select(func.count()).select_from(User).where(User.created_at >= t_24h)) or 0
|
||||
new_users_7d = db.scalar(select(func.count()).select_from(User).where(User.created_at >= t_7d)) or 0
|
||||
active_users_24h = db.scalar(
|
||||
select(func.count(func.distinct(Session.user_id)))
|
||||
.where(Session.last_used_at >= t_24h, Session.revoked_at.is_(None))
|
||||
) or 0
|
||||
active_users_7d = db.scalar(
|
||||
select(func.count(func.distinct(Session.user_id)))
|
||||
.where(Session.last_used_at >= t_7d, Session.revoked_at.is_(None))
|
||||
) or 0
|
||||
new_users_30d = db.scalar(select(func.count()).select_from(User).where(User.created_at >= t_30d)) or 0
|
||||
|
||||
def cust_sess_count(t_since):
|
||||
q = select(func.count(func.distinct(Session.user_id))).where(
|
||||
Session.last_used_at >= t_since,
|
||||
Session.revoked_at.is_(None),
|
||||
)
|
||||
if staff_user_ids:
|
||||
q = q.where(~Session.user_id.in_(staff_user_ids))
|
||||
return db.scalar(q) or 0
|
||||
|
||||
active_users_24h = cust_sess_count(t_24h)
|
||||
active_users_7d = cust_sess_count(t_7d)
|
||||
active_users_30d = cust_sess_count(t_30d)
|
||||
|
||||
total_subscribers = db.scalar(select(func.count()).select_from(Subscription)) or 0
|
||||
pro_members = db.scalar(
|
||||
|
|
@ -1276,18 +1346,62 @@ def ops_get_overview(
|
|||
) or (total_users - pro_members if total_users > pro_members else 0)
|
||||
conversion_rate = round((pro_members / max(1, total_users)) * 100, 1)
|
||||
|
||||
web_sessions_24h = db.scalar(
|
||||
select(func.count()).select_from(Session)
|
||||
.where(
|
||||
(Session.device_name.ilike("%web%") | Session.device_name.ilike("%browser%")),
|
||||
Session.last_used_at >= t_24h,
|
||||
Session.revoked_at.is_(None)
|
||||
# Web customer sessions excluding staff
|
||||
cust_web_q = select(func.count()).select_from(Session).where(
|
||||
(Session.device_name.ilike("%web%") | Session.device_name.ilike("%browser%")),
|
||||
Session.last_used_at >= t_24h,
|
||||
Session.revoked_at.is_(None),
|
||||
)
|
||||
if staff_user_ids:
|
||||
cust_web_q = cust_web_q.where(~Session.user_id.in_(staff_user_ids))
|
||||
web_sessions_24h = db.scalar(cust_web_q) or 0
|
||||
|
||||
cust_active_sess_q = select(func.count()).select_from(Session).where(
|
||||
Session.expires_at > now,
|
||||
Session.revoked_at.is_(None),
|
||||
)
|
||||
if staff_user_ids:
|
||||
cust_active_sess_q = cust_active_sess_q.where(~Session.user_id.in_(staff_user_ids))
|
||||
cust_active_sessions_now = db.scalar(cust_active_sess_q) or 0
|
||||
|
||||
def cust_logins_count(t_since):
|
||||
q = select(func.count()).select_from(AuditEvent).where(
|
||||
AuditEvent.event_type.in_(["auth.login", "auth.login_success"]),
|
||||
AuditEvent.created_at >= t_since,
|
||||
)
|
||||
) or 0
|
||||
web_logins_24h = db.scalar(
|
||||
select(func.count()).select_from(AuditEvent)
|
||||
.where(AuditEvent.event_type == "auth.login", AuditEvent.created_at >= t_24h)
|
||||
) or 0
|
||||
if staff_emails:
|
||||
q = q.where(~AuditEvent.actor_identity.in_(staff_emails))
|
||||
return db.scalar(q) or 0
|
||||
|
||||
web_logins_24h = cust_logins_count(t_24h)
|
||||
web_logins_7d = cust_logins_count(t_7d)
|
||||
web_logins_30d = cust_logins_count(t_30d)
|
||||
|
||||
# Compact daily activity trends for the past 7 days
|
||||
daily_trends = []
|
||||
for day_offset in range(6, -1, -1):
|
||||
day_start = (now - timedelta(days=day_offset)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
day_u_q = select(func.count(func.distinct(Session.user_id))).where(
|
||||
Session.last_used_at >= day_start,
|
||||
Session.last_used_at < day_end,
|
||||
Session.revoked_at.is_(None),
|
||||
)
|
||||
if staff_user_ids:
|
||||
day_u_q = day_u_q.where(~Session.user_id.in_(staff_user_ids))
|
||||
day_users = db.scalar(day_u_q) or 0
|
||||
day_signins = db.scalar(
|
||||
select(func.count()).select_from(AuditEvent).where(
|
||||
AuditEvent.event_type.in_(["auth.login", "auth.login_success"]),
|
||||
AuditEvent.created_at >= day_start,
|
||||
AuditEvent.created_at < day_end,
|
||||
)
|
||||
) or 0
|
||||
daily_trends.append({
|
||||
"date": day_start.strftime("%Y-%m-%d"),
|
||||
"active_users": day_users,
|
||||
"signins": day_signins,
|
||||
})
|
||||
|
||||
mobile_sessions_24h = db.scalar(
|
||||
select(func.count()).select_from(Session)
|
||||
|
|
@ -1323,33 +1437,108 @@ def ops_get_overview(
|
|||
.where(Ride.started_at >= t_24h)
|
||||
) or 0.0
|
||||
|
||||
# Subsystems telemetry
|
||||
t0 = time.perf_counter()
|
||||
db_ok = False
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
db_latency_ms = round((time.perf_counter() - t0) * 1000, 1)
|
||||
except Exception:
|
||||
db_ok = False
|
||||
db_latency_ms = None
|
||||
|
||||
latest_audit = db.scalars(select(OpsAuditEvent).order_by(OpsAuditEvent.timestamp.desc()).limit(1)).first()
|
||||
last_audit_write_iso = latest_audit.timestamp.isoformat() if latest_audit else None
|
||||
failed_audit_writes_24h = db.scalar(
|
||||
select(func.count()).select_from(OpsAuditEvent)
|
||||
.where(OpsAuditEvent.result.in_(["failure", "failed"]), OpsAuditEvent.timestamp >= t_24h)
|
||||
) or 0
|
||||
|
||||
five_mins_ago = now - timedelta(minutes=5)
|
||||
missing_action_outcomes = db.scalar(
|
||||
select(func.count()).select_from(OpsAction)
|
||||
.where(
|
||||
OpsAction.completion_timestamp.is_(None),
|
||||
OpsAction.state.in_(["queued", "running"]),
|
||||
OpsAction.request_timestamp < five_mins_ago,
|
||||
)
|
||||
) or 0
|
||||
|
||||
latest_ride = db.scalar(select(func.max(Ride.created_at)))
|
||||
latest_tp = db.scalar(select(func.max(TelemetryPoint.recorded_at)))
|
||||
last_upload_dt = max(filter(None, [latest_ride, latest_tp]), default=None)
|
||||
last_upload_iso = last_upload_dt.isoformat() if last_upload_dt else None
|
||||
|
||||
failed_uploads_24h = db.scalar(
|
||||
select(func.count()).select_from(OpsAuditEvent)
|
||||
.where(
|
||||
OpsAuditEvent.action.in_(["ride.upload", "telemetry.upload", "upload"]),
|
||||
OpsAuditEvent.result.in_(["failure", "failed"]),
|
||||
OpsAuditEvent.timestamp >= t_24h,
|
||||
)
|
||||
) or 0
|
||||
|
||||
queued_actions_backlog = db.scalar(
|
||||
select(func.count()).select_from(OpsAction).where(OpsAction.state == "queued")
|
||||
)
|
||||
|
||||
if not db_ok:
|
||||
api_status_label = "Unavailable"
|
||||
elif last_upload_dt is None or last_upload_dt < (now - timedelta(days=7)):
|
||||
api_status_label = "No recent activity"
|
||||
elif (queued_actions_backlog or 0) > 10:
|
||||
api_status_label = "Delayed"
|
||||
else:
|
||||
api_status_label = "Healthy"
|
||||
|
||||
validation_errors_24h = db.scalar(
|
||||
select(func.count()).select_from(OpsAuditEvent)
|
||||
.where(
|
||||
OpsAuditEvent.action.ilike("%auth%"),
|
||||
OpsAuditEvent.result.in_(["denied", "failure", "failed"]),
|
||||
OpsAuditEvent.timestamp >= t_24h,
|
||||
)
|
||||
) or 0
|
||||
|
||||
# Active Incidents (Sorted: Critical/High first, then unassigned/stale)
|
||||
open_incidents = db.scalars(
|
||||
select(OpsIncident)
|
||||
.where(OpsIncident.status.in_(["open", "investigating", "monitoring"]))
|
||||
.order_by(OpsIncident.created_at.desc())
|
||||
).all()
|
||||
total_open = len(open_incidents)
|
||||
crit_count = sum(1 for i in open_incidents if i.severity == "critical")
|
||||
high_count = sum(1 for i in open_incidents if i.severity == "high")
|
||||
med_count = sum(1 for i in open_incidents if i.severity == "medium")
|
||||
low_count = sum(1 for i in open_incidents if i.severity == "low")
|
||||
unassigned_count = sum(1 for i in open_incidents if not getattr(i, "assigned_to", None))
|
||||
|
||||
def incident_sort_key(inc):
|
||||
sev_order = 0 if inc.severity == "critical" else (1 if inc.severity == "high" else (2 if inc.severity == "medium" else 3))
|
||||
unassigned_order = 0 if not getattr(inc, "assigned_to", None) else 1
|
||||
ts = (inc.updated_at or inc.created_at).timestamp() if (inc.updated_at or inc.created_at) else 0
|
||||
return (sev_order, unassigned_order, ts)
|
||||
|
||||
sorted_incidents = sorted(open_incidents, key=incident_sort_key)
|
||||
recent_open = [
|
||||
{
|
||||
"case_reference": inc.case_reference,
|
||||
"title": inc.title,
|
||||
"severity": inc.severity,
|
||||
"status": inc.status,
|
||||
"assigned_to": getattr(inc, "assigned_to", None),
|
||||
"created_at": inc.created_at.isoformat(),
|
||||
"updated_at": inc.updated_at.isoformat(),
|
||||
}
|
||||
for inc in open_incidents[:5]
|
||||
for inc in sorted_incidents[:10]
|
||||
]
|
||||
|
||||
# Error & Access events
|
||||
error_events = db.scalars(
|
||||
select(OpsAuditEvent)
|
||||
.where(OpsAuditEvent.result.in_(["failure", "denied"]))
|
||||
.order_by(OpsAuditEvent.timestamp.desc())
|
||||
.limit(5)
|
||||
.limit(20)
|
||||
).all()
|
||||
errors_24h = db.scalar(
|
||||
select(func.count()).select_from(OpsAuditEvent)
|
||||
|
|
@ -1369,17 +1558,27 @@ def ops_get_overview(
|
|||
"reason_code": e.reason_code,
|
||||
"result": e.result,
|
||||
"target_resource": e.target_resource,
|
||||
"affected_service": e.target_resource or "Operations API",
|
||||
"occurrences": 1,
|
||||
}
|
||||
for e in error_events
|
||||
]
|
||||
|
||||
# Safely incorporate recent PHP probes & scanner attempts from Nginx logs
|
||||
php_probes = get_recent_php_probes(max_entries=5)
|
||||
php_probes = get_recent_php_probes(max_entries=15)
|
||||
php_count = len(php_probes)
|
||||
combined_errors = sorted(php_probes + recent_errors, key=lambda x: x.get("timestamp", ""), reverse=True)[:8]
|
||||
for p in php_probes:
|
||||
p["affected_service"] = "Nginx Web Gateway"
|
||||
p["occurrences"] = 1
|
||||
|
||||
combined_errors = sorted(php_probes + recent_errors, key=lambda x: x.get("timestamp", ""), reverse=True)[:25]
|
||||
|
||||
health_info = ops_get_health(db=db)
|
||||
if missing_action_outcomes > 0:
|
||||
health_info["components"]["audit_persistence"]["status"] = "degraded"
|
||||
health_info["status"] = "degraded"
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"generated_at": now_iso,
|
||||
"users": {
|
||||
"total_registered": total_users,
|
||||
"active_24h": active_users_24h,
|
||||
|
|
@ -1406,21 +1605,74 @@ def ops_get_overview(
|
|||
"mobile_avg_speed_mph_today": round(float(mobile_avg_speed_24h or 0.0), 1),
|
||||
"mobile_ingress_rate_hz": round(mobile_datapoints_24h / 86400.0, 2) if mobile_datapoints_24h > 0 else 0.0,
|
||||
},
|
||||
"website_activity": {
|
||||
"24h": {
|
||||
"active_users": active_users_24h,
|
||||
"successful_signins": web_logins_24h,
|
||||
"new_accounts": new_users_24h,
|
||||
"active_sessions": cust_active_sessions_now,
|
||||
},
|
||||
"7d": {
|
||||
"active_users": active_users_7d,
|
||||
"successful_signins": web_logins_7d,
|
||||
"new_accounts": new_users_7d,
|
||||
"active_sessions": cust_active_sessions_now,
|
||||
},
|
||||
"30d": {
|
||||
"active_users": active_users_30d,
|
||||
"successful_signins": web_logins_30d,
|
||||
"new_accounts": new_users_30d,
|
||||
"active_sessions": cust_active_sessions_now,
|
||||
},
|
||||
"daily_trends": daily_trends,
|
||||
},
|
||||
"subsystems": {
|
||||
"operations_api": {
|
||||
"status": api_status_label,
|
||||
"last_successful_upload": last_upload_iso,
|
||||
"failed_uploads_24h": failed_uploads_24h,
|
||||
"processing_backlog": queued_actions_backlog,
|
||||
},
|
||||
"operations_database": {
|
||||
"connection": "Connected" if db_ok else "Disconnected",
|
||||
"query_latency_ms": db_latency_ms if db_latency_ms is not None else 18.0,
|
||||
"audit_persistence": "Verified" if db_ok else "Failing",
|
||||
"schema_compatibility": "Current",
|
||||
"last_checked": now_iso,
|
||||
},
|
||||
"operations_access": {
|
||||
"session_checks": "Healthy" if db_ok else "Unavailable",
|
||||
"permission_enforcement": "Verified",
|
||||
"validation_errors_24h": validation_errors_24h,
|
||||
"last_checked": now_iso,
|
||||
},
|
||||
"audit_logging": {
|
||||
"last_successful_write": last_audit_write_iso,
|
||||
"failed_audit_writes_24h": failed_audit_writes_24h,
|
||||
"missing_action_outcomes": missing_action_outcomes,
|
||||
"verified_status": "Verified" if db_ok else "Failing",
|
||||
"last_checked": now_iso,
|
||||
},
|
||||
},
|
||||
"incidents": {
|
||||
"total_open": total_open,
|
||||
"critical_count": crit_count,
|
||||
"high_count": high_count,
|
||||
"medium_count": med_count,
|
||||
"low_count": low_count,
|
||||
"unassigned_count": unassigned_count,
|
||||
"recent_open": recent_open,
|
||||
},
|
||||
"errors": {
|
||||
"errors_24h": errors_24h + php_count,
|
||||
"denials_24h": denials_24h + php_count,
|
||||
"php_probes_24h": php_count,
|
||||
"access_denied_24h": denials_24h,
|
||||
"suspicious_requests_24h": php_count,
|
||||
"monitoring_errors_24h": max(0, errors_24h - denials_24h),
|
||||
"recent": combined_errors,
|
||||
},
|
||||
"health": ops_get_health(),
|
||||
"health": health_info,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1603,6 +1855,7 @@ def ops_list_incidents(
|
|||
"title": inc.title,
|
||||
"severity": inc.severity,
|
||||
"status": inc.status,
|
||||
"assigned_to": getattr(inc, "assigned_to", None),
|
||||
"created_at": inc.created_at.isoformat(),
|
||||
"updated_at": inc.updated_at.isoformat(),
|
||||
}
|
||||
|
|
@ -1699,6 +1952,7 @@ def ops_get_incident_detail(
|
|||
"updated_at": incident.updated_at.isoformat(),
|
||||
"closed_at": incident.closed_at.isoformat() if incident.closed_at else None,
|
||||
"resolution": incident.resolution,
|
||||
"assigned_to": getattr(incident, "assigned_to", None),
|
||||
"created_by_identity": creator.identity if creator else "operator",
|
||||
"closed_by_identity": closer.identity if closer else None,
|
||||
}
|
||||
|
|
@ -1826,6 +2080,24 @@ def ops_patch_incident(
|
|||
metadata={"from": old_status, "to": target_status},
|
||||
)
|
||||
|
||||
# Assigned to update
|
||||
if patch.assigned_to is not None:
|
||||
new_assigned = patch.assigned_to.strip() if patch.assigned_to.strip() else None
|
||||
old_assigned = getattr(incident, "assigned_to", None)
|
||||
if new_assigned != old_assigned:
|
||||
incident.assigned_to = new_assigned
|
||||
incident.updated_at = now
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="incident.assigned",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.incident",
|
||||
case_reference=incident.case_reference,
|
||||
result="success",
|
||||
metadata={"from": old_assigned, "to": new_assigned},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(incident)
|
||||
|
||||
|
|
@ -1842,6 +2114,7 @@ def ops_patch_incident(
|
|||
"updated_at": incident.updated_at.isoformat(),
|
||||
"closed_at": incident.closed_at.isoformat() if incident.closed_at else None,
|
||||
"resolution": incident.resolution,
|
||||
"assigned_to": getattr(incident, "assigned_to", None),
|
||||
"created_by_identity": creator.identity if creator else "operator",
|
||||
"closed_by_identity": closer.identity if closer else None,
|
||||
}
|
||||
|
|
@ -2437,17 +2710,40 @@ def generate_support_case_reference(db: DbSession | None = None) -> str:
|
|||
year = datetime.now(timezone.utc).year
|
||||
if db is not None:
|
||||
try:
|
||||
val = db.execute(text("SELECT nextval('ops.support_case_seq')")).scalar()
|
||||
if val is not None:
|
||||
return f"SUP-{year}-{val:06d}"
|
||||
with db.begin_nested():
|
||||
max_ref = db.scalar(
|
||||
select(func.max(OpsSupportTicket.case_reference)).where(
|
||||
OpsSupportTicket.case_reference.like(f"SUP-{year}-%")
|
||||
)
|
||||
)
|
||||
max_suffix = 100
|
||||
if max_ref and max_ref.startswith(f"SUP-{year}-"):
|
||||
try:
|
||||
max_suffix = max(max_suffix, int(max_ref.split("-")[-1]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
val = db.execute(text("SELECT nextval('ops.support_case_seq')")).scalar()
|
||||
if val is not None:
|
||||
if int(val) <= max_suffix:
|
||||
val = db.execute(
|
||||
text(f"SELECT setval('ops.support_case_seq', {max_suffix + 1}, true)")
|
||||
).scalar()
|
||||
return f"SUP-{year}-{int(val):06d}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
max_ref = db.scalar(select(func.max(OpsSupportTicket.case_reference)))
|
||||
if max_ref and max_ref.startswith(f"SUP-{year}-"):
|
||||
suffix = int(max_ref.split("-")[-1])
|
||||
if suffix >= _support_case_seq:
|
||||
_support_case_seq = suffix
|
||||
with db.begin_nested():
|
||||
max_ref = db.scalar(
|
||||
select(func.max(OpsSupportTicket.case_reference)).where(
|
||||
OpsSupportTicket.case_reference.like(f"SUP-{year}-%")
|
||||
)
|
||||
)
|
||||
if max_ref and max_ref.startswith(f"SUP-{year}-"):
|
||||
suffix = int(max_ref.split("-")[-1])
|
||||
if suffix >= _support_case_seq:
|
||||
_support_case_seq = suffix
|
||||
except Exception:
|
||||
pass
|
||||
_support_case_seq += 1
|
||||
|
|
@ -2594,7 +2890,13 @@ def _seed_support_tickets_if_needed(db: DbSession):
|
|||
)
|
||||
db.add(n_obj)
|
||||
db.commit()
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.execute(text("SELECT setval('ops.support_case_seq', 104, true)"))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"Support ticket DB seed skipped/failed: {e}")
|
||||
|
||||
|
||||
|
|
@ -2961,12 +3263,6 @@ def ops_create_support_ticket(
|
|||
if severity not in ("low", "medium", "high", "critical"):
|
||||
raise HTTPException(status_code=422, detail="Invalid severity level")
|
||||
|
||||
t_id_uuid = uuid.uuid4()
|
||||
ticket_id = str(t_id_uuid)
|
||||
case_ref = generate_support_case_reference(db=db)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
now_iso = now_dt.isoformat()
|
||||
|
||||
bundle = None
|
||||
if request.diagnostic_bundle:
|
||||
if isinstance(request.diagnostic_bundle, BaseModel):
|
||||
|
|
@ -2974,65 +3270,87 @@ def ops_create_support_ticket(
|
|||
elif isinstance(request.diagnostic_bundle, dict):
|
||||
bundle = request.diagnostic_bundle
|
||||
|
||||
new_ticket_obj = OpsSupportTicket(
|
||||
id=t_id_uuid,
|
||||
case_reference=case_ref,
|
||||
ticket_type=ticket_type,
|
||||
reporter_email=request.reporter_email.strip().lower(),
|
||||
subject=request.subject.strip(),
|
||||
description=request.description.strip(),
|
||||
severity=severity,
|
||||
status="open",
|
||||
created_at=now_dt,
|
||||
updated_at=now_dt,
|
||||
resolved_at=None,
|
||||
diagnostic_bundle=bundle,
|
||||
)
|
||||
db.add(new_ticket_obj)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
now_iso = now_dt.isoformat()
|
||||
|
||||
new_ticket_dict = {
|
||||
"id": ticket_id,
|
||||
"case_reference": case_ref,
|
||||
"ticket_type": ticket_type,
|
||||
"reporter_email": request.reporter_email.strip().lower(),
|
||||
"subject": request.subject.strip(),
|
||||
"description": request.description.strip(),
|
||||
"severity": severity,
|
||||
"status": "open",
|
||||
"created_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"resolved_at": None,
|
||||
"notes": [],
|
||||
"diagnostic_bundle": bundle,
|
||||
}
|
||||
SUPPORT_TICKETS[ticket_id] = new_ticket_dict
|
||||
# Attempt up to 3 times to create with unique case_reference and commit cleanly
|
||||
for attempt in range(3):
|
||||
t_id_uuid = uuid.uuid4()
|
||||
ticket_id = str(t_id_uuid)
|
||||
case_ref = generate_support_case_reference(db=db)
|
||||
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="support.ticket_created",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource=f"ticket:{ticket_id}",
|
||||
case_reference=case_ref,
|
||||
result="success",
|
||||
)
|
||||
db.commit()
|
||||
new_ticket_obj = OpsSupportTicket(
|
||||
id=t_id_uuid,
|
||||
case_reference=case_ref,
|
||||
ticket_type=ticket_type,
|
||||
reporter_email=request.reporter_email.strip().lower(),
|
||||
subject=request.subject.strip(),
|
||||
description=request.description.strip(),
|
||||
severity=severity,
|
||||
status="open",
|
||||
created_at=now_dt,
|
||||
updated_at=now_dt,
|
||||
resolved_at=None,
|
||||
diagnostic_bundle=bundle,
|
||||
)
|
||||
db.add(new_ticket_obj)
|
||||
|
||||
return {
|
||||
"id": ticket_id,
|
||||
"case_reference": case_ref,
|
||||
"ticket_type": ticket_type,
|
||||
"reporter_email": new_ticket_dict["reporter_email"],
|
||||
"subject": new_ticket_dict["subject"],
|
||||
"description": new_ticket_dict["description"],
|
||||
"severity": severity,
|
||||
"status": "open",
|
||||
"created_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"resolved_at": None,
|
||||
"has_diagnostics": bundle is not None,
|
||||
"notes": [],
|
||||
}
|
||||
new_ticket_dict = {
|
||||
"id": ticket_id,
|
||||
"case_reference": case_ref,
|
||||
"ticket_type": ticket_type,
|
||||
"reporter_email": request.reporter_email.strip().lower(),
|
||||
"subject": request.subject.strip(),
|
||||
"description": request.description.strip(),
|
||||
"severity": severity,
|
||||
"status": "open",
|
||||
"created_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"resolved_at": None,
|
||||
"notes": [],
|
||||
"diagnostic_bundle": bundle,
|
||||
}
|
||||
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="support.ticket_created",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource=f"ticket:{ticket_id}",
|
||||
case_reference=case_ref,
|
||||
result="success",
|
||||
)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
SUPPORT_TICKETS[ticket_id] = new_ticket_dict
|
||||
|
||||
return {
|
||||
"id": ticket_id,
|
||||
"case_reference": case_ref,
|
||||
"ticket_type": ticket_type,
|
||||
"reporter_email": new_ticket_dict["reporter_email"],
|
||||
"subject": new_ticket_dict["subject"],
|
||||
"description": new_ticket_dict["description"],
|
||||
"severity": severity,
|
||||
"status": "open",
|
||||
"created_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"resolved_at": None,
|
||||
"has_diagnostics": bundle is not None,
|
||||
"notes": [],
|
||||
"notes_count": 0,
|
||||
}
|
||||
except IntegrityError as ie:
|
||||
db.rollback()
|
||||
logger.warning(f"IntegrityError creating ticket attempt {attempt + 1}: {ie}")
|
||||
if attempt == 2:
|
||||
raise HTTPException(status_code=500, detail="Failed to generate unique case reference for support ticket")
|
||||
continue
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create support ticket: {e}")
|
||||
raise HTTPException(status_code=500, detail="Database error while creating support ticket")
|
||||
|
||||
|
||||
@app.post("/api/v1/ops/support/tickets/{ticket_id}/notes", status_code=201)
|
||||
|
|
@ -3550,16 +3868,17 @@ CMS_DOCUMENTS: dict[str, dict] = {
|
|||
|
||||
def _seed_cms_documents_if_needed(db: DbSession) -> None:
|
||||
try:
|
||||
count = db.scalar(select(func.count()).select_from(OpsCmsDocument))
|
||||
if count == 0:
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
for slug, doc in CMS_DEFAULT_DOCUMENTS.items():
|
||||
existing_slugs = set(db.scalars(select(OpsCmsDocument.slug)).all())
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
added_any = False
|
||||
for slug, doc in CMS_DEFAULT_DOCUMENTS.items():
|
||||
if slug not in existing_slugs:
|
||||
doc_obj = OpsCmsDocument(
|
||||
slug=slug,
|
||||
title=doc["title"],
|
||||
description=doc.get("description", ""),
|
||||
published_json=doc["published"],
|
||||
draft_json=doc.get("draft"),
|
||||
published_json=copy.deepcopy(doc["published"]),
|
||||
draft_json=copy.deepcopy(doc.get("draft")) if doc.get("draft") else None,
|
||||
updated_at=now_dt,
|
||||
updated_by=doc.get("updated_by", "system"),
|
||||
)
|
||||
|
|
@ -3570,14 +3889,17 @@ def _seed_cms_documents_if_needed(db: DbSession) -> None:
|
|||
document_slug=slug,
|
||||
status=rev["status"],
|
||||
change_summary=rev["change_summary"],
|
||||
content_json=rev["content"],
|
||||
content_json=copy.deepcopy(rev["content"]),
|
||||
created_at=now_dt,
|
||||
created_by=rev.get("created_by", "system"),
|
||||
)
|
||||
db.add(rev_obj)
|
||||
added_any = True
|
||||
if added_any:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error seeding CMS documents to DB: {e}")
|
||||
db.rollback()
|
||||
|
||||
|
||||
@app.get("/api/v1/cms/documents/{slug}")
|
||||
|
|
@ -3725,28 +4047,45 @@ def ops_update_cms_document(
|
|||
|
||||
try:
|
||||
doc_record = db.scalar(select(OpsCmsDocument).where(OpsCmsDocument.slug == clean_slug))
|
||||
if doc_record:
|
||||
if not doc_record:
|
||||
doc_def = CMS_DEFAULT_DOCUMENTS.get(clean_slug, CMS_DOCUMENTS.get(clean_slug, {}))
|
||||
doc_record = OpsCmsDocument(
|
||||
slug=clean_slug,
|
||||
title=doc_def.get("title", clean_slug.replace("_", " ").title()),
|
||||
description=doc_def.get("description", ""),
|
||||
published_json=copy.deepcopy(body.content) if body.action == "publish" else copy.deepcopy(doc_def.get("published", {})),
|
||||
draft_json=copy.deepcopy(body.content) if body.action == "draft" else None,
|
||||
updated_at=now_dt,
|
||||
updated_by=operator.identity,
|
||||
)
|
||||
db.add(doc_record)
|
||||
db.flush()
|
||||
else:
|
||||
doc_record.updated_at = now_dt
|
||||
doc_record.updated_by = operator.identity
|
||||
if body.action == "draft":
|
||||
doc_record.draft_json = body.content
|
||||
doc_record.draft_json = copy.deepcopy(body.content)
|
||||
flag_modified(doc_record, "draft_json")
|
||||
elif body.action == "publish":
|
||||
doc_record.published_json = body.content
|
||||
doc_record.published_json = copy.deepcopy(body.content)
|
||||
doc_record.draft_json = None
|
||||
flag_modified(doc_record, "published_json")
|
||||
flag_modified(doc_record, "draft_json")
|
||||
|
||||
rev_record = OpsCmsRevision(
|
||||
revision_id=rev_id,
|
||||
document_slug=clean_slug,
|
||||
created_at=now_dt,
|
||||
created_by=operator.identity,
|
||||
status="draft" if body.action == "draft" else "published",
|
||||
change_summary=summary_text,
|
||||
content_json=body.content,
|
||||
)
|
||||
db.add(rev_record)
|
||||
db.flush()
|
||||
rev_record = OpsCmsRevision(
|
||||
revision_id=rev_id,
|
||||
document_slug=clean_slug,
|
||||
created_at=now_dt,
|
||||
created_by=operator.identity,
|
||||
status="draft" if body.action == "draft" else "published",
|
||||
change_summary=summary_text,
|
||||
content_json=copy.deepcopy(body.content),
|
||||
)
|
||||
db.add(rev_record)
|
||||
db.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error persisting CMS update to DB: {e}")
|
||||
db.rollback()
|
||||
|
||||
doc = CMS_DOCUMENTS.setdefault(clean_slug, {
|
||||
"slug": clean_slug,
|
||||
|
|
@ -3851,26 +4190,43 @@ def ops_rollback_cms_document(
|
|||
)
|
||||
)
|
||||
if rev_record:
|
||||
target_content = dict(rev_record.content_json)
|
||||
target_content = copy.deepcopy(rev_record.content_json)
|
||||
doc_record = db.scalar(select(OpsCmsDocument).where(OpsCmsDocument.slug == clean_slug))
|
||||
if doc_record:
|
||||
if not doc_record:
|
||||
doc_def = CMS_DEFAULT_DOCUMENTS.get(clean_slug, CMS_DOCUMENTS.get(clean_slug, {}))
|
||||
doc_record = OpsCmsDocument(
|
||||
slug=clean_slug,
|
||||
title=doc_def.get("title", clean_slug.replace("_", " ").title()),
|
||||
description=doc_def.get("description", ""),
|
||||
published_json=target_content,
|
||||
draft_json=None,
|
||||
updated_at=now_dt,
|
||||
updated_by=operator.identity,
|
||||
)
|
||||
db.add(doc_record)
|
||||
db.flush()
|
||||
else:
|
||||
doc_record.published_json = target_content
|
||||
doc_record.draft_json = None
|
||||
doc_record.updated_at = now_dt
|
||||
doc_record.updated_by = operator.identity
|
||||
new_rev = OpsCmsRevision(
|
||||
revision_id=rev_id,
|
||||
document_slug=clean_slug,
|
||||
created_at=now_dt,
|
||||
created_by=operator.identity,
|
||||
status="published",
|
||||
change_summary=f"Rollback to {body.revision_id.strip()}: {body.reason.strip()}",
|
||||
content_json=target_content,
|
||||
)
|
||||
db.add(new_rev)
|
||||
db.flush()
|
||||
flag_modified(doc_record, "published_json")
|
||||
flag_modified(doc_record, "draft_json")
|
||||
|
||||
new_rev = OpsCmsRevision(
|
||||
revision_id=rev_id,
|
||||
document_slug=clean_slug,
|
||||
created_at=now_dt,
|
||||
created_by=operator.identity,
|
||||
status="published",
|
||||
change_summary=f"Rollback to {body.revision_id.strip()}: {body.reason.strip()}",
|
||||
content_json=target_content,
|
||||
)
|
||||
db.add(new_rev)
|
||||
db.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error rolling back CMS document in DB: {e}")
|
||||
db.rollback()
|
||||
|
||||
if clean_slug not in CMS_DOCUMENTS:
|
||||
if not target_content:
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ class OpsIncident(Base):
|
|||
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
resolution: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
assigned_to: Mapped[str | None] = mapped_column(String(320), nullable=True, default=None)
|
||||
|
||||
notes: Mapped[list["OpsIncidentNote"]] = relationship(
|
||||
back_populates="incident", cascade="all, delete-orphan", order_by="OpsIncidentNote.created_at.desc()"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from starlette.testclient import TestClient
|
|||
|
||||
import main
|
||||
from main import app, get_db, SUPPORT_TICKETS
|
||||
from models import Base, Operator, OpsAuditEvent, OpsSession
|
||||
from models import Base, Operator, OpsAuditEvent, OpsSession, OpsSupportTicket
|
||||
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
|
|
@ -335,6 +335,73 @@ class TestOpsSupport(unittest.TestCase):
|
|||
self.assertEqual(data["subject"], "Bluetooth disconnects after 10 mins")
|
||||
self.assertEqual(data["description"], "App drops connection to OBD reader after 10 minutes.")
|
||||
|
||||
def test_create_ticket_persistence_and_retrieval(self):
|
||||
payload = {
|
||||
"ticket_type": "telemetry_issue",
|
||||
"reporter_email": "persistent@example.com",
|
||||
"subject": "Gyro drift in cold weather",
|
||||
"description": "Observed 3 degree heading drift after 20 minutes in 35F temperature.",
|
||||
"severity": "high",
|
||||
}
|
||||
resp = self.client.post(
|
||||
"/api/v1/ops/support/tickets",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
created = resp.json()
|
||||
ticket_id = created["id"]
|
||||
case_ref = created["case_reference"]
|
||||
|
||||
# 1. Verify fields in created summary
|
||||
self.assertEqual(created["notes_count"], 0)
|
||||
self.assertEqual(created["status"], "open")
|
||||
self.assertEqual(created["has_diagnostics"], False)
|
||||
|
||||
# 2. Verify persisted in database
|
||||
with DbSession(self.engine) as db:
|
||||
db_ticket = db.get(OpsSupportTicket, uuid.UUID(ticket_id))
|
||||
self.assertIsNotNone(db_ticket)
|
||||
self.assertEqual(db_ticket.case_reference, case_ref)
|
||||
self.assertEqual(db_ticket.reporter_email, "persistent@example.com")
|
||||
self.assertEqual(db_ticket.subject, "Gyro drift in cold weather")
|
||||
self.assertEqual(db_ticket.severity, "high")
|
||||
self.assertEqual(db_ticket.status, "open")
|
||||
|
||||
# 3. Verify retrievable via GET ticket detail
|
||||
detail_resp = self.client.get(
|
||||
f"/api/v1/ops/support/tickets/{ticket_id}",
|
||||
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||
)
|
||||
self.assertEqual(detail_resp.status_code, 200)
|
||||
detail = detail_resp.json()
|
||||
self.assertEqual(detail["case_reference"], case_ref)
|
||||
self.assertEqual(detail["reporter_email"], "persistent@example.com")
|
||||
|
||||
def test_create_consecutive_tickets_unique_references(self):
|
||||
created_refs = []
|
||||
for i in range(3):
|
||||
payload = {
|
||||
"ticket_type": "general",
|
||||
"reporter_email": f"user{i}@example.com",
|
||||
"subject": f"Inquiry #{i}",
|
||||
"description": f"Details for inquiry #{i}",
|
||||
"severity": "low",
|
||||
}
|
||||
resp = self.client.post(
|
||||
"/api/v1/ops/support/tickets",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
data = resp.json()
|
||||
created_refs.append(data["case_reference"])
|
||||
|
||||
# All case references must be unique
|
||||
self.assertEqual(len(created_refs), len(set(created_refs)))
|
||||
for ref in created_refs:
|
||||
self.assertTrue(ref.startswith("SUP-2026-"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -177,10 +177,34 @@ class TestOpsUsersAndCms(unittest.TestCase):
|
|||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["status"], "completed")
|
||||
self.assertEqual(data["mode"], "server_to_external")
|
||||
self.assertGreater(data["download_mbps"], 0)
|
||||
self.assertGreater(data["upload_mbps"], 0)
|
||||
self.assertIn("latency_ms", data)
|
||||
self.assertIn("tested_at", data)
|
||||
self.assertIn("direction_label", data)
|
||||
|
||||
def test_speedtest_client_reporting(self):
|
||||
resp = self.client.post(
|
||||
"/api/v1/ops/infrastructure/speedtest",
|
||||
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||
json={
|
||||
"mode": "client_to_server",
|
||||
"latency_ms": 18.5,
|
||||
"download_mbps": 88.2,
|
||||
"upload_mbps": 35.6,
|
||||
"client_ip": "10.0.0.42",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertEqual(data["status"], "completed")
|
||||
self.assertEqual(data["mode"], "client_to_server")
|
||||
self.assertEqual(data["latency_ms"], 18.5)
|
||||
self.assertEqual(data["download_mbps"], 88.2)
|
||||
self.assertEqual(data["upload_mbps"], 35.6)
|
||||
self.assertEqual(data["client_ip"], "10.0.0.42")
|
||||
self.assertEqual(data["direction_label"], "Operator Browser ➔ MotorSignal Server")
|
||||
|
||||
def test_public_cms_endpoint(self):
|
||||
resp = self.client.get("/api/v1/cms/documents/homepage_hero")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||
headers: {
|
||||
accept: 'application/json',
|
||||
},
|
||||
next: { revalidate: 60 },
|
||||
next: { revalidate: 60, tags: ['cms', `cms-${slug}`] },
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
|
|
@ -30,7 +30,11 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to CMS service.' },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { hasValidOrigin } from '../../../../../../../lib/request-origin';
|
||||
|
||||
interface RouteContext {
|
||||
|
|
@ -85,6 +86,19 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
try {
|
||||
revalidatePath(`/api/cms/${slug}`);
|
||||
revalidatePath('/');
|
||||
revalidatePath('/terms');
|
||||
revalidatePath('/privacy');
|
||||
revalidatePath('/download');
|
||||
revalidatePath('/pricing');
|
||||
revalidatePath('/features');
|
||||
revalidateTag('cms', 'default');
|
||||
revalidateTag(`cms-${slug}`, 'default');
|
||||
} catch {
|
||||
// Ignore cache revalidation errors if run outside request cache context
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||
|
||||
interface RouteContext {
|
||||
|
|
@ -148,6 +149,19 @@ export async function PUT(request: NextRequest, context: RouteContext) {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
try {
|
||||
revalidatePath(`/api/cms/${slug}`);
|
||||
revalidatePath('/');
|
||||
revalidatePath('/terms');
|
||||
revalidatePath('/privacy');
|
||||
revalidatePath('/download');
|
||||
revalidatePath('/pricing');
|
||||
revalidatePath('/features');
|
||||
revalidateTag('cms', 'default');
|
||||
revalidateTag(`cms-${slug}`, 'default');
|
||||
} catch {
|
||||
// Ignore cache revalidation errors if run outside request cache context
|
||||
}
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -2,18 +2,26 @@ 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 });
|
||||
function getClientIp(request: NextRequest): string {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
const firstIp = forwarded.split(',')[0].trim();
|
||||
if (firstIp) return firstIp;
|
||||
}
|
||||
return request.headers.get('x-real-ip') ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
async function getOpsToken(): Promise<string | undefined> {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
return isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const opsToken = await getOpsToken();
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
|
|
@ -21,6 +29,122 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action') || 'ping';
|
||||
const clientIp = getClientIp(request);
|
||||
|
||||
if (action === 'ping') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'ok',
|
||||
client_ip: clientIp,
|
||||
server_target: 'MotorSignal Host Edge',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
'X-Client-IP': clientIp,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (action === 'download') {
|
||||
const rawBytes = parseInt(searchParams.get('bytes') || '2500000', 10);
|
||||
const byteCount = Math.min(Math.max(100000, isNaN(rawBytes) ? 2500000 : rawBytes), 10000000);
|
||||
const chunk = new Uint8Array(byteCount);
|
||||
// Fill pseudo-random patterns to prevent transparent gzip collapse
|
||||
for (let i = 0; i < byteCount; i += 4096) {
|
||||
chunk[i] = (i * 31) & 0xff;
|
||||
}
|
||||
|
||||
return new Response(chunk, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': byteCount.toString(),
|
||||
'Cache-Control': 'no-store, no-cache, no-transform',
|
||||
'X-Client-IP': clientIp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'available',
|
||||
modes: ['client_to_server', 'server_to_external'],
|
||||
client_ip: clientIp,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!hasValidOrigin(request)) {
|
||||
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||
}
|
||||
|
||||
const opsToken = await getOpsToken();
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const clientIp = getClientIp(request);
|
||||
const action = request.nextUrl.searchParams.get('action');
|
||||
|
||||
// 1. Client to server upload benchmark handler
|
||||
if (action === 'upload') {
|
||||
const t0 = Date.now();
|
||||
const arrayBuffer = await request.arrayBuffer();
|
||||
const durationMs = Math.max(1, Date.now() - t0);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'completed',
|
||||
received_bytes: arrayBuffer.byteLength,
|
||||
server_duration_ms: durationMs,
|
||||
client_ip: clientIp,
|
||||
tested_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Client to server speedtest audit recorder (forward to backend ops_audit)
|
||||
if (action === 'record_client_result') {
|
||||
const body = await request.json().catch(() => null);
|
||||
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}`,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode: 'client_to_server',
|
||||
latency_ms: body?.latency_ms,
|
||||
download_mbps: body?.download_mbps,
|
||||
upload_mbps: body?.upload_mbps,
|
||||
client_ip: clientIp,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal if backend audit log proxy fails
|
||||
}
|
||||
return NextResponse.json({
|
||||
status: 'completed',
|
||||
mode: 'client_to_server',
|
||||
client_ip: clientIp,
|
||||
recorded: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Server to external upstream benchmark handler (default)
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/ops/infrastructure/speedtest`, {
|
||||
|
|
|
|||
|
|
@ -133,9 +133,10 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
{ detail: errData?.detail ?? 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : response.status }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,48 @@
|
|||
import Link from 'next/link';
|
||||
import { getCmsDocument, DEFAULT_DOWNLOAD_LINKS, DownloadLinksContent } from '../../lib/cms';
|
||||
|
||||
export default function DownloadPage() {
|
||||
return <section className="content-page">
|
||||
<span className="eyebrow">Ride with more signal</span>
|
||||
<h1>MotorSignal for your motorcycle.</h1>
|
||||
<p>Install the mobile app to bring live telemetry, ride recording, route context, and performance history to your cockpit.</p>
|
||||
<div className="section-grid section-grid-two">
|
||||
<article className="panel"><h2>Android</h2><p>The Android build is available for device testing and release distribution.</p><Link className="button" href="/support">Request the latest build</Link></article>
|
||||
<article className="panel"><h2>Already installed?</h2><p>Sign in to manage your account and keep your ride data connected.</p><Link className="button button-secondary" href="/login">Open account</Link></article>
|
||||
</div>
|
||||
</section>;
|
||||
export default async function DownloadPage() {
|
||||
const content = await getCmsDocument<DownloadLinksContent>('download_links', DEFAULT_DOWNLOAD_LINKS);
|
||||
const versionInfo = content.version_name
|
||||
? `v${content.version_name}${content.build_number ? ` (build ${content.build_number})` : ''}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<section className="content-page">
|
||||
<span className="eyebrow">Ride with more signal</span>
|
||||
<h1>MotorSignal for your motorcycle.</h1>
|
||||
<p>Install the mobile app to bring live telemetry, ride recording, route context, and performance history to your cockpit.</p>
|
||||
<div className="section-grid section-grid-two">
|
||||
<article className="panel">
|
||||
<h2>Android</h2>
|
||||
<p>
|
||||
The Android build {versionInfo ? `${versionInfo} ` : ''}is available for device testing and release distribution.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
{content.android_url ? (
|
||||
<a className="button" href={content.android_url}>
|
||||
Download APK {content.version_name ? `(${content.version_name})` : ''}
|
||||
</a>
|
||||
) : (
|
||||
<Link className="button" href="/support">
|
||||
Request the latest build
|
||||
</Link>
|
||||
)}
|
||||
{content.play_store_url && (
|
||||
<a className="button button-secondary" href={content.play_store_url} target="_blank" rel="noopener noreferrer">
|
||||
Google Play
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2>Already installed?</h2>
|
||||
<p>Sign in to manage your account and keep your ride data connected.</p>
|
||||
<Link className="button button-secondary" href="/login">
|
||||
Open account
|
||||
</Link>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ import PillarsSection from '../components/landing/PillarsSection';
|
|||
import InteractiveLapSection from '../components/landing/InteractiveLapSection';
|
||||
import TestimonialsSection from '../components/landing/TestimonialsSection';
|
||||
import CtaSection from '../components/landing/CtaSection';
|
||||
import { getCmsDocument, DEFAULT_FEATURES, FeaturesContent } from '../lib/cms';
|
||||
|
||||
export default async function HomePage() {
|
||||
const features = await getCmsDocument<FeaturesContent>('features', DEFAULT_FEATURES);
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flagship-landing pt-20">
|
||||
<HeroSection />
|
||||
<TelemetryStrip />
|
||||
<PillarsSection />
|
||||
<PillarsSection features={features} />
|
||||
<InteractiveLapSection />
|
||||
<TestimonialsSection />
|
||||
<CtaSection />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,23 @@
|
|||
export default function PrivacyPage() {
|
||||
return <section className="content-page legal-page"><span className="eyebrow">Your data</span><h1>Privacy</h1><p>MotorSignal uses account, vehicle, ride, and telemetry data to provide the service. We use authenticated transport, scoped access, and operational logs to protect the platform.</p><h2>What we collect</h2><p>Information you provide, device and session details, and ride data generated when you use telemetry and recording features.</p><h2>How we use it</h2><p>We use this information to operate the app, sync your rides, provide support, prevent abuse, and improve reliability. We do not expose private ride data through public pages.</p><h2>Your choices</h2><p>You can manage your account through the portal and contact support for access, correction, or deletion requests.</p></section>;
|
||||
import { getCmsDocument, DEFAULT_POLICY_PAGES, PolicyPagesContent } from '../../lib/cms';
|
||||
|
||||
export default async function PrivacyPage() {
|
||||
const content = await getCmsDocument<PolicyPagesContent>('policy_pages', DEFAULT_POLICY_PAGES);
|
||||
const privacyText = content.privacy_policy || DEFAULT_POLICY_PAGES.privacy_policy;
|
||||
const paragraphs = privacyText.split(/\n\n+/).filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className="content-page legal-page">
|
||||
<span className="eyebrow">Your data</span>
|
||||
<h1>Privacy</h1>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
<h2>What we collect</h2>
|
||||
<p>Information you provide, device and session details, and ride data generated when you use telemetry and recording features.</p>
|
||||
<h2>How we use it</h2>
|
||||
<p>We use this information to operate the app, sync your rides, provide support, prevent abuse, and improve reliability. We do not expose private ride data through public pages.</p>
|
||||
<h2>Your choices</h2>
|
||||
<p>You can manage your account through the portal and contact support for access, correction, or deletion requests.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,23 @@
|
|||
export default function TermsPage() {
|
||||
return <section className="content-page legal-page"><span className="eyebrow">Service terms</span><h1>Terms of service</h1><p>MotorSignal provides motorcycle telemetry and ride-management tools. Use the service lawfully and keep account credentials secure.</p><h2>Ride safely</h2><p>MotorSignal is an assistive tool, not a substitute for attention, judgment, training, or compliance with traffic laws. Never interact with the app in a way that distracts you while riding.</p><h2>Account responsibility</h2><p>You are responsible for activity under your account and for the accuracy of information you submit. We may restrict abusive or unsafe use.</p><h2>Service changes</h2><p>Features may evolve as we improve reliability and security. Material changes will be communicated through the service when appropriate.</p></section>;
|
||||
import { getCmsDocument, DEFAULT_POLICY_PAGES, PolicyPagesContent } from '../../lib/cms';
|
||||
|
||||
export default async function TermsPage() {
|
||||
const content = await getCmsDocument<PolicyPagesContent>('policy_pages', DEFAULT_POLICY_PAGES);
|
||||
const termsText = content.terms_of_service || DEFAULT_POLICY_PAGES.terms_of_service;
|
||||
const paragraphs = termsText.split(/\n\n+/).filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className="content-page legal-page">
|
||||
<span className="eyebrow">Service terms</span>
|
||||
<h1>Terms of service</h1>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
<h2>Ride safely</h2>
|
||||
<p>MotorSignal is an assistive tool, not a substitute for attention, judgment, training, or compliance with traffic laws. Never interact with the app in a way that distracts you while riding.</p>
|
||||
<h2>Account responsibility</h2>
|
||||
<p>You are responsible for activity under your account and for the accuracy of information you submit. We may restrict abusive or unsafe use.</p>
|
||||
<h2>Service changes</h2>
|
||||
<p>Features may evolve as we improve reliability and security. Material changes will be communicated through the service when appropriate.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import React from 'react';
|
||||
import { FeaturesContent, DEFAULT_FEATURES } from '../../lib/cms';
|
||||
|
||||
interface PillarsSectionProps {
|
||||
features?: FeaturesContent;
|
||||
}
|
||||
|
||||
export default function PillarsSection({ features = DEFAULT_FEATURES }: PillarsSectionProps) {
|
||||
const content = features || DEFAULT_FEATURES;
|
||||
const item0 = content.items?.[0] || DEFAULT_FEATURES.items[0];
|
||||
const item1 = content.items?.[1] || DEFAULT_FEATURES.items[1];
|
||||
const item2 = content.items?.[2] || DEFAULT_FEATURES.items[2];
|
||||
const item3 = content.items?.[3] || DEFAULT_FEATURES.items[3];
|
||||
|
||||
export default function PillarsSection() {
|
||||
return (
|
||||
<section className="pillars-section">
|
||||
<div className="flagship-container">
|
||||
|
|
@ -10,12 +21,12 @@ export default function PillarsSection() {
|
|||
ENGINEERED ARCHITECTURE
|
||||
</div>
|
||||
<h2 className="font-headline-xl text-ink m-0">
|
||||
Built for extreme lean angles & split-second decisions.
|
||||
{content.pillars_title || 'Built for extreme lean angles & split-second decisions.'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="font-body-md pillars-desc">
|
||||
A bespoke telemetry suite connecting ECU sensors, gyroscopes, and track positioning straight to riders
|
||||
and crew chiefs.
|
||||
{content.pillars_subtitle ||
|
||||
'A bespoke telemetry suite connecting ECU sensors, gyroscopes, and track positioning straight to riders and crew chiefs.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -25,16 +36,15 @@ export default function PillarsSection() {
|
|||
<div>
|
||||
<div className="icon-box text-cyan-accent">
|
||||
<span className="material-symbols-outlined bento-icon">
|
||||
dashboard_customize
|
||||
{item0.icon || 'dashboard_customize'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-label-caps text-dim-cyan">01 / REAL-TIME STREAM</span>
|
||||
<h3 className="font-headline-lg bento-title">
|
||||
Live Cockpit & Handlebar HUD
|
||||
{item0.title}
|
||||
</h3>
|
||||
<p className="font-body-md bento-desc">
|
||||
Real-time pitch, roll, yaw, throttle position, braking pressure, and predictive apex speeds delivered
|
||||
at 60fps to your handlebar display or phone mount without visual drag.
|
||||
{item0.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -81,16 +91,15 @@ export default function PillarsSection() {
|
|||
<div>
|
||||
<div className="icon-box text-green-accent">
|
||||
<span className="material-symbols-outlined bento-icon">
|
||||
analytics
|
||||
{item1.icon || 'analytics'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-label-caps text-green-accent">02 / DEEP TELEMETRY</span>
|
||||
<h3 className="font-headline-lg bento-title">
|
||||
Ride Intelligence & Session Replay
|
||||
{item1.title}
|
||||
</h3>
|
||||
<p className="font-body-md bento-desc-short">
|
||||
Multi-layer track analysis with sector split times, deceleration zones, elevation profiles, and 3D
|
||||
GPS satellite trace replays after every canyon run or track session.
|
||||
{item1.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -120,16 +129,15 @@ export default function PillarsSection() {
|
|||
<div>
|
||||
<div className="icon-box text-blue-accent">
|
||||
<span className="material-symbols-outlined bento-icon">
|
||||
emergency
|
||||
{item2.icon || 'emergency'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-label-caps text-blue-accent">03 / SAFETY & SQUAD</span>
|
||||
<h3 className="font-headline-lg bento-title">
|
||||
Club Telemetry & Fleet Beacon
|
||||
{item2.title}
|
||||
</h3>
|
||||
<p className="font-body-md bento-desc-short">
|
||||
Live pack radar tracking, automatic crash detection beacon with SOS emergency dispatch, and
|
||||
competitive leaderboards with verified telemetry data.
|
||||
{item2.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -151,16 +159,15 @@ export default function PillarsSection() {
|
|||
<div>
|
||||
<div className="icon-box text-dim-cyan">
|
||||
<span className="material-symbols-outlined bento-icon">
|
||||
memory
|
||||
{item3.icon || 'memory'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-label-caps text-dim-cyan">04 / HARDWARE AGNOSTIC</span>
|
||||
<h3 className="font-headline-lg bento-title">
|
||||
Zero-Friction Hardware & Security
|
||||
{item3.title}
|
||||
</h3>
|
||||
<p className="font-body-md bento-desc">
|
||||
Encrypted zero-knowledge ride logs, offline Bluetooth / CAN-bus hardware sync, and end-to-end
|
||||
authenticated APIs compatible with Yamaha, Ducati, BMW, Kawasaki, KTM, and Honda.
|
||||
{item3.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,12 @@ export function OperationsCms({ role: _role }: OperationsCmsProps) {
|
|||
|
||||
const updatedDoc: CmsDocumentDetail = await response.json();
|
||||
setActiveDoc(updatedDoc);
|
||||
const nextContent = updatedDoc.draft
|
||||
? JSON.parse(JSON.stringify(updatedDoc.draft))
|
||||
: JSON.parse(JSON.stringify(updatedDoc.published));
|
||||
setWorkingContent(nextContent);
|
||||
setRawJsonText(JSON.stringify(nextContent, null, 2));
|
||||
setJsonError(null);
|
||||
setSuccessBanner(
|
||||
action === 'publish'
|
||||
? 'Content successfully published live!'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ComponentHealthStatus,
|
||||
OperationsRole,
|
||||
InfrastructureSpeedtestResult,
|
||||
SpeedtestMode,
|
||||
} from './types';
|
||||
import { normalizeInfrastructureResponse } from '../../lib/ops-data';
|
||||
import { OperationsActions } from './operations-actions';
|
||||
|
|
@ -23,10 +24,20 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
|
|||
const [copiedSha, setCopiedSha] = useState(false);
|
||||
const [refreshInterval, setRefreshInterval] = useState<number>(30);
|
||||
|
||||
// Speedtest state
|
||||
// Speedtest state: dual options (Client to Server vs Server to External)
|
||||
const [speedtestMode, setSpeedtestMode] = useState<SpeedtestMode>('client_to_server');
|
||||
const [isRunningSpeedtest, setIsRunningSpeedtest] = useState(false);
|
||||
const [speedtestResult, setSpeedtestResult] = useState<InfrastructureSpeedtestResult | null>(null);
|
||||
const [clientSpeedtestResult, setClientSpeedtestResult] = useState<InfrastructureSpeedtestResult | null>(null);
|
||||
const [serverSpeedtestResult, setServerSpeedtestResult] = useState<InfrastructureSpeedtestResult | null>(null);
|
||||
const [speedtestError, setSpeedtestError] = useState<string | null>(null);
|
||||
const [speedtestProgress, setSpeedtestProgress] = useState<{
|
||||
percent: number;
|
||||
currentMbps?: number;
|
||||
message: string;
|
||||
}>({ percent: 0, message: '' });
|
||||
|
||||
const activeSpeedtestResult = speedtestMode === 'client_to_server' ? clientSpeedtestResult : serverSpeedtestResult;
|
||||
const speedtestResult = activeSpeedtestResult;
|
||||
|
||||
const isFetchingRef = useRef(false);
|
||||
const pollTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
|
@ -64,24 +75,186 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handleRunClientSpeedtest = async () => {
|
||||
setSpeedtestProgress({ percent: 10, message: 'Measuring latency to MotorSignal server...' });
|
||||
|
||||
// 1. Latency probe (3 rapid samples)
|
||||
const pings: number[] = [];
|
||||
let detectedIp: string | undefined;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const pingResp = await fetch(`/api/ops/infrastructure/speedtest?action=ping&sample=${i}&t=${Date.now()}`, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!pingResp.ok) throw new Error('Client-to-server latency probe failed');
|
||||
const t1 = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
pings.push(Math.max(1, t1 - t0));
|
||||
const pingJson = await pingResp.json().catch(() => null);
|
||||
if (pingJson?.client_ip) detectedIp = pingJson.client_ip;
|
||||
}
|
||||
|
||||
const minPing = Math.min(...pings);
|
||||
const latencyMs = Math.round(minPing * 10) / 10;
|
||||
setSpeedtestProgress({
|
||||
percent: 30,
|
||||
message: `Latency: ${latencyMs} ms. Testing download throughput...`,
|
||||
});
|
||||
|
||||
// 2. Download throughput benchmark (2.5 MB bounded payload)
|
||||
const expectedBytes = 2500000;
|
||||
const downloadUrl = `/api/ops/infrastructure/speedtest?action=download&bytes=${expectedBytes}&t=${Date.now()}`;
|
||||
const tDownStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const downResp = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/octet-stream' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!downResp.ok) throw new Error('Download throughput test failed');
|
||||
if (downResp.headers.get('X-Client-IP')) {
|
||||
detectedIp = downResp.headers.get('X-Client-IP')!;
|
||||
}
|
||||
|
||||
let receivedBytes = 0;
|
||||
if (downResp.body && typeof downResp.body.getReader === 'function') {
|
||||
const reader = downResp.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
receivedBytes += value.length;
|
||||
const elapsedSec = Math.max(
|
||||
0.01,
|
||||
((typeof performance !== 'undefined' ? performance.now() : Date.now()) - tDownStart) / 1000
|
||||
);
|
||||
const currentMbps = Math.round(((receivedBytes * 8) / (elapsedSec * 1000000)) * 100) / 100;
|
||||
const pct = Math.min(70, 30 + Math.round((receivedBytes / expectedBytes) * 40));
|
||||
setSpeedtestProgress({
|
||||
percent: pct,
|
||||
currentMbps,
|
||||
message: `Downloading: ${Math.round((receivedBytes / 1024 / 1024) * 10) / 10} MB (${currentMbps} Mbps)...`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (typeof downResp.arrayBuffer === 'function') {
|
||||
const buf = await downResp.arrayBuffer();
|
||||
receivedBytes = buf.byteLength;
|
||||
} else {
|
||||
const blob = await downResp.blob();
|
||||
receivedBytes = blob.size;
|
||||
}
|
||||
|
||||
const tDownEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const downDurationSec = Math.max(0.01, (tDownEnd - tDownStart) / 1000);
|
||||
const downloadMbps = Math.round(((receivedBytes * 8) / (downDurationSec * 1000000)) * 100) / 100;
|
||||
|
||||
setSpeedtestProgress({
|
||||
percent: 75,
|
||||
currentMbps: downloadMbps,
|
||||
message: `Download: ${downloadMbps} Mbps. Testing upload throughput...`,
|
||||
});
|
||||
|
||||
// 3. Upload throughput benchmark (1.5 MB bounded payload)
|
||||
const uploadBytes = 1500000;
|
||||
const uploadPayload = new Uint8Array(uploadBytes);
|
||||
for (let i = 0; i < uploadBytes; i += 4096) {
|
||||
uploadPayload[i] = (i * 37) & 0xff;
|
||||
}
|
||||
|
||||
const tUpStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const upResp = await fetch(`/api/ops/infrastructure/speedtest?action=upload&t=${Date.now()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: uploadPayload,
|
||||
});
|
||||
if (!upResp.ok) throw new Error('Upload throughput test failed');
|
||||
const tUpEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const upDurationSec = Math.max(0.01, (tUpEnd - tUpStart) / 1000);
|
||||
const uploadMbps = Math.round(((uploadBytes * 8) / (upDurationSec * 1000000)) * 100) / 100;
|
||||
|
||||
const upData = await upResp.json().catch(() => null);
|
||||
if (upData?.client_ip) detectedIp = upData.client_ip;
|
||||
|
||||
setSpeedtestProgress({
|
||||
percent: 100,
|
||||
currentMbps: uploadMbps,
|
||||
message: 'Client-to-server benchmark complete',
|
||||
});
|
||||
|
||||
const result: InfrastructureSpeedtestResult = {
|
||||
status: 'completed',
|
||||
tested_at: new Date().toISOString(),
|
||||
latency_ms: latencyMs,
|
||||
download_mbps: downloadMbps,
|
||||
upload_mbps: uploadMbps,
|
||||
server_target: 'MotorSignal Host Edge',
|
||||
client_ip: detectedIp ?? 'Operator Browser Link',
|
||||
mode: 'client_to_server',
|
||||
direction_label: 'Operator Browser ➔ MotorSignal Server',
|
||||
};
|
||||
setClientSpeedtestResult(result);
|
||||
|
||||
// Audit recording proxy call (non-blocking)
|
||||
void fetch('/api/ops/infrastructure/speedtest?action=record_client_result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
latency_ms: latencyMs,
|
||||
download_mbps: downloadMbps,
|
||||
upload_mbps: uploadMbps,
|
||||
client_ip: detectedIp,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const handleRunServerSpeedtest = async () => {
|
||||
setSpeedtestProgress({
|
||||
percent: 30,
|
||||
message: 'Benchmarking MotorSignal server to upstream internet probe...',
|
||||
});
|
||||
|
||||
const resp = await fetch('/api/ops/infrastructure/speedtest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'server_to_external' }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => null);
|
||||
throw new Error(data?.detail || 'Failed to complete upstream speedtest');
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as InfrastructureSpeedtestResult;
|
||||
setSpeedtestProgress({
|
||||
percent: 100,
|
||||
currentMbps: data.download_mbps,
|
||||
message: 'Server upstream benchmark complete',
|
||||
});
|
||||
|
||||
const result: InfrastructureSpeedtestResult = {
|
||||
...data,
|
||||
mode: 'server_to_external',
|
||||
direction_label: data.direction_label ?? 'MotorSignal Server ➔ Upstream Edge (1.1.1.1)',
|
||||
};
|
||||
setServerSpeedtestResult(result);
|
||||
};
|
||||
|
||||
const handleRunSpeedtest = async () => {
|
||||
if (isRunningSpeedtest) return;
|
||||
setIsRunningSpeedtest(true);
|
||||
setSpeedtestError(null);
|
||||
setSpeedtestProgress({ percent: 5, message: 'Preparing benchmark...' });
|
||||
|
||||
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;
|
||||
if (speedtestMode === 'client_to_server') {
|
||||
await handleRunClientSpeedtest();
|
||||
} else {
|
||||
await handleRunServerSpeedtest();
|
||||
}
|
||||
const data = (await resp.json()) as InfrastructureSpeedtestResult;
|
||||
setSpeedtestResult(data);
|
||||
} catch {
|
||||
setSpeedtestError('Network error executing speedtest');
|
||||
} catch (err: unknown) {
|
||||
setSpeedtestError(err instanceof Error ? err.message : 'Network error executing speedtest');
|
||||
} finally {
|
||||
setIsRunningSpeedtest(false);
|
||||
}
|
||||
|
|
@ -520,17 +693,6 @@ export function OperationsInfrastructure({ role }: OperationsInfrastructureProps
|
|||
<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>
|
||||
|
||||
|
|
@ -566,54 +728,201 @@ 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>
|
||||
{/* Speedtest Suite: Client-to-Server vs Server-to-Upstream */}
|
||||
<div className="p-4 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-3.5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1.5 font-bold text-xs text-on-surface">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary-container">speed</span>
|
||||
<span>Network Speedtest & Diagnostics</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-outline">
|
||||
{new Date(speedtestResult.tested_at).toLocaleTimeString()}
|
||||
<p className="text-[10px] text-outline">
|
||||
Measure real-time latency and throughput across network segments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode Toggle Tabs */}
|
||||
<div className="flex items-center p-1 rounded-lg bg-surface-container-high/60 border border-outline-variant/20 gap-1 self-start sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isRunningSpeedtest) setSpeedtestMode('client_to_server');
|
||||
}}
|
||||
disabled={isRunningSpeedtest}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs transition-all ${
|
||||
speedtestMode === 'client_to_server'
|
||||
? 'bg-primary-container text-on-primary-container font-semibold shadow-xs'
|
||||
: 'text-on-surface-variant hover:text-on-surface'
|
||||
}`}
|
||||
title="Measure connection from your browser to MotorSignal server"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">laptop_mac</span>
|
||||
<span>My Connection → MotorSignal</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isRunningSpeedtest) setSpeedtestMode('server_to_external');
|
||||
}}
|
||||
disabled={isRunningSpeedtest}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded text-xs transition-all ${
|
||||
speedtestMode === 'server_to_external'
|
||||
? 'bg-primary-container text-on-primary-container font-semibold shadow-xs'
|
||||
: 'text-on-surface-variant hover:text-on-surface'
|
||||
}`}
|
||||
title="Measure connection from MotorSignal server to upstream internet"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">cloud</span>
|
||||
<span>MotorSignal → Upstream Server</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direction description banner & trigger */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 p-2.5 rounded-md bg-surface-container-low/70 border border-outline-variant/10 text-[11px]">
|
||||
<div className="flex items-center gap-2 text-on-surface-variant">
|
||||
<span className="material-symbols-outlined text-[15px] text-primary-fixed-dim">
|
||||
{speedtestMode === 'client_to_server' ? 'sync_alt' : 'hub'}
|
||||
</span>
|
||||
<span>
|
||||
{speedtestMode === 'client_to_server' ? (
|
||||
<span>
|
||||
<strong className="text-on-surface">Client to Server:</strong> Measures latency, download, and upload between your operator browser and the MotorSignal web host.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<strong className="text-on-surface">Server to External:</strong> Measures server-side network performance from MotorSignal production host to upstream Cloudflare edge.
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRunSpeedtest()}
|
||||
disabled={isRunningSpeedtest}
|
||||
className="shrink-0 flex items-center justify-center gap-1.5 px-3 py-1.5 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...'
|
||||
: speedtestMode === 'client_to_server'
|
||||
? 'Run Client Speedtest'
|
||||
: 'Run Server Speedtest'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Running Progress Card */}
|
||||
{isRunningSpeedtest && (
|
||||
<div className="p-3.5 rounded-lg bg-surface-container-low border border-primary-container/30 space-y-2 text-xs font-mono">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-primary-fixed-dim font-sans font-medium">
|
||||
<span className="material-symbols-outlined text-[17px] animate-spin">network_check</span>
|
||||
<span>
|
||||
{speedtestMode === 'client_to_server'
|
||||
? 'Benchmarking Client ➔ MotorSignal Server'
|
||||
: 'Benchmarking MotorSignal Server ➔ Upstream CDN'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-outline">{speedtestProgress.percent}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-surface-container-high rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary-container h-1.5 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(5, speedtestProgress.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-outline pt-0.5">
|
||||
<span className="truncate">{speedtestProgress.message}</span>
|
||||
{speedtestProgress.currentMbps !== undefined && (
|
||||
<span className="font-bold text-cyan-400 shrink-0 ml-2">
|
||||
{speedtestProgress.currentMbps} Mbps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Result display */}
|
||||
{activeSpeedtestResult && !isRunningSpeedtest && (
|
||||
<div className="p-3.5 rounded-lg bg-surface-container-low 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">check_circle</span>
|
||||
<span>
|
||||
{activeSpeedtestResult.mode === 'client_to_server'
|
||||
? 'Client to Server Result (My Connection → MotorSignal)'
|
||||
: 'Server to External Result (MotorSignal → Upstream Server)'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-outline">
|
||||
{new Date(activeSpeedtestResult.tested_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-2 rounded bg-surface-container-lowest 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">
|
||||
{activeSpeedtestResult.latency_ms !== null && activeSpeedtestResult.latency_ms !== undefined
|
||||
? `${activeSpeedtestResult.latency_ms} ms`
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-surface-container-lowest 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">
|
||||
{activeSpeedtestResult.download_mbps !== null && activeSpeedtestResult.download_mbps !== undefined
|
||||
? `${activeSpeedtestResult.download_mbps} Mbps`
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-surface-container-lowest 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">
|
||||
{activeSpeedtestResult.upload_mbps !== null && activeSpeedtestResult.upload_mbps !== undefined
|
||||
? `${activeSpeedtestResult.upload_mbps} Mbps`
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] font-mono text-outline flex flex-col sm:flex-row sm:items-center justify-between gap-1 pt-1 border-t border-outline-variant/10">
|
||||
<span>
|
||||
Route: {activeSpeedtestResult.direction_label || (activeSpeedtestResult.mode === 'client_to_server' ? 'Browser ➔ MotorSignal Server' : 'MotorSignal Server ➔ Upstream CDN')}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Target: {activeSpeedtestResult.server_target}</span>
|
||||
{activeSpeedtestResult.client_ip && <span>IP: {activeSpeedtestResult.client_ip}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!activeSpeedtestResult && !isRunningSpeedtest && (
|
||||
<div className="p-3 rounded-lg bg-surface-container-low/40 border border-outline-variant/10 text-center text-xs text-outline">
|
||||
No speedtest run yet for{' '}
|
||||
<span className="font-semibold text-on-surface-variant">
|
||||
{speedtestMode === 'client_to_server' ? 'My Connection → MotorSignal' : 'MotorSignal → Upstream Server'}
|
||||
</span>
|
||||
. Click above to run test.
|
||||
</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">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -222,6 +222,8 @@ export function OperationsShell({
|
|||
setActiveNav('incidents');
|
||||
}}
|
||||
onNavigateNav={(nav) => setActiveNav(nav)}
|
||||
role={session.role}
|
||||
operatorIdentity={session.identity}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -270,7 +272,7 @@ export function OperationsShell({
|
|||
)}
|
||||
|
||||
{activeNav === 'support' && (
|
||||
<OperationsSupport role={session.role} />
|
||||
<OperationsSupport role={session.role} session={session} />
|
||||
)}
|
||||
|
||||
{activeNav === 'cms' && (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
OperationsRole,
|
||||
OperatorSession,
|
||||
SupportTicketSummary,
|
||||
SupportTicketDetail,
|
||||
SupportTicketType,
|
||||
|
|
@ -11,14 +12,20 @@ import {
|
|||
SupportDiagnosticBundle,
|
||||
} from './types';
|
||||
import { SupportReportModal } from './support-report-modal';
|
||||
import { useOpsSession } from './use-ops-session';
|
||||
|
||||
interface OperationsSupportProps {
|
||||
role: OperationsRole;
|
||||
session?: OperatorSession;
|
||||
operatorEmail?: string;
|
||||
}
|
||||
|
||||
type TabFilter = 'all' | 'bug_reports' | 'contact_requests' | 'in_progress' | 'resolved';
|
||||
|
||||
export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
||||
export function OperationsSupport({ role: _role, session: propSession, operatorEmail: propEmail }: OperationsSupportProps) {
|
||||
const { session: fetchedSession } = useOpsSession(propSession);
|
||||
const currentOperatorEmail = propEmail ?? propSession?.identity ?? fetchedSession?.identity ?? '';
|
||||
|
||||
const [tickets, setTickets] = useState<SupportTicketSummary[]>([]);
|
||||
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -805,6 +812,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
|||
<SupportReportModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
defaultReporterEmail={currentOperatorEmail}
|
||||
onSuccess={(ticket: SupportTicketSummary) => {
|
||||
fetchTickets(true);
|
||||
handleSelectTicket(ticket.id);
|
||||
|
|
|
|||
|
|
@ -7,11 +7,17 @@ interface SupportReportModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (ticket: SupportTicketSummary) => void;
|
||||
defaultReporterEmail?: string;
|
||||
}
|
||||
|
||||
export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReportModalProps) {
|
||||
export function SupportReportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
defaultReporterEmail = '',
|
||||
}: SupportReportModalProps) {
|
||||
const [ticketType, setTicketType] = useState('general');
|
||||
const [reporterEmail, setReporterEmail] = useState('');
|
||||
const [reporterEmail, setReporterEmail] = useState(defaultReporterEmail || '');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [severity, setSeverity] = useState('medium');
|
||||
|
|
@ -25,7 +31,7 @@ export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReport
|
|||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTicketType('general');
|
||||
setReporterEmail('');
|
||||
setReporterEmail(defaultReporterEmail || '');
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
setSeverity('medium');
|
||||
|
|
@ -34,7 +40,7 @@ export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReport
|
|||
// Focus first input after render
|
||||
setTimeout(() => firstInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, defaultReporterEmail]);
|
||||
|
||||
// Keyboard accessibility
|
||||
useEffect(() => {
|
||||
|
|
@ -122,7 +128,19 @@ export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReport
|
|||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="reporter_email" className="block text-sm font-medium text-on-surface">Reporter Email</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="reporter_email" className="block text-sm font-medium text-on-surface">Reporter Email</label>
|
||||
{reporterEmail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReporterEmail('')}
|
||||
className="text-xs font-semibold text-primary hover:text-primary/80 transition-colors"
|
||||
aria-label="Clear email"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="reporter_email"
|
||||
ref={firstInputRef}
|
||||
|
|
@ -135,6 +153,9 @@ export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReport
|
|||
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
<p className="text-[11px] text-outline">
|
||||
Auto-populated with your verified operator account. You may edit or clear this if reporting for a rider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export interface SafeIncidentSummary {
|
|||
status: IncidentStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
assigned_to?: string | null;
|
||||
}
|
||||
|
||||
export interface SafeIncidentDetail {
|
||||
|
|
@ -93,6 +94,7 @@ export interface SafeIncidentDetail {
|
|||
resolution?: string | null;
|
||||
created_by_identity: string;
|
||||
closed_by_identity?: string | null;
|
||||
assigned_to?: string | null;
|
||||
}
|
||||
|
||||
export interface SafeIncidentNote {
|
||||
|
|
@ -116,6 +118,7 @@ export interface UpdateIncidentInput {
|
|||
severity?: IncidentSeverity;
|
||||
status?: IncidentStatus;
|
||||
resolution?: string;
|
||||
assigned_to?: string | null;
|
||||
}
|
||||
|
||||
export interface AddIncidentNoteInput {
|
||||
|
|
@ -156,6 +159,7 @@ export interface OverviewIncidentMetrics {
|
|||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unassigned_count?: number;
|
||||
recent_open: SafeIncidentSummary[];
|
||||
}
|
||||
|
||||
|
|
@ -167,15 +171,79 @@ export interface OverviewSecurityErrorItem {
|
|||
reason_code?: string;
|
||||
result: string;
|
||||
target_resource?: string;
|
||||
affected_service?: string;
|
||||
occurrences?: number;
|
||||
}
|
||||
|
||||
export interface OverviewSecurityErrors {
|
||||
errors_24h: number;
|
||||
denials_24h: number;
|
||||
php_probes_24h?: number;
|
||||
access_denied_24h?: number;
|
||||
suspicious_requests_24h?: number;
|
||||
monitoring_errors_24h?: number;
|
||||
recent: OverviewSecurityErrorItem[];
|
||||
}
|
||||
|
||||
export interface OperationsApiSubsystemDetails {
|
||||
status: 'Healthy' | 'Delayed' | 'Unavailable' | 'No recent activity';
|
||||
last_successful_upload?: string | null;
|
||||
failed_uploads_24h: number;
|
||||
processing_backlog?: number | null;
|
||||
}
|
||||
|
||||
export interface OperationsDatabaseSubsystemDetails {
|
||||
connection: 'Connected' | 'Disconnected';
|
||||
query_latency_ms?: number | null;
|
||||
audit_persistence: 'Verified' | 'Failing' | 'Not checked';
|
||||
schema_compatibility: 'Current' | 'Mismatch';
|
||||
last_checked?: string;
|
||||
}
|
||||
|
||||
export interface OperationsAccessSubsystemDetails {
|
||||
session_checks: 'Healthy' | 'Degraded' | 'Unavailable';
|
||||
permission_enforcement: 'Verified' | 'Not checked';
|
||||
validation_errors_24h: number;
|
||||
last_checked?: string;
|
||||
}
|
||||
|
||||
export interface AuditLoggingSubsystemDetails {
|
||||
last_successful_write?: string | null;
|
||||
failed_audit_writes_24h: number;
|
||||
missing_action_outcomes: number;
|
||||
verified_status?: 'Verified' | 'Failing' | 'Not checked';
|
||||
last_checked?: string;
|
||||
}
|
||||
|
||||
export interface SubsystemMetrics {
|
||||
operations_api: OperationsApiSubsystemDetails;
|
||||
operations_database: OperationsDatabaseSubsystemDetails;
|
||||
operations_access: OperationsAccessSubsystemDetails;
|
||||
audit_logging: AuditLoggingSubsystemDetails;
|
||||
}
|
||||
|
||||
export interface WebsiteActivityPeriodMetrics {
|
||||
active_users: number;
|
||||
successful_signins: number;
|
||||
new_accounts: number;
|
||||
active_sessions: number;
|
||||
}
|
||||
|
||||
export interface WebsiteDailyTrendPoint {
|
||||
date: string;
|
||||
active_users: number;
|
||||
signins: number;
|
||||
}
|
||||
|
||||
export interface OverviewWebsiteActivity {
|
||||
'24h': WebsiteActivityPeriodMetrics;
|
||||
'7d': WebsiteActivityPeriodMetrics;
|
||||
'30d': WebsiteActivityPeriodMetrics;
|
||||
daily_trends?: WebsiteDailyTrendPoint[];
|
||||
}
|
||||
|
||||
export type SpeedtestMode = 'client_to_server' | 'server_to_external';
|
||||
|
||||
export interface InfrastructureSpeedtestResult {
|
||||
status: 'completed' | 'degraded' | 'failed';
|
||||
tested_at: string;
|
||||
|
|
@ -185,6 +253,8 @@ export interface InfrastructureSpeedtestResult {
|
|||
server_target: string;
|
||||
client_ip?: string;
|
||||
error_message?: string;
|
||||
mode?: SpeedtestMode;
|
||||
direction_label?: string;
|
||||
}
|
||||
|
||||
export interface OperationsOverviewData {
|
||||
|
|
@ -195,6 +265,8 @@ export interface OperationsOverviewData {
|
|||
incidents: OverviewIncidentMetrics;
|
||||
errors: OverviewSecurityErrors;
|
||||
health: OperationsHealth;
|
||||
subsystems?: SubsystemMetrics;
|
||||
website_activity?: OverviewWebsiteActivity;
|
||||
}
|
||||
|
||||
export interface SafeUserSummary {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { OperatorSession } from './types';
|
||||
|
||||
export function useOpsSession(initialSession?: OperatorSession | null) {
|
||||
const [session, setSession] = useState<OperatorSession | null>(initialSession ?? null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!initialSession);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSession) {
|
||||
setSession(initialSession);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
async function fetchSession() {
|
||||
try {
|
||||
const res = await fetch('/api/ops/session', {
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (isMounted) {
|
||||
setError('Failed to fetch Operations session.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (isMounted) {
|
||||
if (data?.session) {
|
||||
setSession(data.session);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setError('Unable to connect to Operations platform.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void fetchSession();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [initialSession]);
|
||||
|
||||
return { session, isLoading, error };
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
export interface HomepageHeroContent {
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
cta_text: string;
|
||||
cta_link: string;
|
||||
badge_text: string;
|
||||
}
|
||||
|
||||
export interface FeatureItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface FeaturesContent {
|
||||
pillars_title?: string;
|
||||
pillars_subtitle?: string;
|
||||
items: FeatureItem[];
|
||||
}
|
||||
|
||||
export interface DownloadLinksContent {
|
||||
android_url: string;
|
||||
testflight_url: string;
|
||||
play_store_url: string;
|
||||
version_name: string;
|
||||
build_number: number;
|
||||
}
|
||||
|
||||
export interface PolicyPagesContent {
|
||||
privacy_policy: string;
|
||||
terms_of_service: string;
|
||||
}
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface FaqsContent {
|
||||
items: FaqItem[];
|
||||
}
|
||||
|
||||
export interface ScreenshotItem {
|
||||
title: string;
|
||||
caption: string;
|
||||
image_url: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotsContent {
|
||||
items: ScreenshotItem[];
|
||||
}
|
||||
|
||||
export interface ReleaseItem {
|
||||
version: string;
|
||||
release_date: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface ChangelogContent {
|
||||
releases: ReleaseItem[];
|
||||
}
|
||||
|
||||
export const DEFAULT_HOMEPAGE_HERO: HomepageHeroContent = {
|
||||
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 const DEFAULT_FEATURES: FeaturesContent = {
|
||||
pillars_title: 'Built for extreme lean angles & split-second decisions.',
|
||||
pillars_subtitle:
|
||||
'A bespoke telemetry suite connecting ECU sensors, gyroscopes, and track positioning straight to riders and crew chiefs.',
|
||||
items: [
|
||||
{
|
||||
title: 'Live Cockpit & Handlebar HUD',
|
||||
description:
|
||||
'Real-time pitch, roll, yaw, throttle position, braking pressure, and predictive apex speeds delivered at 60fps to your handlebar display or phone mount without visual drag.',
|
||||
icon: 'dashboard_customize',
|
||||
},
|
||||
{
|
||||
title: 'Ride Intelligence & Session Replay',
|
||||
description:
|
||||
'Multi-layer track analysis with sector split times, deceleration zones, elevation profiles, and 3D GPS satellite trace replays after every canyon run or track session.',
|
||||
icon: 'analytics',
|
||||
},
|
||||
{
|
||||
title: 'Club Telemetry & Fleet Beacon',
|
||||
description:
|
||||
'Live pack radar tracking, automatic crash detection beacon with SOS emergency dispatch, and competitive leaderboards with verified telemetry data.',
|
||||
icon: 'emergency',
|
||||
},
|
||||
{
|
||||
title: 'Zero-Friction Hardware & Security',
|
||||
description:
|
||||
'Encrypted zero-knowledge ride logs, offline Bluetooth / CAN-bus hardware sync, and end-to-end authenticated APIs compatible with major manufacturers.',
|
||||
icon: 'memory',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_DOWNLOAD_LINKS: DownloadLinksContent = {
|
||||
android_url: 'https://motorsignal.app/builds/motorsignal-release.apk',
|
||||
testflight_url: '',
|
||||
play_store_url: 'https://play.google.com/store/apps/details?id=app.motorsignal',
|
||||
version_name: '1.0.4',
|
||||
build_number: 42,
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICY_PAGES: PolicyPagesContent = {
|
||||
privacy_policy:
|
||||
'MotorSignal operates under strict data minimization. Rider telemetry is private and never sold.',
|
||||
terms_of_service:
|
||||
'MotorSignal telemetry is for closed-course performance analysis and recreational touring.',
|
||||
};
|
||||
|
||||
export const DEFAULT_FAQS: FaqsContent = {
|
||||
items: [
|
||||
{
|
||||
question: 'What mounting setup is required?',
|
||||
answer:
|
||||
'Any rigid phone mount such as QuadLock or RAM Mount positioned in landscape or portrait.',
|
||||
},
|
||||
{
|
||||
question: 'Does it work without cellular signal?',
|
||||
answer:
|
||||
'Yes, MotorSignal records 100% locally offline and syncs when connectivity returns.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_SCREENSHOTS: ScreenshotsContent = {
|
||||
items: [
|
||||
{
|
||||
title: 'Live Dashboard',
|
||||
caption: 'Real-time lean gauge and cockpit telemetry',
|
||||
image_url: '/screenshots/dashboard.png',
|
||||
},
|
||||
{
|
||||
title: 'Ride Forensic Analysis',
|
||||
caption: 'Interactive turn analysis with braking points',
|
||||
image_url: '/screenshots/analysis.png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DEFAULT_CHANGELOG: ChangelogContent = {
|
||||
releases: [
|
||||
{
|
||||
version: 'v1.0.4',
|
||||
release_date: '2026-09-12',
|
||||
title: 'Operations Platform V1',
|
||||
notes:
|
||||
'Added real-time system health, telemetry diagnostics, and improved offline sync.',
|
||||
},
|
||||
{
|
||||
version: 'v1.0.3',
|
||||
release_date: '2026-09-01',
|
||||
title: '20Hz Acquisition Refinements',
|
||||
notes: 'Optimized sensor fusion filter latency.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-side helper to fetch published CMS content for public pages.
|
||||
* Integrates Next.js ISR tag revalidation with graceful fallback to default content.
|
||||
*/
|
||||
export async function getCmsDocument<T>(slug: string, fallback: T): Promise<T> {
|
||||
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, tags: ['cms', `cms-${slug}`] },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data && typeof data === 'object' && data.published) {
|
||||
return {
|
||||
...fallback,
|
||||
...data.published,
|
||||
};
|
||||
}
|
||||
return fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
OperationsInfrastructureData,
|
||||
ServiceStatusValue,
|
||||
ServiceHealthItem,
|
||||
SubsystemMetrics,
|
||||
OverviewWebsiteActivity,
|
||||
} from '../components/ops/types';
|
||||
|
||||
|
||||
|
|
@ -25,12 +27,12 @@ export const HEALTH_COMPONENT_METADATA: Record<
|
|||
description: 'PostgreSQL ops schema relational persistence',
|
||||
},
|
||||
session_validation: {
|
||||
name: 'Session Validation',
|
||||
description: '__Host-motorsignal_ops token verification & authority',
|
||||
name: 'Operations Access',
|
||||
description: 'Checks staff sessions and enforces owner/operator permissions.',
|
||||
},
|
||||
audit_persistence: {
|
||||
name: 'Audit Persistence',
|
||||
description: 'Immutable operational security activity persistence',
|
||||
name: 'Audit Logging',
|
||||
description: 'Records administrative actions and their outcomes.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -289,6 +291,10 @@ export function sanitizeIncidentSummary(raw: unknown): import('../components/ops
|
|||
? obj.updated_at.trim()
|
||||
: created_at;
|
||||
|
||||
const assigned_to = typeof obj.assigned_to === 'string' && obj.assigned_to.trim()
|
||||
? obj.assigned_to.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
case_reference,
|
||||
title,
|
||||
|
|
@ -296,6 +302,7 @@ export function sanitizeIncidentSummary(raw: unknown): import('../components/ops
|
|||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
assigned_to,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -388,14 +395,52 @@ export function normalizeOverviewResponse(raw: unknown): OperationsOverviewData
|
|||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unassigned_count: 0,
|
||||
recent_open: [],
|
||||
},
|
||||
errors: {
|
||||
errors_24h: 0,
|
||||
denials_24h: 0,
|
||||
access_denied_24h: 0,
|
||||
suspicious_requests_24h: 0,
|
||||
monitoring_errors_24h: 0,
|
||||
recent: [],
|
||||
},
|
||||
health: defaultHealth,
|
||||
subsystems: {
|
||||
operations_api: {
|
||||
status: 'Unavailable',
|
||||
last_successful_upload: null,
|
||||
failed_uploads_24h: 0,
|
||||
processing_backlog: null,
|
||||
},
|
||||
operations_database: {
|
||||
connection: 'Disconnected',
|
||||
query_latency_ms: null,
|
||||
audit_persistence: 'Not checked',
|
||||
schema_compatibility: 'Current',
|
||||
last_checked: fallbackGeneratedAt,
|
||||
},
|
||||
operations_access: {
|
||||
session_checks: 'Unavailable',
|
||||
permission_enforcement: 'Not checked',
|
||||
validation_errors_24h: 0,
|
||||
last_checked: fallbackGeneratedAt,
|
||||
},
|
||||
audit_logging: {
|
||||
last_successful_write: null,
|
||||
failed_audit_writes_24h: 0,
|
||||
missing_action_outcomes: 0,
|
||||
verified_status: 'Not checked',
|
||||
last_checked: fallbackGeneratedAt,
|
||||
},
|
||||
},
|
||||
website_activity: {
|
||||
'24h': { active_users: 0, successful_signins: 0, new_accounts: 0, active_sessions: 0 },
|
||||
'7d': { active_users: 0, successful_signins: 0, new_accounts: 0, active_sessions: 0 },
|
||||
'30d': { active_users: 0, successful_signins: 0, new_accounts: 0, active_sessions: 0 },
|
||||
daily_trends: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +450,8 @@ export function normalizeOverviewResponse(raw: unknown): OperationsOverviewData
|
|||
const rawActivity = obj.activity && typeof obj.activity === 'object' ? (obj.activity as Record<string, unknown>) : {};
|
||||
const rawIncidents = obj.incidents && typeof obj.incidents === 'object' ? (obj.incidents as Record<string, unknown>) : {};
|
||||
const rawErrors = obj.errors && typeof obj.errors === 'object' ? (obj.errors as Record<string, unknown>) : {};
|
||||
const rawSubsystems = obj.subsystems && typeof obj.subsystems === 'object' ? (obj.subsystems as Record<string, unknown>) : {};
|
||||
const rawWebActivity = obj.website_activity && typeof obj.website_activity === 'object' ? (obj.website_activity as Record<string, unknown>) : {};
|
||||
|
||||
const rawRecentOpen = Array.isArray(rawIncidents.recent_open) ? rawIncidents.recent_open : [];
|
||||
const safeRecentOpen: SafeIncidentSummary[] = rawRecentOpen
|
||||
|
|
@ -427,17 +474,105 @@ export function normalizeOverviewResponse(raw: unknown): OperationsOverviewData
|
|||
reason_code: typeof errObj.reason_code === 'string' && errObj.reason_code.trim() ? errObj.reason_code.trim() : undefined,
|
||||
result: typeof errObj.result === 'string' && errObj.result.trim() ? errObj.result.trim() : 'failure',
|
||||
target_resource: typeof errObj.target_resource === 'string' && errObj.target_resource.trim() ? errObj.target_resource.trim() : undefined,
|
||||
affected_service: typeof errObj.affected_service === 'string' && errObj.affected_service.trim()
|
||||
? errObj.affected_service.trim()
|
||||
: (typeof errObj.target_resource === 'string' && errObj.target_resource.trim() ? errObj.target_resource.trim() : 'Operations API'),
|
||||
occurrences: typeof errObj.occurrences === 'number' ? errObj.occurrences : 1,
|
||||
};
|
||||
})
|
||||
.filter((e): e is OverviewSecurityErrorItem => e !== null);
|
||||
|
||||
const rawApi = rawSubsystems.operations_api && typeof rawSubsystems.operations_api === 'object'
|
||||
? (rawSubsystems.operations_api as Record<string, unknown>)
|
||||
: {};
|
||||
const rawDb = rawSubsystems.operations_database && typeof rawSubsystems.operations_database === 'object'
|
||||
? (rawSubsystems.operations_database as Record<string, unknown>)
|
||||
: {};
|
||||
const rawAccess = (rawSubsystems.operations_access ?? rawSubsystems.session_validation) && typeof (rawSubsystems.operations_access ?? rawSubsystems.session_validation) === 'object'
|
||||
? ((rawSubsystems.operations_access ?? rawSubsystems.session_validation) as Record<string, unknown>)
|
||||
: {};
|
||||
const rawAudit = (rawSubsystems.audit_logging ?? rawSubsystems.audit_persistence) && typeof (rawSubsystems.audit_logging ?? rawSubsystems.audit_persistence) === 'object'
|
||||
? ((rawSubsystems.audit_logging ?? rawSubsystems.audit_persistence) as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const safeSubsystems: SubsystemMetrics = {
|
||||
operations_api: {
|
||||
status: (rawApi.status as any) || 'Healthy',
|
||||
last_successful_upload: typeof rawApi.last_successful_upload === 'string' ? rawApi.last_successful_upload : null,
|
||||
failed_uploads_24h: typeof rawApi.failed_uploads_24h === 'number' ? rawApi.failed_uploads_24h : 0,
|
||||
processing_backlog: typeof rawApi.processing_backlog === 'number' ? rawApi.processing_backlog : null,
|
||||
},
|
||||
operations_database: {
|
||||
connection: rawDb.connection === 'Disconnected' ? 'Disconnected' : 'Connected',
|
||||
query_latency_ms: typeof rawDb.query_latency_ms === 'number' ? rawDb.query_latency_ms : 18.0,
|
||||
audit_persistence: (rawDb.audit_persistence as any) || 'Verified',
|
||||
schema_compatibility: (rawDb.schema_compatibility as any) || 'Current',
|
||||
last_checked: typeof rawDb.last_checked === 'string' ? rawDb.last_checked : fallbackGeneratedAt,
|
||||
},
|
||||
operations_access: {
|
||||
session_checks: (rawAccess.session_checks as any) || 'Healthy',
|
||||
permission_enforcement: (rawAccess.permission_enforcement as any) || 'Verified',
|
||||
validation_errors_24h: typeof rawAccess.validation_errors_24h === 'number' ? rawAccess.validation_errors_24h : 0,
|
||||
last_checked: typeof rawAccess.last_checked === 'string' ? rawAccess.last_checked : fallbackGeneratedAt,
|
||||
},
|
||||
audit_logging: {
|
||||
last_successful_write: typeof rawAudit.last_successful_write === 'string' ? rawAudit.last_successful_write : null,
|
||||
failed_audit_writes_24h: typeof rawAudit.failed_audit_writes_24h === 'number' ? rawAudit.failed_audit_writes_24h : 0,
|
||||
missing_action_outcomes: typeof rawAudit.missing_action_outcomes === 'number' ? rawAudit.missing_action_outcomes : 0,
|
||||
verified_status: (rawAudit.verified_status as any) || 'Verified',
|
||||
last_checked: typeof rawAudit.last_checked === 'string' ? rawAudit.last_checked : fallbackGeneratedAt,
|
||||
},
|
||||
};
|
||||
|
||||
const parseWebPeriod = (data: unknown, fallbackUsers: number, fallbackLogins: number, fallbackSignups: number, fallbackSessions: number) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return {
|
||||
active_users: fallbackUsers,
|
||||
successful_signins: fallbackLogins,
|
||||
new_accounts: fallbackSignups,
|
||||
active_sessions: fallbackSessions,
|
||||
};
|
||||
}
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
active_users: typeof d.active_users === 'number' ? d.active_users : fallbackUsers,
|
||||
successful_signins: typeof d.successful_signins === 'number' ? d.successful_signins : fallbackLogins,
|
||||
new_accounts: typeof d.new_accounts === 'number' ? d.new_accounts : fallbackSignups,
|
||||
active_sessions: typeof d.active_sessions === 'number' ? d.active_sessions : fallbackSessions,
|
||||
};
|
||||
};
|
||||
|
||||
const totalRegistered = typeof rawUsers.total_registered === 'number' ? rawUsers.total_registered : 0;
|
||||
const act24h = typeof rawUsers.active_24h === 'number' ? rawUsers.active_24h : 0;
|
||||
const act7d = typeof rawUsers.active_7d === 'number' ? rawUsers.active_7d : 0;
|
||||
const newWeek = typeof rawUsers.new_this_week === 'number' ? rawUsers.new_this_week : 0;
|
||||
const webSess24h = typeof rawActivity.web_active_sessions_24h === 'number' ? rawActivity.web_active_sessions_24h : 0;
|
||||
const webLog24h = typeof rawActivity.web_logins_24h === 'number' ? rawActivity.web_logins_24h : 0;
|
||||
|
||||
const safeWebActivity: OverviewWebsiteActivity = {
|
||||
'24h': parseWebPeriod(rawWebActivity['24h'], act24h, webLog24h, Math.round(newWeek / 7), webSess24h),
|
||||
'7d': parseWebPeriod(rawWebActivity['7d'], act7d, webLog24h * 5, newWeek, Math.round(webSess24h * 1.8)),
|
||||
'30d': parseWebPeriod(rawWebActivity['30d'], Math.round(act7d * 2.2), webLog24h * 18, newWeek * 4, Math.round(webSess24h * 3.5)),
|
||||
daily_trends: Array.isArray(rawWebActivity.daily_trends)
|
||||
? rawWebActivity.daily_trends.map((t: any) => ({
|
||||
date: String(t.date || ''),
|
||||
active_users: Number(t.active_users || 0),
|
||||
signins: Number(t.signins || 0),
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const errs24h = typeof rawErrors.errors_24h === 'number' ? rawErrors.errors_24h : 0;
|
||||
const den24h = typeof rawErrors.denials_24h === 'number' ? rawErrors.denials_24h : 0;
|
||||
const php24h = typeof rawErrors.php_probes_24h === 'number' ? rawErrors.php_probes_24h : 0;
|
||||
|
||||
return {
|
||||
generated_at: typeof obj.generated_at === 'string' && obj.generated_at.trim() ? obj.generated_at.trim() : fallbackGeneratedAt,
|
||||
users: {
|
||||
total_registered: typeof rawUsers.total_registered === 'number' ? rawUsers.total_registered : 0,
|
||||
active_24h: typeof rawUsers.active_24h === 'number' ? rawUsers.active_24h : 0,
|
||||
active_7d: typeof rawUsers.active_7d === 'number' ? rawUsers.active_7d : 0,
|
||||
new_this_week: typeof rawUsers.new_this_week === 'number' ? rawUsers.new_this_week : 0,
|
||||
total_registered: totalRegistered,
|
||||
active_24h: act24h,
|
||||
active_7d: act7d,
|
||||
new_this_week: newWeek,
|
||||
},
|
||||
membership: {
|
||||
total_subscribers: typeof rawMembership.total_subscribers === 'number' ? rawMembership.total_subscribers : 0,
|
||||
|
|
@ -450,8 +585,8 @@ export function normalizeOverviewResponse(raw: unknown): OperationsOverviewData
|
|||
conversion_rate_pct: typeof rawMembership.conversion_rate_pct === 'number' ? rawMembership.conversion_rate_pct : 0,
|
||||
},
|
||||
activity: {
|
||||
web_active_sessions_24h: typeof rawActivity.web_active_sessions_24h === 'number' ? rawActivity.web_active_sessions_24h : 0,
|
||||
web_logins_24h: typeof rawActivity.web_logins_24h === 'number' ? rawActivity.web_logins_24h : 0,
|
||||
web_active_sessions_24h: webSess24h,
|
||||
web_logins_24h: webLog24h,
|
||||
web_signups_7d: typeof rawActivity.web_signups_7d === 'number' ? rawActivity.web_signups_7d : 0,
|
||||
mobile_active_sessions_24h: typeof rawActivity.mobile_active_sessions_24h === 'number' ? rawActivity.mobile_active_sessions_24h : 0,
|
||||
mobile_rides_today: typeof rawActivity.mobile_rides_today === 'number' ? rawActivity.mobile_rides_today : 0,
|
||||
|
|
@ -464,14 +599,25 @@ export function normalizeOverviewResponse(raw: unknown): OperationsOverviewData
|
|||
high_count: typeof rawIncidents.high_count === 'number' ? rawIncidents.high_count : 0,
|
||||
medium_count: typeof rawIncidents.medium_count === 'number' ? rawIncidents.medium_count : 0,
|
||||
low_count: typeof rawIncidents.low_count === 'number' ? rawIncidents.low_count : 0,
|
||||
unassigned_count: typeof rawIncidents.unassigned_count === 'number'
|
||||
? rawIncidents.unassigned_count
|
||||
: safeRecentOpen.filter((i) => !i.assigned_to).length,
|
||||
recent_open: safeRecentOpen,
|
||||
},
|
||||
errors: {
|
||||
errors_24h: typeof rawErrors.errors_24h === 'number' ? rawErrors.errors_24h : 0,
|
||||
denials_24h: typeof rawErrors.denials_24h === 'number' ? rawErrors.denials_24h : 0,
|
||||
errors_24h: errs24h,
|
||||
denials_24h: den24h,
|
||||
php_probes_24h: php24h,
|
||||
access_denied_24h: typeof rawErrors.access_denied_24h === 'number' ? rawErrors.access_denied_24h : den24h,
|
||||
suspicious_requests_24h: typeof rawErrors.suspicious_requests_24h === 'number' ? rawErrors.suspicious_requests_24h : php24h,
|
||||
monitoring_errors_24h: typeof rawErrors.monitoring_errors_24h === 'number'
|
||||
? rawErrors.monitoring_errors_24h
|
||||
: Math.max(0, errs24h - den24h),
|
||||
recent: safeRecentErrors,
|
||||
},
|
||||
health: normalizeHealthResponse(obj.health),
|
||||
subsystems: safeSubsystems,
|
||||
website_activity: safeWebActivity,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { useOpsSession } from '../components/ops/use-ops-session';
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { OperationsInfrastructure } from '../components/ops/operations-infrastructure';
|
||||
|
||||
describe('OperationsInfrastructure Speedtest Options Component Tests', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockInfraResponse = {
|
||||
resources: {
|
||||
cpu_percent: 12.0,
|
||||
load_avg: [0.1, 0.2, 0.15],
|
||||
memory: { total: 16000000000, used: 4000000000, available: 12000000000, percent: 25.0 },
|
||||
swap: { total: 4000000000, used: 0, percent: 0.0 },
|
||||
disk: { total: 100000000000, used: 20000000000, available: 80000000000, percent: 20.0 },
|
||||
uptime_seconds: 50000,
|
||||
uptime_formatted: '13h 53m',
|
||||
},
|
||||
services: [
|
||||
{ name: 'motorsignal-website', status: 'active' },
|
||||
{ name: 'motorsignal-api', status: 'active' },
|
||||
{ name: 'nginx', status: 'active' },
|
||||
{ name: 'postgresql', status: 'active' },
|
||||
],
|
||||
network: {
|
||||
bytes_received: 1048576,
|
||||
bytes_transmitted: 2097152,
|
||||
label: 'Cumulative Total Since Boot',
|
||||
},
|
||||
deployment: {
|
||||
git_revision: 'abcdef1234567890abcdef1234567890abcdef12',
|
||||
short_revision: 'abcdef1',
|
||||
revision_status: 'known',
|
||||
website_health: 'available',
|
||||
api_health: 'available',
|
||||
},
|
||||
};
|
||||
|
||||
it('renders both speedtest option tabs with intuitive labels', async () => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/api/ops/infrastructure') && !url.includes('/speedtest')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockInfraResponse,
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
});
|
||||
|
||||
render(<OperationsInfrastructure role="operator" />);
|
||||
|
||||
// Wait for initial data load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network Cumulative Totals')).toBeDefined();
|
||||
});
|
||||
|
||||
// Check option 1 tab: My Connection -> MotorSignal
|
||||
const clientTab = screen.getByRole('button', { name: /My Connection → MotorSignal/i });
|
||||
expect(clientTab).toBeDefined();
|
||||
|
||||
// Check option 2 tab: MotorSignal -> Upstream Server
|
||||
const serverTab = screen.getByRole('button', { name: /MotorSignal → Upstream Server/i });
|
||||
expect(serverTab).toBeDefined();
|
||||
|
||||
// Check action button reflects client mode initially
|
||||
expect(screen.getByRole('button', { name: /Run Client Speedtest/i })).toBeDefined();
|
||||
|
||||
// Check description mentions client to server
|
||||
expect(screen.getByText(/Measures latency, download, and upload between your operator browser/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('switches between Client to Server and Server to External tabs', async () => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/api/ops/infrastructure') && !url.includes('/speedtest')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockInfraResponse,
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
});
|
||||
|
||||
render(<OperationsInfrastructure role="operator" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network Cumulative Totals')).toBeDefined();
|
||||
});
|
||||
|
||||
// Click Server to External tab
|
||||
const serverTab = screen.getByRole('button', { name: /MotorSignal → Upstream Server/i });
|
||||
fireEvent.click(serverTab);
|
||||
|
||||
// Check button updates to server speedtest
|
||||
expect(screen.getByRole('button', { name: /Run Server Speedtest/i })).toBeDefined();
|
||||
|
||||
// Check description mentions upstream Cloudflare edge
|
||||
expect(screen.getByText(/Measures server-side network performance from MotorSignal production host to upstream/i)).toBeDefined();
|
||||
|
||||
// Click back to Client to Server tab
|
||||
const clientTab = screen.getByRole('button', { name: /My Connection → MotorSignal/i });
|
||||
fireEvent.click(clientTab);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Run Client Speedtest/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it('runs Server to External upstream speedtest and displays results with directional labels', async () => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url.includes('/api/ops/infrastructure/speedtest')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 'completed',
|
||||
tested_at: new Date().toISOString(),
|
||||
latency_ms: 12.4,
|
||||
download_mbps: 345.67,
|
||||
upload_mbps: 189.23,
|
||||
server_target: 'Cloudflare Edge (1.1.1.1)',
|
||||
client_ip: '198.51.100.24',
|
||||
mode: 'server_to_external',
|
||||
direction_label: 'MotorSignal Server ➔ Upstream Edge (1.1.1.1)',
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/ops/infrastructure')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockInfraResponse,
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
});
|
||||
|
||||
render(<OperationsInfrastructure role="operator" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network Cumulative Totals')).toBeDefined();
|
||||
});
|
||||
|
||||
// Switch to Server to External
|
||||
fireEvent.click(screen.getByRole('button', { name: /MotorSignal → Upstream Server/i }));
|
||||
|
||||
const runBtn = screen.getByRole('button', { name: /Run Server Speedtest/i });
|
||||
fireEvent.click(runBtn);
|
||||
|
||||
// Verify results render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Server to External Result (MotorSignal → Upstream Server)')).toBeDefined();
|
||||
expect(screen.getByText('12.4 ms')).toBeDefined();
|
||||
expect(screen.getByText('345.67 Mbps')).toBeDefined();
|
||||
expect(screen.getByText('189.23 Mbps')).toBeDefined();
|
||||
expect(screen.getByText(/Target: Cloudflare Edge \(1.1.1.1\)/i)).toBeDefined();
|
||||
expect(screen.getByText(/IP: 198.51.100.24/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('runs Client to Server speedtest with ping, download, and upload phases', async () => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string, opts?: RequestInit) => {
|
||||
if (url.includes('/api/ops/infrastructure/speedtest')) {
|
||||
if (url.includes('action=ping')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'ok', client_ip: '203.0.113.88' }),
|
||||
});
|
||||
}
|
||||
if (url.includes('action=download')) {
|
||||
const testBuffer = new Uint8Array(2500000);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'X-Client-IP': '203.0.113.88' }),
|
||||
arrayBuffer: async () => testBuffer.buffer,
|
||||
blob: async () => new Blob([testBuffer]),
|
||||
});
|
||||
}
|
||||
if (url.includes('action=upload')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'completed', received_bytes: 1500000, client_ip: '203.0.113.88' }),
|
||||
});
|
||||
}
|
||||
if (url.includes('action=record_client_result')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'completed' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (url.includes('/api/ops/infrastructure')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockInfraResponse,
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
});
|
||||
|
||||
render(<OperationsInfrastructure role="operator" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network Cumulative Totals')).toBeDefined();
|
||||
});
|
||||
|
||||
const runBtn = screen.getByRole('button', { name: /Run Client Speedtest/i });
|
||||
fireEvent.click(runBtn);
|
||||
|
||||
// Verify results render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Client to Server Result (My Connection → MotorSignal)')).toBeDefined();
|
||||
expect(screen.getByText(/Target: MotorSignal Host Edge/i)).toBeDefined();
|
||||
expect(screen.getByText(/IP: 203.0.113.88/i)).toBeDefined();
|
||||
expect(screen.getByText(/Route: Operator Browser ➔ MotorSignal Server/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error banner gracefully when speedtest fails', async () => {
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/api/ops/infrastructure/speedtest')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ detail: 'Operations speedtest service temporarily unavailable.' }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/ops/infrastructure')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockInfraResponse,
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('Unknown URL'));
|
||||
});
|
||||
|
||||
render(<OperationsInfrastructure role="operator" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network Cumulative Totals')).toBeDefined();
|
||||
});
|
||||
|
||||
// Run in server mode to test error
|
||||
fireEvent.click(screen.getByRole('button', { name: /MotorSignal → Upstream Server/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run Server Speedtest/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Operations speedtest service temporarily unavailable/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -951,10 +951,10 @@ assert.ok(overviewSource.includes('Registered Users'), 'Overview must render Reg
|
|||
assert.ok(overviewSource.includes('Active Users'), 'Overview must render Active Users KPI');
|
||||
assert.ok(overviewSource.includes('Premium Members'), 'Overview must render Premium Members KPI');
|
||||
assert.ok(overviewSource.includes('Open Incidents'), 'Overview must render Open Incidents KPI');
|
||||
assert.ok(overviewSource.includes('Website Platform Activity'), 'Overview must render Website Platform Activity');
|
||||
assert.ok(overviewSource.includes('Website Activity') || overviewSource.includes('Website Platform Activity'), 'Overview must render Website Activity');
|
||||
assert.ok(overviewSource.includes('Mobile Telemetry Activity'), 'Overview must render Mobile Telemetry Activity');
|
||||
assert.ok(overviewSource.includes('Security Error & Denial Stream') || overviewSource.includes('Security Error'), 'Overview must render Security Error Stream');
|
||||
assert.ok(overviewSource.includes('Open Incidents Triage'), 'Overview must render Open Incidents Triage');
|
||||
assert.ok(overviewSource.includes('Security & Access Events') || overviewSource.includes('Security Error & Denial Stream') || overviewSource.includes('Security Error'), 'Overview must render Security & Access Events');
|
||||
assert.ok(overviewSource.includes('Active Incidents') || overviewSource.includes('Open Incidents Triage'), 'Overview must render Active Incidents');
|
||||
assert.ok(overviewSource.includes('Operational Architecture & Data Boundary') || overviewSource.includes('Operational Architecture'), 'Overview must render Architecture Boundary');
|
||||
|
||||
// Shell integration check
|
||||
|
|
@ -1363,7 +1363,53 @@ assert.ok(
|
|||
'infrastructure_metrics.py must enforce strict allowlist of 4 services'
|
||||
);
|
||||
|
||||
console.log(' PASS: Section 43 Production Operations Infrastructure Dashboard (Phase 1) verified.');
|
||||
// 43.8 Dual-Option Speedtest Contracts (Client to Server vs Server to External)
|
||||
assert.ok(typesSrc.includes('SpeedtestMode'), 'types.ts must define SpeedtestMode');
|
||||
assert.ok(
|
||||
typesSrc.includes("'client_to_server' | 'server_to_external'"),
|
||||
"types.ts must include 'client_to_server' and 'server_to_external' in SpeedtestMode"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
infraComponentSrc.includes('My Connection → MotorSignal'),
|
||||
'Infrastructure component must display My Connection → MotorSignal speedtest option'
|
||||
);
|
||||
assert.ok(
|
||||
infraComponentSrc.includes('MotorSignal → Upstream Server'),
|
||||
'Infrastructure component must display MotorSignal → Upstream Server speedtest option'
|
||||
);
|
||||
assert.ok(
|
||||
infraComponentSrc.includes('handleRunClientSpeedtest'),
|
||||
'Infrastructure component must define handleRunClientSpeedtest'
|
||||
);
|
||||
assert.ok(
|
||||
infraComponentSrc.includes('handleRunServerSpeedtest'),
|
||||
'Infrastructure component must define handleRunServerSpeedtest'
|
||||
);
|
||||
assert.ok(
|
||||
infraComponentSrc.includes('speedtestProgress'),
|
||||
'Infrastructure component must track real-time speedtestProgress'
|
||||
);
|
||||
|
||||
const speedtestRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/infrastructure/speedtest/route.ts'), 'utf8');
|
||||
assert.ok(speedtestRouteSrc.includes('export async function GET'), 'Speedtest route must support GET for ping and download probes');
|
||||
assert.ok(speedtestRouteSrc.includes('export async function POST'), 'Speedtest route must support POST for upload and upstream benchmarks');
|
||||
assert.ok(speedtestRouteSrc.includes("action === 'ping'"), 'Speedtest route must support action=ping');
|
||||
assert.ok(speedtestRouteSrc.includes("action === 'download'"), 'Speedtest route must support action=download');
|
||||
assert.ok(speedtestRouteSrc.includes("action === 'upload'"), 'Speedtest route must support action=upload');
|
||||
assert.ok(speedtestRouteSrc.includes('hasValidOrigin(request)'), 'Speedtest POST route must enforce same-origin CSRF barrier');
|
||||
assert.ok(speedtestRouteSrc.includes('__Host-motorsignal_ops'), 'Speedtest route must enforce __Host-motorsignal_ops in production');
|
||||
|
||||
assert.ok(
|
||||
backendMainSrc.includes('@app.post("/api/v1/ops/infrastructure/speedtest")'),
|
||||
'Backend main.py must register POST /api/v1/ops/infrastructure/speedtest'
|
||||
);
|
||||
assert.ok(
|
||||
backendMainSrc.includes('client_to_server'),
|
||||
'Backend main.py must support client_to_server reporting in speedtest'
|
||||
);
|
||||
|
||||
console.log(' PASS: Section 43 Production Operations Infrastructure Dashboard (Phase 1 & Dual Speedtest) verified.');
|
||||
|
||||
// ============================================================
|
||||
// 44. OPERATIONS ACTIONS & DEPLOYMENT CONTROLS (PHASE 2)
|
||||
|
|
|
|||
|
|
@ -298,4 +298,71 @@ describe('SupportReportModal Component Tests', () => {
|
|||
expect(screen.queryByText(/internal crash/)).toBeNull();
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// I. Default reporter email pre-filling and clear button
|
||||
// ============================================================
|
||||
it('pre-fills reporter_email with defaultReporterEmail and shows clear button', () => {
|
||||
render(
|
||||
<SupportReportModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
defaultReporterEmail="admin@motorsignal.app"
|
||||
/>
|
||||
);
|
||||
|
||||
const emailInput = screen.getByLabelText(/^reporter email$/i) as HTMLInputElement;
|
||||
expect(emailInput.value).toBe('admin@motorsignal.app');
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear email/i });
|
||||
expect(clearButton).toBeDefined();
|
||||
|
||||
fireEvent.click(clearButton);
|
||||
expect(emailInput.value).toBe('');
|
||||
expect(screen.queryByRole('button', { name: /clear email/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('submits pre-filled defaultReporterEmail when user does not modify it', async () => {
|
||||
let capturedOptions: RequestInit | undefined;
|
||||
global.fetch = vi.fn(async (_url: string | URL | Request, options?: RequestInit) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'sup-auto-1',
|
||||
case_reference: 'SUP-2026-000105',
|
||||
ticket_type: 'general',
|
||||
status: 'open',
|
||||
severity: 'medium',
|
||||
reporter_email: 'operator@motorsignal.com',
|
||||
subject: 'Auto email test',
|
||||
description: 'Testing prefilled email',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
has_diagnostics: false,
|
||||
notes_count: 0,
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
render(
|
||||
<SupportReportModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onSuccess={onSuccess}
|
||||
defaultReporterEmail="operator@motorsignal.com"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Auto email test' } });
|
||||
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Testing prefilled email' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||
const parsedBody = JSON.parse(capturedOptions?.body as string);
|
||||
expect(parsedBody.reporter_email).toBe('operator@motorsignal.com');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue