feat(ops): add incident management, user administration, support tickets, and website CMS
This commit is contained in:
parent
0282a2972f
commit
fa2fccd137
|
|
@ -8,6 +8,7 @@ DATABASE_NAME=motorsignal_dev
|
||||||
DATABASE_USER=motorsignal
|
DATABASE_USER=motorsignal
|
||||||
DATABASE_PASSWORD=replace-me
|
DATABASE_PASSWORD=replace-me
|
||||||
WEB_BASE_URL=http://localhost:3000
|
WEB_BASE_URL=http://localhost:3000
|
||||||
|
FIREBASE_PROJECT_ID=motoapp-d22e0
|
||||||
|
|
||||||
# Optional email delivery. Without SMTP_HOST, token emails are not sent.
|
# Optional email delivery. Without SMTP_HOST, token emails are not sent.
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -119,8 +119,12 @@ def main() -> None:
|
||||||
assert status == 201 and telemetry["accepted"] == 1
|
assert status == 201 and telemetry["accepted"] == 1
|
||||||
status, telemetry = request("GET", f"/api/v1/rides/{created_ride['id']}/telemetry", token=admin_access)
|
status, telemetry = request("GET", f"/api/v1/rides/{created_ride['id']}/telemetry", token=admin_access)
|
||||||
assert status == 200 and telemetry["count"] == 1
|
assert status == 200 and telemetry["count"] == 1
|
||||||
|
status, summary = request("GET", f"/api/v1/rides/{created_ride['id']}/telemetry/summary", token=admin_access)
|
||||||
|
assert status == 200 and summary["point_count"] == 1 and summary["max_speed_mph"] == 42.0
|
||||||
status, telemetry = request("GET", f"/api/v1/admin/rides/{created_ride['id']}/telemetry", token=admin_access)
|
status, telemetry = request("GET", f"/api/v1/admin/rides/{created_ride['id']}/telemetry", token=admin_access)
|
||||||
assert status == 200 and telemetry["count"] == 1
|
assert status == 200 and telemetry["count"] == 1
|
||||||
|
status, summary = request("GET", f"/api/v1/admin/rides/{created_ride['id']}/telemetry/summary", token=admin_access)
|
||||||
|
assert status == 200 and summary["point_count"] == 1
|
||||||
status, subscription = request("GET", "/api/v1/me/subscription", token=admin_access)
|
status, subscription = request("GET", "/api/v1/me/subscription", token=admin_access)
|
||||||
assert status == 200 and subscription["plan"] == "free"
|
assert status == 200 and subscription["plan"] == "free"
|
||||||
status, entitlements = request("GET", "/api/v1/me/entitlements", token=admin_access)
|
status, entitlements = request("GET", "/api/v1/me/entitlements", token=admin_access)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "app"))
|
||||||
|
|
||||||
|
os.environ.setdefault("JWT_SECRET", "local-test-secret")
|
||||||
|
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
|
||||||
|
os.environ["DATABASE_USER"] = "test"
|
||||||
|
os.environ["DATABASE_PASSWORD"] = "test"
|
||||||
|
os.environ["DATABASE_NAME"] = "test"
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event, select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
from main import app, get_db, SUPPORT_TICKETS
|
||||||
|
from models import Base, Operator, OpsAuditEvent, OpsSession
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw_token: str) -> str:
|
||||||
|
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpsSupport(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.db_path = os.path.join(ROOT, "tests", "test_ops_support.db")
|
||||||
|
cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_support_schema.db")
|
||||||
|
for p in (cls.db_path, cls.ops_db_path):
|
||||||
|
if os.path.exists(p):
|
||||||
|
try:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cls.engine = create_engine(f"sqlite:///{cls.db_path}")
|
||||||
|
|
||||||
|
@event.listens_for(cls.engine, "connect")
|
||||||
|
def attach_ops(dbapi_con, con_record):
|
||||||
|
dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops")
|
||||||
|
|
||||||
|
Base.metadata.create_all(cls.engine)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = DbSession(cls.engine)
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
cls.engine.dispose()
|
||||||
|
for p in (cls.db_path, cls.ops_db_path):
|
||||||
|
if os.path.exists(p):
|
||||||
|
try:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
db.query(OpsAuditEvent).delete()
|
||||||
|
db.query(OpsSession).delete()
|
||||||
|
db.query(Operator).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.op_id = uuid.uuid4()
|
||||||
|
self.op_identity = "operator@motorsignal.internal"
|
||||||
|
self.op_token = "valid-operator-token-123"
|
||||||
|
op = Operator(
|
||||||
|
id=self.op_id,
|
||||||
|
identity=self.op_identity,
|
||||||
|
role="operator",
|
||||||
|
status="active",
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
op_session = OpsSession(
|
||||||
|
operator_id=self.op_id,
|
||||||
|
session_token_hash=hash_token(self.op_token),
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(op)
|
||||||
|
db.add(op_session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def test_list_tickets_unauthorized(self):
|
||||||
|
resp = self.client.get("/api/v1/ops/support/tickets")
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_list_tickets_authorized(self):
|
||||||
|
resp = self.client.get(
|
||||||
|
"/api/v1/ops/support/tickets",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertIn("items", data)
|
||||||
|
self.assertIn("total", data)
|
||||||
|
self.assertGreaterEqual(data["total"], 4)
|
||||||
|
|
||||||
|
def test_filter_tickets_by_type_and_status(self):
|
||||||
|
resp = self.client.get(
|
||||||
|
"/api/v1/ops/support/tickets?type_filter=bug_report",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
items = resp.json()["items"]
|
||||||
|
for it in items:
|
||||||
|
self.assertEqual(it["ticket_type"], "bug_report")
|
||||||
|
|
||||||
|
resp = self.client.get(
|
||||||
|
"/api/v1/ops/support/tickets?status_filter=resolved",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
items = resp.json()["items"]
|
||||||
|
for it in items:
|
||||||
|
self.assertEqual(it["status"], "resolved")
|
||||||
|
|
||||||
|
def test_search_tickets(self):
|
||||||
|
resp = self.client.get(
|
||||||
|
"/api/v1/ops/support/tickets?q=freeze",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
items = resp.json()["items"]
|
||||||
|
self.assertGreaterEqual(len(items), 1)
|
||||||
|
self.assertTrue(any("freeze" in it["subject"].lower() for it in items))
|
||||||
|
|
||||||
|
def test_get_ticket_detail(self):
|
||||||
|
ticket_id = "e1a11111-2222-3333-4444-555555555551"
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["case_reference"], "SUP-2026-000101")
|
||||||
|
self.assertEqual(data["has_diagnostics"], True)
|
||||||
|
self.assertIn("notes", data)
|
||||||
|
|
||||||
|
def test_create_ticket_and_audit(self):
|
||||||
|
payload = {
|
||||||
|
"ticket_type": "bug_report",
|
||||||
|
"reporter_email": "testrider@example.com",
|
||||||
|
"subject": "Bluetooth disconnects after 10 mins",
|
||||||
|
"description": "App drops connection to OBD reader after 10 minutes of riding.",
|
||||||
|
"severity": "medium",
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
self.assertTrue(data["case_reference"].startswith("SUP-2026-"))
|
||||||
|
self.assertEqual(data["status"], "open")
|
||||||
|
self.assertEqual(data["reporter_email"], "testrider@example.com")
|
||||||
|
|
||||||
|
# Verify audit event
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
event = db.scalar(
|
||||||
|
select(OpsAuditEvent).where(
|
||||||
|
OpsAuditEvent.action == "support.ticket_created",
|
||||||
|
OpsAuditEvent.case_reference == data["case_reference"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(event)
|
||||||
|
self.assertEqual(event.actor_identity, self.op_identity)
|
||||||
|
|
||||||
|
def test_add_ticket_note_and_audit(self):
|
||||||
|
ticket_id = "e1a11111-2222-3333-4444-555555555551"
|
||||||
|
resp = self.client.post(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}/notes",
|
||||||
|
json={"body": "Investigated BLE stack disconnect log."},
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
note = resp.json()
|
||||||
|
self.assertEqual(note["author_identity"], self.op_identity)
|
||||||
|
self.assertEqual(note["body"], "Investigated BLE stack disconnect log.")
|
||||||
|
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
event = db.scalar(
|
||||||
|
select(OpsAuditEvent).where(
|
||||||
|
OpsAuditEvent.action == "support.note_added",
|
||||||
|
OpsAuditEvent.target_resource == f"ticket:{ticket_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(event)
|
||||||
|
|
||||||
|
def test_patch_ticket_status_severity_and_audit(self):
|
||||||
|
ticket_id = "e1a11111-2222-3333-4444-555555555552"
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}",
|
||||||
|
json={"status": "resolved", "severity": "low"},
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["status"], "resolved")
|
||||||
|
self.assertEqual(data["severity"], "low")
|
||||||
|
self.assertIsNotNone(data["resolved_at"])
|
||||||
|
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
event = db.scalar(
|
||||||
|
select(OpsAuditEvent).where(
|
||||||
|
OpsAuditEvent.action == "support.ticket_updated",
|
||||||
|
OpsAuditEvent.target_resource == f"ticket:{ticket_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(event)
|
||||||
|
|
||||||
|
def test_get_diagnostics_requires_reason(self):
|
||||||
|
ticket_id = "e1a11111-2222-3333-4444-555555555551"
|
||||||
|
# Reason missing
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
# Reason too short (< 3 chars)
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics?reason=ab",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
# Valid reason
|
||||||
|
reason_text = "Debugging gyro freeze issue for user"
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics?reason={reason_text}",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
bundle = resp.json()
|
||||||
|
self.assertEqual(bundle["device_model"], "Google Pixel 8 Pro")
|
||||||
|
self.assertIn("SensorFusionException", bundle["stack_trace"])
|
||||||
|
|
||||||
|
# Audit event recorded
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
event = db.scalar(
|
||||||
|
select(OpsAuditEvent).where(
|
||||||
|
OpsAuditEvent.action == "support.diagnostics_viewed",
|
||||||
|
OpsAuditEvent.target_resource == f"ticket:{ticket_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(event)
|
||||||
|
self.assertEqual(event.reason_code, reason_text)
|
||||||
|
|
||||||
|
def test_get_diagnostics_not_found(self):
|
||||||
|
ticket_id = "e1a11111-2222-3333-4444-555555555552"
|
||||||
|
resp = self.client.get(
|
||||||
|
f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics?reason=investigation",
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
import { OperationsShell } from '../../components/ops/operations-shell';
|
import { OperationsShell } from '../../components/ops/operations-shell';
|
||||||
import { OperationsErrorState } from '../../components/ops/operations-error-state';
|
import { OperationsErrorState } from '../../components/ops/operations-error-state';
|
||||||
import { normalizeHealthResponse, sanitizeAuditEvent } from '../../lib/ops-data';
|
import { normalizeHealthResponse, sanitizeAuditEvent } from '../../lib/ops-data';
|
||||||
|
|
@ -13,9 +14,11 @@ interface AdminPageProps {
|
||||||
searchParams?: Promise<{
|
searchParams?: Promise<{
|
||||||
nav?: string;
|
nav?: string;
|
||||||
case?: string;
|
case?: string;
|
||||||
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function parseNavParam(nav?: string): OperationsNavId {
|
function parseNavParam(nav?: string): OperationsNavId {
|
||||||
if (nav === 'incidents' || nav === 'audit' || nav === 'operators' || nav === 'session') {
|
if (nav === 'incidents' || nav === 'audit' || nav === 'operators' || nav === 'session') {
|
||||||
return nav;
|
return nav;
|
||||||
|
|
@ -35,10 +38,26 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
cookieStore.get('motorsignal_ops')?.value;
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
const riderToken = cookieStore.get('motorsignal_access_token')?.value;
|
||||||
|
|
||||||
|
if (resolvedParams.error === 'forbidden') {
|
||||||
|
return (
|
||||||
|
<section className="ops-error-root min-h-screen bg-surface flex items-center justify-center p-4">
|
||||||
|
<OperationsErrorState
|
||||||
|
status={403}
|
||||||
|
message="You do not have sufficient authority to access Operations."
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Unauthenticated - prompt for login (Zero query parameter preview/bypass)
|
// Unauthenticated - prompt for login (Zero query parameter preview/bypass)
|
||||||
if (!opsToken) {
|
if (!opsToken) {
|
||||||
|
if (riderToken) {
|
||||||
|
redirect('/api/ops/auth/session');
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen bg-surface flex items-center justify-center p-4">
|
<section className="ops-error-root min-h-screen bg-surface flex items-center justify-center p-4">
|
||||||
<OperationsErrorState
|
<OperationsErrorState
|
||||||
status={401}
|
status={401}
|
||||||
message="Your Operations session has expired or is unauthenticated."
|
message="Your Operations session has expired or is unauthenticated."
|
||||||
|
|
@ -58,8 +77,11 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||||
const meResponse = await fetch(`${baseUrl}/api/v1/ops/me`, { headers, cache: 'no-store' });
|
const meResponse = await fetch(`${baseUrl}/api/v1/ops/me`, { headers, cache: 'no-store' });
|
||||||
|
|
||||||
if (meResponse.status === 401) {
|
if (meResponse.status === 401) {
|
||||||
|
if (riderToken) {
|
||||||
|
redirect('/api/ops/auth/session');
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen bg-surface flex items-center justify-center p-4">
|
<section className="ops-error-root min-h-screen bg-surface flex items-center justify-center p-4">
|
||||||
<OperationsErrorState
|
<OperationsErrorState
|
||||||
status={401}
|
status={401}
|
||||||
message="Your Operations session has expired. Please sign in again."
|
message="Your Operations session has expired. Please sign in again."
|
||||||
|
|
@ -68,9 +90,10 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (meResponse.status === 403) {
|
if (meResponse.status === 403) {
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen bg-surface flex items-center justify-center p-4">
|
<section className="ops-error-root min-h-screen bg-surface flex items-center justify-center p-4">
|
||||||
<OperationsErrorState
|
<OperationsErrorState
|
||||||
status={403}
|
status={403}
|
||||||
message="You do not have sufficient authority to access Operations."
|
message="You do not have sufficient authority to access Operations."
|
||||||
|
|
@ -81,7 +104,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||||
|
|
||||||
if (!meResponse.ok) {
|
if (!meResponse.ok) {
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen bg-surface flex items-center justify-center p-4">
|
<section className="ops-error-root min-h-screen bg-surface flex items-center justify-center p-4">
|
||||||
<OperationsErrorState
|
<OperationsErrorState
|
||||||
status={503}
|
status={503}
|
||||||
message="Operations platform is temporarily unavailable."
|
message="Operations platform is temporarily unavailable."
|
||||||
|
|
@ -155,7 +178,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen bg-surface flex items-center justify-center p-4">
|
<section className="ops-error-root min-h-screen bg-surface flex items-center justify-center p-4">
|
||||||
<OperationsErrorState
|
<OperationsErrorState
|
||||||
status={503}
|
status={503}
|
||||||
message="Unable to connect to the Operations platform service."
|
message="Unable to connect to the Operations platform service."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
function getSafeRedirectUrl(targetPath: string, request: NextRequest): URL {
|
||||||
|
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0].trim()
|
||||||
|
?? request.headers.get('host')
|
||||||
|
?? 'motorsignal.app';
|
||||||
|
const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0].trim()
|
||||||
|
?? (process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||||
|
return new URL(targetPath, `${forwardedProto}://${forwardedHost}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const riderToken = cookieStore.get('motorsignal_access_token')?.value;
|
||||||
|
|
||||||
|
if (!riderToken) {
|
||||||
|
return NextResponse.redirect(getSafeRedirectUrl('/login?returnTo=/admin', request));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${baseUrl}/api/v1/ops/session/bootstrap`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${riderToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (upstream.ok) {
|
||||||
|
const data = (await upstream.json()) as { ops_token?: string };
|
||||||
|
const opsToken = data.ops_token;
|
||||||
|
|
||||||
|
if (opsToken) {
|
||||||
|
const redirectUrl = getSafeRedirectUrl('/admin', request);
|
||||||
|
const response = NextResponse.redirect(redirectUrl);
|
||||||
|
const opsCookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
response.cookies.set('__Host-motorsignal_ops', opsToken, opsCookieOptions);
|
||||||
|
response.cookies.set('motorsignal_ops', opsToken, {
|
||||||
|
...opsCookieOptions,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upstream.status === 403) {
|
||||||
|
return NextResponse.redirect(getSafeRedirectUrl('/admin?error=forbidden', request));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(getSafeRedirectUrl('/login?returnTo=/admin', request));
|
||||||
|
} catch {
|
||||||
|
return NextResponse.redirect(getSafeRedirectUrl('/login?returnTo=/admin', request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const riderToken = cookieStore.get('motorsignal_access_token')?.value;
|
||||||
|
|
||||||
|
if (!riderToken) {
|
||||||
|
return NextResponse.json({ detail: 'Rider authentication required.' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${baseUrl}/api/v1/ops/session/bootstrap`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${riderToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
const err = await upstream.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(err, { status: upstream.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await upstream.json()) as { ops_token?: string };
|
||||||
|
const opsToken = data.ops_token;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json({ detail: 'Failed to issue operations token.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
const opsCookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
response.cookies.set('__Host-motorsignal_ops', opsToken, opsCookieOptions);
|
||||||
|
response.cookies.set('motorsignal_ops', opsToken, {
|
||||||
|
...opsCookieOptions,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ detail: 'Operations bootstrap unavailable.' }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents/${encodeURIComponent(slug)}/rollback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to rollback website content.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'CMS document or revision not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid rollback parameters.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params;
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view this content document.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'CMS document not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to update website content.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'CMS document not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid CMS update fields.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view website content.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { normalizeOverviewResponse } from '../../../../lib/ops-data';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/overview`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Access restricted.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
normalizeOverviewResponse(null),
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(normalizeOverviewResponse(data), { status: 200 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
normalizeOverviewResponse(null),
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
ticketId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ticketId } = await context.params;
|
||||||
|
const reasonParam = request.nextUrl.searchParams.get('reason');
|
||||||
|
const reasonHeader = request.headers.get('x-diagnostic-reason');
|
||||||
|
const reason = (reasonParam ?? reasonHeader ?? '').trim();
|
||||||
|
|
||||||
|
if (!reason || reason.length < 3) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'A valid reason of at least 3 characters is required to inspect diagnostics.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
const targetUrl = new URL(
|
||||||
|
`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}/diagnostics`
|
||||||
|
);
|
||||||
|
targetUrl.searchParams.set('reason', reason);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to inspect diagnostics.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'No diagnostic bundle found for this ticket.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid reason parameter.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
ticketId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ticketId } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to add notes.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Support ticket not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Note body cannot be empty.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
ticketId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ticketId } = await context.params;
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view this support ticket.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Support ticket not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ticketId } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to update support tickets.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Support ticket not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid update values.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const typeFilter = searchParams.get('type_filter');
|
||||||
|
const statusFilter = searchParams.get('status_filter');
|
||||||
|
const q = searchParams.get('q');
|
||||||
|
const cursor = searchParams.get('cursor');
|
||||||
|
const limit = searchParams.get('limit');
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
const targetUrl = new URL(`${baseUrl}/api/v1/ops/support/tickets`);
|
||||||
|
if (typeFilter) targetUrl.searchParams.set('type_filter', typeFilter);
|
||||||
|
if (statusFilter) targetUrl.searchParams.set('status_filter', statusFilter);
|
||||||
|
if (q) targetUrl.searchParams.set('q', q);
|
||||||
|
if (cursor) targetUrl.searchParams.set('cursor', cursor);
|
||||||
|
if (limit) targetUrl.searchParams.set('limit', limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view support tickets.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to create support tickets.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid support ticket fields.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
userId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'You do not have authority to reset user passwords.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'User not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid request fields.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
userId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}/revoke-sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'You do not have authority to revoke sessions.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'User not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid request fields.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
userId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await context.params;
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view this User.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'User not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{
|
||||||
|
userId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
if (!hasValidOrigin(request)) {
|
||||||
|
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await context.params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Invalid request body.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'You do not have authority to update user status.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'User not found.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: data?.detail ?? 'Invalid user status fields.' },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||||
|
const opsToken = isProduction
|
||||||
|
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||||
|
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||||
|
cookieStore.get('motorsignal_ops')?.value;
|
||||||
|
|
||||||
|
if (!opsToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Authentication required.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const q = searchParams.get('q');
|
||||||
|
const cursor = searchParams.get('cursor');
|
||||||
|
const limit = searchParams.get('limit');
|
||||||
|
const statusFilter = searchParams.get('status_filter');
|
||||||
|
const verifiedFilter = searchParams.get('verified_filter');
|
||||||
|
|
||||||
|
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||||
|
const targetUrl = new URL(`${baseUrl}/api/v1/ops/users`);
|
||||||
|
if (q) targetUrl.searchParams.set('q', q);
|
||||||
|
if (cursor) targetUrl.searchParams.set('cursor', cursor);
|
||||||
|
if (limit) targetUrl.searchParams.set('limit', limit);
|
||||||
|
if (statusFilter) targetUrl.searchParams.set('status_filter', statusFilter);
|
||||||
|
if (verifiedFilter) targetUrl.searchParams.set('verified_filter', verifiedFilter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${opsToken}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Your Operations session has expired.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status === 403) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'You do not have authority to view Users.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Operations is temporarily unavailable.' },
|
||||||
|
{ status: response.status >= 500 ? response.status : 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ detail: 'Unable to connect to Operations platform.' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,8 +32,8 @@ export function OperationsAudit({
|
||||||
<span className="material-symbols-outlined text-[24px]">lock</span>
|
<span className="material-symbols-outlined text-[24px]">lock</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-mono uppercase tracking-widest text-outline">
|
<span className="text-[10px] font-mono text-outline font-semibold">
|
||||||
ACCESS RESTRICTED
|
Access Restricted
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-lg font-bold text-on-surface mt-1">
|
<h2 className="text-lg font-bold text-on-surface mt-1">
|
||||||
Owner Authority Required
|
Owner Authority Required
|
||||||
|
|
@ -44,7 +44,7 @@ export function OperationsAudit({
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 border-t border-outline-variant/20 flex items-center justify-center gap-2 text-xs text-outline font-mono">
|
<div className="pt-3 border-t border-outline-variant/20 flex items-center justify-center gap-2 text-xs text-outline font-mono">
|
||||||
<span>Current Role:</span>
|
<span>Current Role:</span>
|
||||||
<span className="px-2 py-0.5 rounded bg-surface-container-highest text-on-surface font-semibold uppercase">
|
<span className="px-2 py-0.5 rounded bg-surface-container-highest text-on-surface font-semibold capitalize">
|
||||||
{role}
|
{role}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,23 +57,23 @@ export function OperationsAudit({
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] font-mono uppercase tracking-widest text-primary-fixed-dim">
|
<span className="text-[11px] font-mono text-primary-fixed-dim block font-semibold">
|
||||||
GOVERNANCE & COMPLIANCE
|
Governance & Compliance
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-headline-xl text-2xl sm:text-3xl font-bold text-on-surface tracking-tight mt-1">
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1">
|
||||||
Audit Activity
|
Audit Activity
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-on-surface-variant mt-1">
|
<p className="text-xs sm:text-sm text-outline mt-1.5 max-w-2xl">
|
||||||
Immutable operational and security activity log across platform boundaries.
|
Immutable operational and security activity log across platform boundaries.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{total !== undefined && (
|
{total !== undefined && (
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0">
|
||||||
<span className="text-[10px] uppercase font-mono text-outline block">
|
<span className="text-[11px] font-mono text-outline block">
|
||||||
Recorded Events
|
Recorded Events
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono font-bold text-on-surface">
|
<span className="text-sm sm:text-base font-mono font-extrabold text-on-surface">
|
||||||
{total.toLocaleString()}
|
{total.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,31 +81,31 @@ export function OperationsAudit({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit Activity Table Container */}
|
{/* Audit Activity Table Container */}
|
||||||
<div className="rounded-xl bg-surface-container-low border border-outline-variant/30 overflow-hidden shadow-sm">
|
<div className="rounded-2xl bg-surface-container-low border border-outline-variant/30 overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
className="w-full text-left border-collapse min-w-[720px]"
|
className="w-full text-left border-collapse min-w-[720px]"
|
||||||
aria-label="Operations Audit Events"
|
aria-label="Operations Audit Events"
|
||||||
>
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-outline-variant/30 bg-surface-container-lowest/60 text-[11px] font-mono uppercase tracking-wider text-outline">
|
<tr className="border-b border-outline-variant/30 bg-surface-container-lowest/60 text-[11px] font-mono text-outline">
|
||||||
<th scope="col" className="py-3 px-4">
|
<th scope="col" className="py-4 px-5 sm:px-6 font-semibold">
|
||||||
TIME
|
Time
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-3 px-4">
|
<th scope="col" className="py-4 px-5 sm:px-6 font-semibold">
|
||||||
ACTION
|
Action
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-3 px-4">
|
<th scope="col" className="py-4 px-5 sm:px-6 font-semibold">
|
||||||
RESULT
|
Result
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-3 px-4">
|
<th scope="col" className="py-4 px-5 sm:px-6 font-semibold">
|
||||||
ACTOR
|
Actor
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-3 px-4">
|
<th scope="col" className="py-4 px-5 sm:px-6 font-semibold">
|
||||||
TARGET
|
Target
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-3 px-4">
|
<th scope="col" className="py-4 px-5 sm:px-6 font-semibold">
|
||||||
CASE
|
Case
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -169,16 +169,16 @@ export function OperationsAudit({
|
||||||
className="hover:bg-surface-container-high/40 transition-colors"
|
className="hover:bg-surface-container-high/40 transition-colors"
|
||||||
>
|
>
|
||||||
{/* TIME */}
|
{/* TIME */}
|
||||||
<td className="py-3 px-4 font-mono text-[11px] text-on-surface-variant whitespace-nowrap">
|
<td className="py-4 px-5 sm:px-6 font-mono text-[11px] sm:text-xs text-on-surface-variant whitespace-nowrap">
|
||||||
<time dateTime={event.timestamp}>{formattedTime}</time>
|
<time dateTime={event.timestamp}>{formattedTime}</time>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* ACTION */}
|
{/* ACTION */}
|
||||||
<td className="py-3 px-4 font-medium text-on-surface whitespace-nowrap">
|
<td className="py-4 px-5 sm:px-6 font-medium text-on-surface whitespace-nowrap">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-mono text-xs">{event.action}</span>
|
<span className="font-mono text-xs sm:text-sm font-semibold">{event.action}</span>
|
||||||
{event.reason_code && (
|
{event.reason_code && (
|
||||||
<span className="text-[10px] text-outline font-mono">
|
<span className="text-[10px] text-outline font-mono mt-0.5">
|
||||||
reason: {event.reason_code}
|
reason: {event.reason_code}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -186,9 +186,9 @@ export function OperationsAudit({
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* RESULT */}
|
{/* RESULT */}
|
||||||
<td className="py-3 px-4 whitespace-nowrap">
|
<td className="py-4 px-5 sm:px-6 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider border ${
|
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono font-semibold capitalize border ${
|
||||||
isSuccess
|
isSuccess
|
||||||
? 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60'
|
? 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60'
|
||||||
: isFailure
|
: isFailure
|
||||||
|
|
@ -201,13 +201,13 @@ export function OperationsAudit({
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* ACTOR (Current Operations Identity) */}
|
{/* ACTOR (Current Operations Identity) */}
|
||||||
<td className="py-3 px-4">
|
<td className="py-4 px-5 sm:px-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-semibold text-on-surface truncate max-w-[200px]" title={event.actor_identity}>
|
<span className="font-semibold text-on-surface truncate max-w-xs sm:max-w-sm text-xs sm:text-sm" title={event.actor_identity}>
|
||||||
{event.actor_identity}
|
{event.actor_identity}
|
||||||
</span>
|
</span>
|
||||||
{event.actor_role && (
|
{event.actor_role && (
|
||||||
<span className="text-[10px] text-outline font-mono uppercase">
|
<span className="text-[10px] text-outline font-mono mt-0.5 capitalize">
|
||||||
Role: {event.actor_role}
|
Role: {event.actor_role}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -215,12 +215,12 @@ export function OperationsAudit({
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* TARGET */}
|
{/* TARGET */}
|
||||||
<td className="py-3 px-4 font-mono text-[11px] text-on-surface-variant">
|
<td className="py-4 px-5 sm:px-6 font-mono text-xs text-on-surface-variant">
|
||||||
{event.target_resource || '--'}
|
{event.target_resource || '--'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* CASE */}
|
{/* CASE */}
|
||||||
<td className="py-3 px-4 font-mono text-[11px] text-on-surface-variant">
|
<td className="py-4 px-5 sm:px-6 font-mono text-xs text-on-surface-variant">
|
||||||
{event.case_reference || '--'}
|
{event.case_reference || '--'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -63,7 +63,8 @@ export function OperationsCreateIncident({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setErrorMessage('Operations platform is temporarily unavailable.');
|
const data = await response.json().catch(() => null);
|
||||||
|
setErrorMessage(data?.detail ?? 'Operations platform is temporarily unavailable.');
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -81,14 +82,17 @@ export function OperationsCreateIncident({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-outline-variant/20 pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
|
<span className="text-xs font-mono text-primary-fixed-dim font-semibold">
|
||||||
|
New Incident Record
|
||||||
|
</span>
|
||||||
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1.5">
|
||||||
Create Operational Incident
|
Create Operational Incident
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-on-surface-variant mt-0.5">
|
<p className="text-xs sm:text-sm text-on-surface-variant mt-1.5">
|
||||||
Case reference and open status will be generated automatically.
|
Case reference and open status will be generated automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,7 +100,7 @@ export function OperationsCreateIncident({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-surface-container-high hover:bg-surface-bright border border-outline-variant/40 text-xs sm:text-sm font-semibold text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -104,17 +108,17 @@ export function OperationsCreateIncident({
|
||||||
|
|
||||||
{/* Error Alert */}
|
{/* Error Alert */}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
|
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs sm:text-sm">
|
||||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
<span className="material-symbols-outlined text-[20px]">error</span>
|
||||||
<span>{errorMessage}</span>
|
<span>{errorMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form Card */}
|
{/* Form Card */}
|
||||||
<form onSubmit={handleSubmit} className="bg-surface-container-low p-6 rounded-xl border border-outline-variant/30 space-y-5">
|
<form onSubmit={handleSubmit} className="bg-surface-container-low p-6 sm:p-8 rounded-2xl border border-outline-variant/30 space-y-6 shadow-sm">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label htmlFor="incident-title" className="block text-xs font-semibold text-on-surface">
|
<label htmlFor="incident-title" className="block text-xs sm:text-sm font-semibold text-on-surface">
|
||||||
Incident Title <span className="text-error">*</span>
|
Incident Title <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -125,7 +129,7 @@ export function OperationsCreateIncident({
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g. Ingress latency spike on edge cluster"
|
placeholder="e.g. Ingress latency spike on edge cluster"
|
||||||
className="w-full bg-surface-container px-3.5 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="w-full bg-surface-container px-4 py-2.5 rounded-xl border border-outline-variant/40 text-xs sm:text-sm text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -142,7 +146,7 @@ export function OperationsCreateIncident({
|
||||||
key={sev}
|
key={sev}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSeverity(sev)}
|
onClick={() => setSeverity(sev)}
|
||||||
className={`px-3 py-2.5 rounded-lg border text-xs font-mono font-bold uppercase transition-all flex flex-col items-center gap-1 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none ${
|
className={`px-3 py-2.5 rounded-lg border text-xs font-mono font-semibold capitalize transition-all flex flex-col items-center gap-1 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none ${
|
||||||
isSelected
|
isSelected
|
||||||
? sev === 'critical'
|
? sev === 'critical'
|
||||||
? 'bg-error/20 border-error text-error shadow-[0_0_12px_rgba(255,100,100,0.2)]'
|
? 'bg-error/20 border-error text-error shadow-[0_0_12px_rgba(255,100,100,0.2)]'
|
||||||
|
|
@ -184,22 +188,22 @@ export function OperationsCreateIncident({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions Bar */}
|
{/* Actions Bar */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-outline-variant/20">
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-outline-variant/20">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="px-5 py-2.5 rounded-xl bg-surface-container border border-outline-variant/30 text-xs sm:text-sm font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-primary-container text-on-primary-container text-xs sm:text-sm font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<span className="material-symbols-outlined text-[16px] animate-spin">
|
<span className="material-symbols-outlined text-[18px] animate-spin">
|
||||||
progress_activity
|
progress_activity
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { onAuthStateChanged } from 'firebase/auth';
|
||||||
|
import { firebaseAuth } from '../../lib/firebase-client';
|
||||||
|
|
||||||
interface OperationsErrorStateProps {
|
interface OperationsErrorStateProps {
|
||||||
status: 401 | 403 | 404 | 503 | 'disabled' | 'network' | number;
|
status: 401 | 403 | 404 | 503 | 'disabled' | 'network' | number;
|
||||||
|
|
@ -14,17 +16,49 @@ export function OperationsErrorState({
|
||||||
message,
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: OperationsErrorStateProps) {
|
}: OperationsErrorStateProps) {
|
||||||
|
const [authenticating, setAuthenticating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 401) return;
|
||||||
|
const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => {
|
||||||
|
if (user) {
|
||||||
|
setAuthenticating(true);
|
||||||
|
try {
|
||||||
|
const idToken = await user.getIdToken();
|
||||||
|
const res = await fetch('/api/auth/firebase-login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id_token: idToken }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore and remain on error state
|
||||||
|
} finally {
|
||||||
|
setAuthenticating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
let title = 'Operations Error';
|
let title = 'Operations Error';
|
||||||
let description = message ?? 'An unexpected error occurred in the Operations console.';
|
let description = authenticating
|
||||||
let icon = 'error';
|
? 'Verifying operator credentials and activating session...'
|
||||||
|
: (message ?? 'An unexpected error occurred in the Operations console.');
|
||||||
|
let icon = authenticating ? 'sync' : 'error';
|
||||||
let showSignIn = false;
|
let showSignIn = false;
|
||||||
let showRetry = true;
|
let showRetry = true;
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
title = 'Authentication Required';
|
title = authenticating ? 'Authenticating Operator...' : 'Authentication Required';
|
||||||
description = message ?? 'Your Operations session has expired or is unauthenticated.';
|
description = authenticating
|
||||||
icon = 'lock';
|
? 'Verifying operator credentials and establishing secure session...'
|
||||||
showSignIn = true;
|
: (message ?? 'Your Operations session has expired or is unauthenticated.');
|
||||||
|
icon = authenticating ? 'sync' : 'lock';
|
||||||
|
showSignIn = !authenticating;
|
||||||
showRetry = false;
|
showRetry = false;
|
||||||
} else if (status === 403) {
|
} else if (status === 403) {
|
||||||
title = 'Access Restricted';
|
title = 'Access Restricted';
|
||||||
|
|
@ -54,8 +88,8 @@ export function OperationsErrorState({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-widest text-outline">
|
<span className="text-[10px] font-mono text-outline font-semibold">
|
||||||
STATUS {typeof status === 'number' ? status : status.toUpperCase()}
|
Status {status}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-lg font-bold text-on-surface">{title}</h2>
|
<h2 className="text-lg font-bold text-on-surface">{title}</h2>
|
||||||
<p className="text-xs text-on-surface-variant leading-relaxed">
|
<p className="text-xs text-on-surface-variant leading-relaxed">
|
||||||
|
|
@ -66,7 +100,7 @@ export function OperationsErrorState({
|
||||||
<div className="pt-4 border-t border-outline-variant/20 flex items-center justify-center gap-3">
|
<div className="pt-4 border-t border-outline-variant/20 flex items-center justify-center gap-3">
|
||||||
{showSignIn && (
|
{showSignIn && (
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login?returnTo=/admin"
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-sm hover:brightness-105 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-sm hover:brightness-105 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[16px]">login</span>
|
<span className="material-symbols-outlined text-[16px]">login</span>
|
||||||
|
|
|
||||||
|
|
@ -317,27 +317,27 @@ export function OperationsIncidentDetail({
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-error/15 text-error border border-error/30">
|
||||||
CRITICAL
|
Critical
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'high':
|
case 'high':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
HIGH
|
High
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'medium':
|
case 'medium':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-primary-container/15 text-primary-container border border-primary-container/30">
|
||||||
MEDIUM
|
Medium
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'low':
|
case 'low':
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-medium bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
||||||
LOW
|
Low
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -347,39 +347,39 @@ export function OperationsIncidentDetail({
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'open':
|
case 'open':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
||||||
OPEN
|
Open
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'investigating':
|
case 'investigating':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||||
INVESTIGATING
|
Investigating
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'monitoring':
|
case 'monitoring':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||||
MONITORING
|
Monitoring
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'resolved':
|
case 'resolved':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||||
RESOLVED
|
Resolved
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-medium bg-surface-container-high text-outline border border-outline-variant/30">
|
||||||
CLOSED
|
Closed
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'archived':
|
case 'archived':
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-medium bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
||||||
ARCHIVED
|
Archived
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -419,27 +419,27 @@ export function OperationsIncidentDetail({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Navigation & Actions Topbar */}
|
{/* Navigation & Actions Topbar */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs sm:text-sm font-semibold text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono font-bold text-sm text-primary-container">
|
<span className="font-mono font-bold text-sm sm:text-base text-primary-container">
|
||||||
{incident.case_reference}
|
{incident.case_reference}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchIncidentData()}
|
onClick={() => fetchIncidentData()}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container border border-outline-variant/30 text-xs sm:text-sm font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
||||||
<span>Refresh</span>
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -451,7 +451,7 @@ export function OperationsIncidentDetail({
|
||||||
setModalAction('close');
|
setModalAction('close');
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-surface-container-high border border-outline-variant/40 text-xs font-semibold text-on-surface hover:bg-surface-container-highest transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container-high border border-outline-variant/40 text-xs sm:text-sm font-semibold text-on-surface hover:bg-surface-container-highest transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[16px] text-emerald-400">
|
<span className="material-symbols-outlined text-[16px] text-emerald-400">
|
||||||
task_alt
|
task_alt
|
||||||
|
|
@ -506,11 +506,11 @@ export function OperationsIncidentDetail({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Incident Header Card */}
|
{/* Incident Header Card */}
|
||||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 shadow-sm space-y-4">
|
<div className="bg-surface-container-low p-6 sm:p-8 rounded-2xl border border-outline-variant/30 shadow-sm space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<span className="font-mono text-xs font-bold text-primary-fixed-dim uppercase tracking-wider">
|
<span className="font-mono text-xs sm:text-sm font-bold text-primary-fixed-dim">
|
||||||
{incident.case_reference}
|
{incident.case_reference}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -518,7 +518,7 @@ export function OperationsIncidentDetail({
|
||||||
{getStatusBadge(incident.status)}
|
{getStatusBadge(incident.status)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-headline-sm font-bold text-on-surface">
|
<h1 className="font-headline-xl text-2xl sm:text-3xl font-extrabold text-on-surface tracking-tight mt-1">
|
||||||
{incident.title}
|
{incident.title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -528,41 +528,41 @@ export function OperationsIncidentDetail({
|
||||||
<div className="flex flex-wrap items-center gap-3 bg-surface-container p-3 rounded-xl border border-outline-variant/30 shrink-0">
|
<div className="flex flex-wrap items-center gap-3 bg-surface-container p-3 rounded-xl border border-outline-variant/30 shrink-0">
|
||||||
{/* Severity Selector */}
|
{/* Severity Selector */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="block text-[10px] font-mono uppercase text-outline">
|
<span className="block text-[10px] font-mono text-outline">
|
||||||
Severity
|
Severity
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={incident.severity}
|
value={incident.severity}
|
||||||
disabled={isMutating}
|
disabled={isMutating}
|
||||||
onChange={(e) => handleSeverityChange(e.target.value as IncidentSeverity)}
|
onChange={(e) => handleSeverityChange(e.target.value as IncidentSeverity)}
|
||||||
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-bold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-semibold capitalize text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="critical">CRITICAL</option>
|
<option value="critical">Critical</option>
|
||||||
<option value="high">HIGH</option>
|
<option value="high">High</option>
|
||||||
<option value="medium">MEDIUM</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="low">LOW</option>
|
<option value="low">Low</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Transition Selector */}
|
{/* Status Transition Selector */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="block text-[10px] font-mono uppercase text-outline">
|
<span className="block text-[10px] font-mono text-outline">
|
||||||
Transition Status
|
Transition Status
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={incident.status}
|
value={incident.status}
|
||||||
disabled={isMutating}
|
disabled={isMutating}
|
||||||
onChange={(e) => handleStatusChange(e.target.value as IncidentStatus)}
|
onChange={(e) => handleStatusChange(e.target.value as IncidentStatus)}
|
||||||
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-bold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-semibold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value={incident.status} disabled>
|
<option value={incident.status} disabled>
|
||||||
Current ({incident.status.toUpperCase()})
|
Current ({incident.status.charAt(0).toUpperCase() + incident.status.slice(1)})
|
||||||
</option>
|
</option>
|
||||||
{(['open', 'investigating', 'monitoring', 'resolved', 'closed', 'archived'] as IncidentStatus[])
|
{(['open', 'investigating', 'monitoring', 'resolved', 'closed', 'archived'] as IncidentStatus[])
|
||||||
.filter((s) => isValidStatusTransition(incident.status, s, role))
|
.filter((s) => isValidStatusTransition(incident.status, s, role))
|
||||||
.map((s) => (
|
.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
→ {s.toUpperCase()}
|
→ {s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -574,25 +574,25 @@ export function OperationsIncidentDetail({
|
||||||
{/* Metadata Grid */}
|
{/* Metadata Grid */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-outline-variant/20 text-xs">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-outline-variant/20 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-[10px] font-mono uppercase text-outline">Created By</span>
|
<span className="block text-[10px] font-mono text-outline">Created By</span>
|
||||||
<span className="font-mono text-on-surface-variant break-all">
|
<span className="font-mono text-on-surface-variant break-all">
|
||||||
{incident.created_by_identity}
|
{incident.created_by_identity}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-[10px] font-mono uppercase text-outline">Created At</span>
|
<span className="block text-[10px] font-mono text-outline">Created At</span>
|
||||||
<span className="font-mono text-on-surface-variant">
|
<span className="font-mono text-on-surface-variant">
|
||||||
{formatDate(incident.created_at)}
|
{formatDate(incident.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-[10px] font-mono uppercase text-outline">Last Updated</span>
|
<span className="block text-[10px] font-mono text-outline">Last Updated</span>
|
||||||
<span className="font-mono text-on-surface-variant">
|
<span className="font-mono text-on-surface-variant">
|
||||||
{formatDate(incident.updated_at)}
|
{formatDate(incident.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-[10px] font-mono uppercase text-outline">Closed</span>
|
<span className="block text-[10px] font-mono text-outline">Closed</span>
|
||||||
<span className="font-mono text-on-surface-variant">
|
<span className="font-mono text-on-surface-variant">
|
||||||
{incident.closed_at ? formatDate(incident.closed_at) : '—'}
|
{incident.closed_at ? formatDate(incident.closed_at) : '—'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -602,7 +602,7 @@ export function OperationsIncidentDetail({
|
||||||
|
|
||||||
{/* Summary Card */}
|
{/* Summary Card */}
|
||||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-2">
|
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-2">
|
||||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-outline">
|
<h2 className="text-xs font-headline-sm font-semibold text-outline">
|
||||||
Incident Summary
|
Incident Summary
|
||||||
</h2>
|
</h2>
|
||||||
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed">
|
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed">
|
||||||
|
|
@ -617,7 +617,7 @@ export function OperationsIncidentDetail({
|
||||||
<span className="material-symbols-outlined text-emerald-400 text-[18px]">
|
<span className="material-symbols-outlined text-emerald-400 text-[18px]">
|
||||||
task_alt
|
task_alt
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-emerald-400">
|
<h2 className="text-xs font-headline-sm font-semibold text-emerald-400">
|
||||||
Resolution Record
|
Resolution Record
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -636,7 +636,7 @@ export function OperationsIncidentDetail({
|
||||||
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
||||||
notes
|
notes
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-on-surface">
|
<h2 className="text-xs font-headline-sm font-semibold text-on-surface">
|
||||||
Operator Notes
|
Operator Notes
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -719,7 +719,7 @@ export function OperationsIncidentDetail({
|
||||||
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
||||||
history
|
history
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-on-surface">
|
<h2 className="text-xs font-headline-sm font-semibold text-on-surface">
|
||||||
Operational Timeline
|
Operational Timeline
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -729,11 +729,11 @@ export function OperationsIncidentDetail({
|
||||||
<div className="relative space-y-1">
|
<div className="relative space-y-1">
|
||||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-blue-500 border-2 border-surface-container-low" />
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-blue-500 border-2 border-surface-container-low" />
|
||||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||||
<span className="font-bold text-on-surface uppercase">Incident Opened</span>
|
<span className="font-semibold text-on-surface">Incident Opened</span>
|
||||||
<span>{formatDate(incident.created_at)}</span>
|
<span>{formatDate(incident.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-variant">
|
<p className="text-xs text-on-surface-variant">
|
||||||
Created by <span className="font-mono text-primary-container">{incident.created_by_identity}</span> with initial severity <span className="font-mono uppercase font-semibold">{incident.severity}</span>.
|
Created by <span className="font-mono text-primary-container">{incident.created_by_identity}</span> with initial severity <span className="font-mono capitalize font-medium">{incident.severity}</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -742,7 +742,7 @@ export function OperationsIncidentDetail({
|
||||||
<div className="relative space-y-1">
|
<div className="relative space-y-1">
|
||||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-purple-400 border-2 border-surface-container-low" />
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-purple-400 border-2 border-surface-container-low" />
|
||||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||||
<span className="font-bold text-on-surface uppercase">Operator Notes ({notes.length})</span>
|
<span className="font-semibold text-on-surface">Operator Notes ({notes.length})</span>
|
||||||
<span>{formatDate(notes[0]?.created_at)}</span>
|
<span>{formatDate(notes[0]?.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-variant">
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
|
@ -756,7 +756,7 @@ export function OperationsIncidentDetail({
|
||||||
<div className="relative space-y-1">
|
<div className="relative space-y-1">
|
||||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-emerald-400 border-2 border-surface-container-low" />
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-emerald-400 border-2 border-surface-container-low" />
|
||||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||||
<span className="font-bold text-emerald-400 uppercase">Resolution Documented</span>
|
<span className="font-semibold text-emerald-400">Resolution Documented</span>
|
||||||
<span>{formatDate(incident.updated_at)}</span>
|
<span>{formatDate(incident.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-variant">
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
|
@ -770,7 +770,7 @@ export function OperationsIncidentDetail({
|
||||||
<div className="relative space-y-1">
|
<div className="relative space-y-1">
|
||||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-outline border-2 border-surface-container-low" />
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-outline border-2 border-surface-container-low" />
|
||||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||||
<span className="font-bold text-on-surface uppercase">Incident Closed</span>
|
<span className="font-semibold text-on-surface">Incident Closed</span>
|
||||||
<span>{formatDate(incident.closed_at)}</span>
|
<span>{formatDate(incident.closed_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-variant">
|
<p className="text-xs text-on-surface-variant">
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export function OperationsIncidentModal({
|
||||||
<span className="material-symbols-outlined text-primary-container text-[20px]">
|
<span className="material-symbols-outlined text-primary-container text-[20px]">
|
||||||
{action === 'close' ? 'task_alt' : action === 'reopen' ? 'replay' : 'archive'}
|
{action === 'close' ? 'task_alt' : action === 'reopen' ? 'replay' : 'archive'}
|
||||||
</span>
|
</span>
|
||||||
<h2 id="modal-title" className="text-base font-headline-sm font-bold text-on-surface uppercase">
|
<h2 id="modal-title" className="text-base font-headline-sm font-bold text-on-surface">
|
||||||
{titles[action]}
|
{titles[action]}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -101,27 +101,27 @@ export function OperationsIncidentsList({
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-error/15 text-error border border-error/30">
|
||||||
CRITICAL
|
Critical
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'high':
|
case 'high':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
HIGH
|
High
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'medium':
|
case 'medium':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-primary-container/15 text-primary-container border border-primary-container/30">
|
||||||
MEDIUM
|
Medium
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'low':
|
case 'low':
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
||||||
LOW
|
Low
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -131,39 +131,39 @@ export function OperationsIncidentsList({
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'open':
|
case 'open':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
||||||
OPEN
|
Open
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'investigating':
|
case 'investigating':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||||
INVESTIGATING
|
Investigating
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'monitoring':
|
case 'monitoring':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||||
MONITORING
|
Monitoring
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'resolved':
|
case 'resolved':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||||
RESOLVED
|
Resolved
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'closed':
|
case 'closed':
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-surface-container-high text-outline border border-outline-variant/30">
|
||||||
CLOSED
|
Closed
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'archived':
|
case 'archived':
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-medium bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
||||||
ARCHIVED
|
Archived
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -188,48 +188,51 @@ export function OperationsIncidentsList({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-xs font-mono text-primary-fixed-dim font-semibold">
|
||||||
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
|
Reliability & Incident Management
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight">
|
||||||
Operational Incidents
|
Operational Incidents
|
||||||
</h1>
|
</h1>
|
||||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase tracking-wider bg-primary-container/10 text-primary-container border border-primary-container/20">
|
<span className="px-2.5 py-1 rounded-full text-xs font-mono font-semibold bg-primary-container/10 text-primary-container border border-primary-container/30">
|
||||||
OPS-4
|
OPS-4
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-on-surface-variant mt-0.5">
|
<p className="text-xs sm:text-sm text-on-surface-variant mt-1.5">
|
||||||
Isolated reliability & incident tracking with immutable case references.
|
Isolated reliability & incident tracking with immutable case references.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchIncidents()}
|
onClick={() => fetchIncidents()}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container-high hover:bg-surface-bright text-primary-container border border-primary-container/40 text-xs sm:text-sm font-semibold shadow-sm transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
title="Refresh incidents list"
|
title="Refresh incidents list"
|
||||||
>
|
>
|
||||||
<span className={`material-symbols-outlined text-[16px] ${isLoading ? 'animate-spin' : ''}`}>
|
<span className={`material-symbols-outlined text-[18px] ${isLoading ? 'animate-spin' : ''}`}>
|
||||||
refresh
|
refresh
|
||||||
</span>
|
</span>
|
||||||
<span>Refresh</span>
|
<span>{isLoading ? 'Checking...' : 'Refresh'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCreateIncident}
|
onClick={onCreateIncident}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-primary-container text-on-primary-container text-xs sm:text-sm font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[16px]">add_circle</span>
|
<span className="material-symbols-outlined text-[18px]">add_circle</span>
|
||||||
<span>New Incident</span>
|
<span>New Incident</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Bar */}
|
{/* Filter Bar */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-surface-container-low p-3.5 rounded-xl border border-outline-variant/20">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-surface-container-low p-4 sm:p-5 rounded-2xl border border-outline-variant/30 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
|
@ -339,16 +342,16 @@ export function OperationsIncidentsList({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Table View (hidden on small screens) */}
|
{/* Desktop Table View (hidden on small screens) */}
|
||||||
<div className="hidden md:block overflow-hidden rounded-xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
|
<div className="hidden md:block overflow-hidden rounded-2xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs sm:text-sm border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-outline-variant/20 bg-surface-container-low text-[11px] font-mono text-outline uppercase tracking-wider">
|
<tr className="border-b border-outline-variant/20 bg-surface-container-low text-xs font-mono text-outline">
|
||||||
<th className="py-3 px-4 font-semibold">CASE</th>
|
<th data-column="CASE" className="py-4 px-5 sm:px-6 font-semibold">Case</th>
|
||||||
<th className="py-3 px-4 font-semibold">TITLE</th>
|
<th data-column="TITLE" className="py-4 px-5 sm:px-6 font-semibold">Title</th>
|
||||||
<th className="py-3 px-4 font-semibold">SEVERITY</th>
|
<th data-column="SEVERITY" className="py-4 px-5 sm:px-6 font-semibold">Severity</th>
|
||||||
<th className="py-3 px-4 font-semibold">STATUS</th>
|
<th data-column="STATUS" className="py-4 px-5 sm:px-6 font-semibold">Status</th>
|
||||||
<th className="py-3 px-4 font-semibold">UPDATED</th>
|
<th data-column="UPDATED" className="py-4 px-5 sm:px-6 font-semibold">Updated</th>
|
||||||
<th className="py-3 px-4 font-semibold">CREATED</th>
|
<th data-column="CREATED" className="py-4 px-5 sm:px-6 font-semibold">Created</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-outline-variant/10 text-on-surface">
|
<tbody className="divide-y divide-outline-variant/10 text-on-surface">
|
||||||
|
|
@ -365,18 +368,18 @@ export function OperationsIncidentsList({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="py-3 px-4 font-mono font-bold text-primary-container group-hover:underline">
|
<td className="py-4 px-5 sm:px-6 font-mono text-xs sm:text-sm font-bold text-primary-container group-hover:underline">
|
||||||
{incident.case_reference}
|
{incident.case_reference}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 font-medium text-on-surface max-w-xs truncate">
|
<td className="py-4 px-5 sm:px-6 font-medium text-sm text-on-surface max-w-xl truncate">
|
||||||
{incident.title}
|
{incident.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">{getSeverityBadge(incident.severity)}</td>
|
<td className="py-4 px-5 sm:px-6">{getSeverityBadge(incident.severity)}</td>
|
||||||
<td className="py-3 px-4">{getStatusBadge(incident.status)}</td>
|
<td className="py-4 px-5 sm:px-6">{getStatusBadge(incident.status)}</td>
|
||||||
<td className="py-3 px-4 text-[11px] font-mono text-on-surface-variant">
|
<td className="py-4 px-5 sm:px-6 text-xs sm:text-sm font-mono text-on-surface-variant">
|
||||||
{formatDate(incident.updated_at)}
|
{formatDate(incident.updated_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-[11px] font-mono text-outline">
|
<td className="py-4 px-5 sm:px-6 text-xs sm:text-sm font-mono text-outline">
|
||||||
{formatDate(incident.created_at)}
|
{formatDate(incident.created_at)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
|
@ -7,40 +7,40 @@ export function OperationsOperators() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className="pb-4 border-b border-outline-variant/30">
|
<div className="pb-4 border-b border-outline-variant/30">
|
||||||
<span className="text-[11px] font-mono uppercase tracking-widest text-primary-fixed-dim">
|
<span className="text-[11px] font-mono text-primary-fixed-dim block font-semibold">
|
||||||
ACCESS CONTROL
|
Access Control
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-headline-xl text-2xl sm:text-3xl font-bold text-on-surface tracking-tight mt-1">
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1">
|
||||||
OPERATORS
|
Operators
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-on-surface-variant mt-1">
|
<p className="text-xs sm:text-sm text-outline mt-1.5 max-w-2xl">
|
||||||
Directory and role assignments for platform administrative operators.
|
Directory and role assignments for platform administrative operators.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clean Placeholder Panel */}
|
{/* Clean Placeholder Panel */}
|
||||||
<div className="p-8 rounded-xl bg-surface-container-low border border-outline-variant/30 max-w-2xl mx-auto my-8 text-center space-y-4 shadow-sm">
|
<div className="p-8 sm:p-12 rounded-2xl bg-surface-container-low border border-outline-variant/30 max-w-4xl mx-auto my-10 text-center space-y-6 shadow-sm">
|
||||||
<div className="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant/40 flex items-center justify-center text-outline mx-auto">
|
<div className="w-16 h-16 rounded-2xl bg-surface-container-high border border-outline-variant/40 flex items-center justify-center text-outline mx-auto shadow-inner">
|
||||||
<span className="material-symbols-outlined text-[24px]">manage_accounts</span>
|
<span className="material-symbols-outlined text-[32px]">manage_accounts</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-base font-bold text-on-surface uppercase tracking-wide">
|
<h2 className="text-lg sm:text-xl font-bold text-on-surface tracking-tight">
|
||||||
OPERATORS
|
Operators
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-on-surface-variant max-w-md mx-auto leading-relaxed">
|
<p className="text-xs sm:text-sm text-on-surface-variant max-w-lg mx-auto leading-relaxed">
|
||||||
Operator management is not enabled in this Operations foundation.
|
Operator management is not enabled in this Operations foundation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-outline-variant/20 text-[11px] text-outline text-left space-y-2 bg-surface-container-lowest/50 p-4 rounded-lg">
|
<div className="pt-4 border-t border-outline-variant/20 text-xs text-outline text-left space-y-2.5 bg-surface-container-lowest/50 p-5 sm:p-6 rounded-xl">
|
||||||
<div className="flex items-center gap-1.5 font-mono text-on-surface font-semibold uppercase text-[10px]">
|
<div className="flex items-center gap-2 font-mono text-on-surface font-semibold text-xs">
|
||||||
<span className="material-symbols-outlined text-[14px] text-primary-fixed-dim">
|
<span className="material-symbols-outlined text-[16px] text-primary-fixed-dim">
|
||||||
info
|
info
|
||||||
</span>
|
</span>
|
||||||
<span>Security Governance Policy</span>
|
<span>Security Governance Policy</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="leading-normal">
|
<p className="leading-relaxed">
|
||||||
Operator accounts, role promotions, and revocations are managed strictly through versioned PostgreSQL infrastructure migrations on the <code className="text-primary-fixed-dim font-mono">ops.operators</code> table.
|
Operator accounts, role promotions, and revocations are managed strictly through versioned PostgreSQL infrastructure migrations on the <code className="text-primary-fixed-dim font-mono">ops.operators</code> table.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OperationsHealth, ComponentHealthStatus, HealthComponentKey } from './types';
|
import {
|
||||||
|
OperationsHealth,
|
||||||
|
ComponentHealthStatus,
|
||||||
|
HealthComponentKey,
|
||||||
|
OperationsOverviewData,
|
||||||
|
OperationsNavId,
|
||||||
|
} from './types';
|
||||||
import { HEALTH_COMPONENT_METADATA } from '../../lib/ops-data';
|
import { HEALTH_COMPONENT_METADATA } from '../../lib/ops-data';
|
||||||
|
|
||||||
interface OperationsOverviewProps {
|
interface OperationsOverviewProps {
|
||||||
|
overview?: OperationsOverviewData | null;
|
||||||
health: OperationsHealth;
|
health: OperationsHealth;
|
||||||
onRefresh: () => Promise<void> | void;
|
onRefresh: () => Promise<void> | void;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
|
onSelectIncident?: (caseRef: string) => void;
|
||||||
|
onNavigateNav?: (nav: OperationsNavId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OperationsOverview({
|
export function OperationsOverview({
|
||||||
|
overview,
|
||||||
health,
|
health,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
onSelectIncident,
|
||||||
|
onNavigateNav,
|
||||||
}: OperationsOverviewProps) {
|
}: OperationsOverviewProps) {
|
||||||
const componentKeys: HealthComponentKey[] = [
|
const componentKeys: HealthComponentKey[] = [
|
||||||
'operations_api',
|
'operations_api',
|
||||||
|
|
@ -22,38 +34,70 @@ export function OperationsOverview({
|
||||||
'audit_persistence',
|
'audit_persistence',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const effectiveHealth = overview?.health ?? health;
|
||||||
|
|
||||||
function getStatusBadge(status: ComponentHealthStatus) {
|
function getStatusBadge(status: ComponentHealthStatus) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'available':
|
case 'available':
|
||||||
return {
|
return {
|
||||||
label: 'AVAILABLE',
|
label: 'Available',
|
||||||
icon: 'check_circle',
|
icon: 'check_circle',
|
||||||
classes: 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60',
|
classes: 'bg-emerald-950/60 text-emerald-400 border-emerald-800/60',
|
||||||
};
|
};
|
||||||
case 'degraded':
|
case 'degraded':
|
||||||
return {
|
return {
|
||||||
label: 'DEGRADED',
|
label: 'Degraded',
|
||||||
icon: 'warning',
|
icon: 'warning',
|
||||||
classes: 'bg-amber-950/60 text-amber-400 border-amber-800/60',
|
classes: 'bg-amber-950/60 text-amber-400 border-amber-800/60',
|
||||||
};
|
};
|
||||||
case 'unavailable':
|
case 'unavailable':
|
||||||
return {
|
return {
|
||||||
label: 'UNAVAILABLE',
|
label: 'Unavailable',
|
||||||
icon: 'cancel',
|
icon: 'cancel',
|
||||||
classes: 'bg-rose-950/60 text-rose-400 border-rose-800/60',
|
classes: 'bg-rose-950/60 text-rose-400 border-rose-800/60',
|
||||||
};
|
};
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: 'UNKNOWN',
|
label: 'Unknown',
|
||||||
icon: 'help',
|
icon: 'help',
|
||||||
classes: 'bg-surface-container-highest text-on-surface-variant border-outline-variant/40',
|
classes: 'bg-surface-container-highest text-on-surface-variant border-outline-variant/40',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedCheckedAt = health.checked_at
|
function getSeverityBadge(severity: string) {
|
||||||
? new Date(health.checked_at).toLocaleString('en-US', {
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-rose-950/60 text-rose-400 border border-rose-800/60';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-amber-950/60 text-amber-400 border border-amber-800/60';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-950/60 text-yellow-400 border border-yellow-800/60';
|
||||||
|
case 'low':
|
||||||
|
default:
|
||||||
|
return 'bg-blue-950/60 text-blue-400 border border-blue-800/60';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'open':
|
||||||
|
return 'bg-rose-950/60 text-rose-300 border border-rose-800/60';
|
||||||
|
case 'investigating':
|
||||||
|
return 'bg-amber-950/60 text-amber-300 border border-amber-800/60';
|
||||||
|
case 'monitoring':
|
||||||
|
return 'bg-cyan-950/60 text-cyan-300 border border-cyan-800/60';
|
||||||
|
case 'resolved':
|
||||||
|
case 'closed':
|
||||||
|
default:
|
||||||
|
return 'bg-emerald-950/60 text-emerald-300 border border-emerald-800/60';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveCheckedAt = effectiveHealth.checked_at;
|
||||||
|
const formattedCheckedAt = effectiveCheckedAt
|
||||||
|
? new Date(effectiveCheckedAt).toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|
@ -65,29 +109,29 @@ export function OperationsOverview({
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 w-full max-w-none">
|
||||||
{/* Header Row: Title & Manual Refresh */}
|
{/* Header Row: Title & Manual Refresh */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] font-mono uppercase tracking-widest text-primary-fixed-dim">
|
<span className="text-xs font-mono text-primary-fixed-dim font-semibold">
|
||||||
SYSTEM HEALTH
|
System Health & Operations
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-headline-xl text-2xl sm:text-3xl font-bold text-on-surface tracking-tight mt-1">
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1.5">
|
||||||
Operations Overview
|
System Health & Operations Overview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-on-surface-variant mt-1">
|
<p className="text-xs sm:text-sm text-on-surface-variant mt-1.5">
|
||||||
Real-time health telemetry across core Operations subsystems.
|
Unified telemetry, platform activity, and operational security triage.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="block text-[10px] uppercase font-mono text-outline">
|
<span className="block text-[11px] font-mono text-outline font-semibold">
|
||||||
Last checked
|
Last checked
|
||||||
</span>
|
</span>
|
||||||
<time
|
<time
|
||||||
dateTime={health.checked_at}
|
dateTime={effectiveCheckedAt}
|
||||||
className="text-xs font-mono text-on-surface font-medium"
|
className="text-xs sm:text-sm font-mono text-on-surface font-semibold"
|
||||||
>
|
>
|
||||||
{formattedCheckedAt}
|
{formattedCheckedAt}
|
||||||
</time>
|
</time>
|
||||||
|
|
@ -98,10 +142,10 @@ export function OperationsOverview({
|
||||||
onClick={() => void onRefresh()}
|
onClick={() => void onRefresh()}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
aria-label="Refresh Operations health status"
|
aria-label="Refresh Operations health status"
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-surface-container-high hover:bg-surface-bright text-primary-container border border-primary-container/40 text-xs font-semibold shadow-sm transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container-high hover:bg-surface-bright text-primary-container border border-primary-container/40 text-xs sm:text-sm font-semibold shadow-sm transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-outlined text-[16px] ${
|
className={`material-symbols-outlined text-[18px] ${
|
||||||
isRefreshing ? 'animate-spin' : ''
|
isRefreshing ? 'animate-spin' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -112,44 +156,283 @@ export function OperationsOverview({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4 Health Status Cards */}
|
{/* 1. KPI Row (4 Cards) */}
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"
|
||||||
|
aria-label="Operations Key Metrics"
|
||||||
|
>
|
||||||
|
{/* Card 1: Registered Users */}
|
||||||
|
<article className="p-5 sm:p-6 rounded-2xl bg-surface-container-low border border-outline-variant/30 flex flex-col justify-between min-h-[140px] relative overflow-hidden group hover:border-outline-variant/60 transition-all shadow-sm">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-xs font-mono text-outline font-semibold">
|
||||||
|
Registered Users
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-outline">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
group
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl sm:text-4xl lg:text-5xl font-extrabold font-mono text-on-surface tracking-tight my-1.5">
|
||||||
|
{overview ? overview.users.total_registered.toLocaleString() : '--'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-emerald-400 font-mono font-medium">
|
||||||
|
<span className="material-symbols-outlined text-[16px]">trending_up</span>
|
||||||
|
<span>+{overview ? overview.users.new_this_week.toLocaleString() : 0} this week</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Card 2: Active Users 24h / 7d */}
|
||||||
|
<article className="p-5 sm:p-6 rounded-2xl bg-surface-container-low border border-outline-variant/30 flex flex-col justify-between min-h-[140px] relative overflow-hidden group hover:border-outline-variant/60 transition-all shadow-sm">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-xs font-mono text-outline font-semibold">
|
||||||
|
Active Users (24h)
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-outline">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
bolt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl sm:text-4xl lg:text-5xl font-extrabold font-mono text-on-surface tracking-tight my-1.5">
|
||||||
|
{overview ? overview.users.active_24h.toLocaleString() : '--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-on-surface-variant font-mono">
|
||||||
|
{overview ? overview.users.active_7d.toLocaleString() : '--'} active in 7 days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Card 3: Premium Members / Conversion Rate */}
|
||||||
|
<article className="p-5 sm:p-6 rounded-2xl bg-surface-container-low border border-outline-variant/30 flex flex-col justify-between min-h-[140px] relative overflow-hidden group hover:border-outline-variant/60 transition-all shadow-sm">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-xs font-mono text-outline font-semibold">
|
||||||
|
Premium Members
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-amber-400">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
workspace_premium
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl sm:text-4xl lg:text-5xl font-extrabold font-mono text-on-surface tracking-tight my-1.5">
|
||||||
|
{overview ? overview.membership.premium_members.toLocaleString() : '--'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-on-surface-variant font-mono">
|
||||||
|
{overview ? overview.membership.conversion_rate_pct : 0}% conv. ({overview ? overview.membership.free_members.toLocaleString() : 0} free)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Card 4: Open Incidents */}
|
||||||
|
<article className="p-5 sm:p-6 rounded-2xl bg-surface-container-low border border-outline-variant/30 flex flex-col justify-between min-h-[140px] relative overflow-hidden group hover:border-outline-variant/60 transition-all shadow-sm">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span className="text-xs font-mono text-outline font-semibold">
|
||||||
|
Open Incidents
|
||||||
|
</span>
|
||||||
|
<div className={`w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center ${
|
||||||
|
(overview?.incidents.critical_count ?? 0) > 0
|
||||||
|
? 'text-rose-400'
|
||||||
|
: (overview?.incidents.total_open ?? 0) > 0
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-emerald-400'
|
||||||
|
}`}>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
{(overview?.incidents.total_open ?? 0) > 0 ? 'warning' : 'check_circle'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`text-3xl sm:text-4xl lg:text-5xl font-extrabold font-mono tracking-tight my-1.5 ${
|
||||||
|
(overview?.incidents.critical_count ?? 0) > 0
|
||||||
|
? 'text-rose-400'
|
||||||
|
: (overview?.incidents.total_open ?? 0) > 0
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-on-surface'
|
||||||
|
}`}>
|
||||||
|
{overview ? overview.incidents.total_open : 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-on-surface-variant font-mono">
|
||||||
|
{overview ? overview.incidents.critical_count : 0} critical, {overview ? overview.incidents.high_count : 0} high
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Platform Activity Split */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5" aria-label="Platform Activity">
|
||||||
|
{/* Website Activity */}
|
||||||
|
<section className="p-6 sm:p-7 rounded-2xl bg-surface-container-low border border-outline-variant/30 space-y-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-outline-variant/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-primary-fixed-dim">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
language
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-on-surface tracking-tight">
|
||||||
|
Website Platform Activity
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-on-surface-variant">
|
||||||
|
Portal logins, browser sessions, and web registrations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono px-2.5 py-1 rounded-md bg-surface-container-highest text-on-surface-variant font-semibold">
|
||||||
|
Web Ingress
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold">
|
||||||
|
24h Sessions
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
|
||||||
|
{overview ? overview.activity.web_active_sessions_24h.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold">
|
||||||
|
24h Logins
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
|
||||||
|
{overview ? overview.activity.web_logins_24h.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold">
|
||||||
|
7d Signups
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
|
||||||
|
{overview ? overview.activity.web_signups_7d.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Mobile Telemetry Activity */}
|
||||||
|
<section className="p-6 sm:p-7 rounded-2xl bg-surface-container-low border border-outline-variant/30 space-y-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-outline-variant/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-primary-fixed-dim">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
two_wheeler
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-on-surface tracking-tight">
|
||||||
|
Mobile Telemetry Activity
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-on-surface-variant">
|
||||||
|
Mobile ride recording, active sessions, and sensor data ingestion
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono px-2.5 py-1 rounded-md bg-surface-container-highest text-on-surface-variant font-semibold">
|
||||||
|
Mobile Ingress
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
|
||||||
|
24h Sessions
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
|
||||||
|
{overview ? overview.activity.mobile_active_sessions_24h.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
|
||||||
|
Rides Today
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
|
||||||
|
{overview ? overview.activity.mobile_rides_today.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
|
||||||
|
Active Rides
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-emerald-400">
|
||||||
|
{overview ? overview.activity.mobile_rides_active.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col justify-between">
|
||||||
|
<span className="block text-xs font-mono text-outline mb-1 font-semibold whitespace-nowrap">
|
||||||
|
Data Points
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-on-surface">
|
||||||
|
{overview ? overview.activity.mobile_datapoints_24h.toLocaleString() : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Subsystem Health Telemetry (4 Cards) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xs font-semibold text-outline font-mono">
|
||||||
|
Core Subsystem Health
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-xs font-mono font-semibold border ${
|
||||||
|
getStatusBadge(effectiveHealth.status).classes
|
||||||
|
}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">
|
||||||
|
{getStatusBadge(effectiveHealth.status).icon}
|
||||||
|
</span>
|
||||||
|
<span>System {getStatusBadge(effectiveHealth.status).label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"
|
||||||
aria-label="Subsystem Health Telemetry"
|
aria-label="Subsystem Health Telemetry"
|
||||||
>
|
>
|
||||||
{componentKeys.map((key) => {
|
{componentKeys.map((key) => {
|
||||||
const meta = HEALTH_COMPONENT_METADATA[key];
|
const meta = HEALTH_COMPONENT_METADATA[key];
|
||||||
const comp = health.components?.[key];
|
const comp = effectiveHealth.components?.[key];
|
||||||
const status = comp?.status ?? 'unknown';
|
const status = comp?.status ?? 'unknown';
|
||||||
const badge = getStatusBadge(status);
|
const badge = getStatusBadge(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={key}
|
key={key}
|
||||||
className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/30 flex flex-col justify-between gap-3 relative overflow-hidden group hover:border-outline-variant/60 transition-colors"
|
className="p-5 sm:p-6 rounded-2xl bg-surface-container-low border border-outline-variant/30 flex flex-col justify-between min-h-[160px] relative overflow-hidden group hover:border-outline-variant/60 transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-xs font-mono text-outline font-semibold">
|
||||||
Subsystem
|
Subsystem
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-sm font-bold text-on-surface tracking-tight">
|
<h3 className="text-base font-bold text-on-surface tracking-tight">
|
||||||
{meta.name}
|
{meta.name}
|
||||||
</h2>
|
</h3>
|
||||||
<p className="text-[11px] text-on-surface-variant leading-relaxed">
|
<p className="text-xs sm:text-sm text-on-surface-variant leading-relaxed">
|
||||||
{comp?.description || meta.description}
|
{comp?.description || meta.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-outline-variant/20 flex items-center justify-between">
|
<div className="pt-3 border-t border-outline-variant/20 flex items-center justify-between mt-3">
|
||||||
<span className="text-[10px] text-outline font-mono uppercase">
|
<span className="text-xs text-outline font-mono font-medium">
|
||||||
Status
|
Status
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono font-bold tracking-wider border uppercase ${badge.classes}`}
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-mono font-semibold border ${badge.classes}`}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[12px]">
|
<span className="material-symbols-outlined text-[14px]">
|
||||||
{badge.icon}
|
{badge.icon}
|
||||||
</span>
|
</span>
|
||||||
<span>{badge.label}</span>
|
<span>{badge.label}</span>
|
||||||
|
|
@ -159,40 +442,253 @@ export function OperationsOverview({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Operational Policy & Architecture Boundary */}
|
{/* 4. Recent Security Errors / Denials Stream */}
|
||||||
<div className="p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/30 space-y-3">
|
<section className="p-6 sm:p-7 rounded-2xl bg-surface-container-low border border-outline-variant/30 space-y-5 shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-outline-variant/20">
|
||||||
<span className="material-symbols-outlined text-primary-container text-[18px]">
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-rose-400">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
security
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-on-surface tracking-tight">
|
||||||
|
Security Error & Denial Stream
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-on-surface-variant">
|
||||||
|
Live monitoring of unauthorized attempts and subsystem operation failures
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="text-xs font-mono px-3 py-1 rounded-md bg-rose-950/60 text-rose-400 border border-rose-800/60 font-bold">
|
||||||
|
{overview?.errors.denials_24h ?? 0} Denials (24h)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono px-3 py-1 rounded-md bg-amber-950/60 text-amber-400 border border-amber-800/60 font-bold">
|
||||||
|
{overview?.errors.errors_24h ?? 0} Errors (24h)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overview?.errors.recent && overview.errors.recent.length > 0 ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{overview.errors.recent.map((err, idx) => (
|
||||||
|
<div
|
||||||
|
key={err.id ?? idx}
|
||||||
|
className="p-4 rounded-xl bg-surface-container-lowest border border-outline-variant/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-md text-xs font-mono font-semibold capitalize ${
|
||||||
|
err.result === 'denied'
|
||||||
|
? 'bg-rose-950/60 text-rose-400 border border-rose-800/60'
|
||||||
|
: 'bg-amber-950/60 text-amber-400 border border-amber-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{err.result}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-bold text-on-surface">
|
||||||
|
{err.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-on-surface-variant font-mono text-xs">
|
||||||
|
by {err.actor_identity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs font-mono text-outline shrink-0">
|
||||||
|
{err.target_resource && (
|
||||||
|
<span className="truncate max-w-[200px]">
|
||||||
|
res: {err.target_resource}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{err.reason_code && (
|
||||||
|
<span className="truncate max-w-[160px] text-on-surface-variant">
|
||||||
|
({err.reason_code})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<time dateTime={err.timestamp} className="font-semibold text-on-surface-variant">
|
||||||
|
{new Date(err.timestamp).toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 rounded-xl bg-surface-container-lowest border border-outline-variant/20 text-center text-xs sm:text-sm text-on-surface-variant">
|
||||||
|
Zero security errors or access denials recorded in recent event window.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 5. Open Incidents Triage */}
|
||||||
|
<section className="p-6 sm:p-7 rounded-2xl bg-surface-container-low border border-outline-variant/30 space-y-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-outline-variant/20">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-amber-400">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
|
crisis_alert
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-on-surface tracking-tight">
|
||||||
|
Open Incidents Triage
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-on-surface-variant">
|
||||||
|
Active incidents requiring operator assessment or active investigation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onNavigateNav && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigateNav('incidents')}
|
||||||
|
className="text-xs sm:text-sm font-mono text-primary-fixed-dim hover:text-primary-container flex items-center gap-1.5 transition-colors font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>View All</span>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overview?.incidents.recent_open && overview.incidents.recent_open.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-outline-variant/20">
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-xs sm:text-sm">
|
||||||
|
<thead className="bg-surface-container text-xs font-mono text-outline border-b border-outline-variant/20">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-5 py-3.5 font-semibold">Case</th>
|
||||||
|
<th scope="col" className="px-5 py-3.5 font-semibold">Title</th>
|
||||||
|
<th scope="col" className="px-5 py-3.5 font-semibold">Severity</th>
|
||||||
|
<th scope="col" className="px-5 py-3.5 font-semibold">Status</th>
|
||||||
|
<th scope="col" className="px-5 py-3.5 font-semibold text-right">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-outline-variant/10 bg-surface-container-lowest font-mono">
|
||||||
|
{overview.incidents.recent_open.map((inc) => (
|
||||||
|
<tr
|
||||||
|
key={inc.case_reference}
|
||||||
|
onClick={() => onSelectIncident?.(inc.case_reference)}
|
||||||
|
className={`hover:bg-surface-bright/40 transition-colors ${
|
||||||
|
onSelectIncident ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-4 font-bold text-primary-fixed-dim whitespace-nowrap">
|
||||||
|
{inc.case_reference}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 font-sans text-on-surface font-semibold max-w-sm truncate">
|
||||||
|
{inc.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold capitalize ${getSeverityBadge(inc.severity)}`}>
|
||||||
|
{inc.severity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold capitalize ${getStatusBadgeClass(inc.status)}`}>
|
||||||
|
{inc.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right text-outline whitespace-nowrap text-xs">
|
||||||
|
{new Date(inc.created_at).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Stacked Cards */}
|
||||||
|
<div className="md:hidden divide-y divide-outline-variant/15 bg-surface-container-lowest">
|
||||||
|
{overview.incidents.recent_open.map((inc) => (
|
||||||
|
<div
|
||||||
|
key={inc.case_reference}
|
||||||
|
onClick={() => onSelectIncident?.(inc.case_reference)}
|
||||||
|
className={`p-4 space-y-2.5 ${onSelectIncident ? 'cursor-pointer active:bg-surface-bright/40' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-mono text-xs font-bold text-primary-fixed-dim">
|
||||||
|
{inc.case_reference}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-mono font-semibold capitalize ${getSeverityBadge(inc.severity)}`}>
|
||||||
|
{inc.severity}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-mono font-semibold capitalize ${getStatusBadgeClass(inc.status)}`}>
|
||||||
|
{inc.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-on-surface font-semibold line-clamp-1">
|
||||||
|
{inc.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-outline">
|
||||||
|
Created {new Date(inc.created_at).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 rounded-xl bg-surface-container-lowest border border-outline-variant/20 text-center text-xs sm:text-sm text-on-surface-variant">
|
||||||
|
All operational systems normal. Zero open or active triage incidents.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 6. Operational Policy & Architecture Boundary */}
|
||||||
|
<div className="p-6 sm:p-7 rounded-2xl bg-surface-container-lowest border border-outline-variant/30 space-y-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-primary-container">
|
||||||
|
<span className="material-symbols-outlined text-[20px]">
|
||||||
verified_user
|
verified_user
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xs font-bold text-on-surface uppercase tracking-wider">
|
</div>
|
||||||
|
<h3 className="text-sm sm:text-base font-bold text-on-surface tracking-tight">
|
||||||
Operational Architecture & Data Boundary
|
Operational Architecture & Data Boundary
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-on-surface-variant pt-2 border-t border-outline-variant/20">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-xs sm:text-sm text-on-surface-variant pt-4 border-t border-outline-variant/20">
|
||||||
<div>
|
<div>
|
||||||
<span className="block font-semibold text-on-surface mb-0.5">
|
<span className="block font-semibold text-on-surface mb-1">
|
||||||
Authority Source
|
Authority Source
|
||||||
</span>
|
</span>
|
||||||
<p className="leading-normal">
|
<p className="leading-relaxed">
|
||||||
PostgreSQL <code className="text-primary-fixed-dim">ops.operators</code> schema. Consumer roles and Firebase custom claims strictly bypassed.
|
PostgreSQL <code className="text-primary-fixed-dim">ops.operators</code> schema. Consumer roles and Firebase custom claims strictly bypassed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block font-semibold text-on-surface mb-0.5">
|
<span className="block font-semibold text-on-surface mb-1">
|
||||||
Privacy Isolation
|
Privacy Isolation
|
||||||
</span>
|
</span>
|
||||||
<p className="leading-normal">
|
<p className="leading-relaxed">
|
||||||
Zero cross-rider data access. Personal rides, GPS points, vehicles, and billing accounts are structurally inaccessible.
|
Zero cross-rider data access. Personal rides, GPS points, vehicles, and billing accounts are structurally inaccessible.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block font-semibold text-on-surface mb-0.5">
|
<span className="block font-semibold text-on-surface mb-1">
|
||||||
Session Integrity
|
Session Integrity
|
||||||
</span>
|
</span>
|
||||||
<p className="leading-normal">
|
<p className="leading-relaxed">
|
||||||
Governed by host-prefixed <code className="text-primary-fixed-dim">__Host-motorsignal_ops</code> cookies with strict same-origin isolation.
|
Governed by host-prefixed <code className="text-primary-fixed-dim">__Host-motorsignal_ops</code> cookies with strict same-origin isolation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OperatorSession } from './types';
|
import { OperatorSession } from './types';
|
||||||
|
|
@ -30,26 +30,26 @@ export function OperationsSessionView({
|
||||||
const isOwner = session.role === 'owner';
|
const isOwner = session.role === 'owner';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 w-full max-w-5xl">
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className="pb-4 border-b border-outline-variant/30">
|
<div className="pb-4 border-b border-outline-variant/30">
|
||||||
<span className="text-[11px] font-mono uppercase tracking-widest text-primary-fixed-dim">
|
<span className="text-[11px] font-mono text-primary-fixed-dim block font-semibold">
|
||||||
SESSION CREDENTIALS
|
Session Credentials
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-headline-xl text-2xl sm:text-3xl font-bold text-on-surface tracking-tight mt-1">
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1">
|
||||||
My Session
|
My Session
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-on-surface-variant mt-1">
|
<p className="text-xs sm:text-sm text-outline mt-1.5 max-w-2xl">
|
||||||
Cryptographic authority and active authentication status for this browser session.
|
Cryptographic authority and active authentication status for this browser session.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Session Details Card */}
|
{/* Session Details Card */}
|
||||||
<div className="p-6 rounded-xl bg-surface-container-low border border-outline-variant/30 space-y-6 shadow-sm">
|
<div className="p-6 sm:p-8 rounded-2xl bg-surface-container-low border border-outline-variant/30 space-y-6 shadow-sm">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{/* Verified Identity */}
|
{/* Verified Identity */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Verified Identity
|
Verified Identity
|
||||||
</span>
|
</span>
|
||||||
<div className="text-sm font-semibold text-on-surface break-all">
|
<div className="text-sm font-semibold text-on-surface break-all">
|
||||||
|
|
@ -62,12 +62,12 @@ export function OperationsSessionView({
|
||||||
|
|
||||||
{/* Operations Role */}
|
{/* Operations Role */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Operations Role
|
Operations Role
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-bold uppercase tracking-wider ${
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold capitalize ${
|
||||||
isOwner
|
isOwner
|
||||||
? 'bg-primary-container/15 text-primary-container border border-primary-container/40'
|
? 'bg-primary-container/15 text-primary-container border border-primary-container/40'
|
||||||
: 'bg-surface-container-high text-on-surface font-semibold border border-outline-variant/40'
|
: 'bg-surface-container-high text-on-surface font-semibold border border-outline-variant/40'
|
||||||
|
|
@ -83,12 +83,12 @@ export function OperationsSessionView({
|
||||||
|
|
||||||
{/* Session Status */}
|
{/* Session Status */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Session Status
|
Session Status
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 text-xs font-mono font-semibold text-secondary-fixed">
|
<div className="flex items-center gap-1.5 text-xs font-mono font-semibold text-secondary-fixed">
|
||||||
<span className="h-2 w-2 rounded-full bg-secondary-fixed"></span>
|
<span className="h-2 w-2 rounded-full bg-secondary-fixed"></span>
|
||||||
<span className="uppercase">{session.status}</span>
|
<span className="capitalize">{session.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-on-surface-variant font-mono">
|
<span className="text-[10px] text-on-surface-variant font-mono">
|
||||||
Validated on-demand
|
Validated on-demand
|
||||||
|
|
@ -97,10 +97,10 @@ export function OperationsSessionView({
|
||||||
|
|
||||||
{/* Environment */}
|
{/* Environment */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Runtime Environment
|
Runtime Environment
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs font-mono font-semibold text-on-surface uppercase">
|
<div className="text-xs font-mono font-semibold text-on-surface capitalize">
|
||||||
{session.environment ?? 'production'}
|
{session.environment ?? 'production'}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-on-surface-variant font-mono">
|
<span className="text-[10px] text-on-surface-variant font-mono">
|
||||||
|
|
@ -110,7 +110,7 @@ export function OperationsSessionView({
|
||||||
|
|
||||||
{/* Created Timestamp */}
|
{/* Created Timestamp */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Session Created
|
Session Created
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs font-mono text-on-surface">
|
<div className="text-xs font-mono text-on-surface">
|
||||||
|
|
@ -120,7 +120,7 @@ export function OperationsSessionView({
|
||||||
|
|
||||||
{/* Last Activity */}
|
{/* Last Activity */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Last Activity
|
Last Activity
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs font-mono text-on-surface">
|
<div className="text-xs font-mono text-on-surface">
|
||||||
|
|
@ -130,7 +130,7 @@ export function OperationsSessionView({
|
||||||
|
|
||||||
{/* Absolute Expiry */}
|
{/* Absolute Expiry */}
|
||||||
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1 sm:col-span-2">
|
<div className="p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1 sm:col-span-2">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider text-outline">
|
<span className="text-[10px] font-mono text-outline">
|
||||||
Absolute Expiry
|
Absolute Expiry
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs font-mono text-on-surface">
|
<div className="text-xs font-mono text-on-surface">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
OperatorSession,
|
OperatorSession,
|
||||||
OperationsHealth,
|
OperationsHealth,
|
||||||
SafeAuditEvent,
|
SafeAuditEvent,
|
||||||
|
OperationsOverviewData,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { OperationsTopBar } from './operations-top-bar';
|
import { OperationsTopBar } from './operations-top-bar';
|
||||||
import { OperationsSidebar } from './operations-sidebar';
|
import { OperationsSidebar } from './operations-sidebar';
|
||||||
|
|
@ -16,10 +17,14 @@ import { OperationsSessionView } from './operations-session';
|
||||||
import { OperationsIncidentsList } from './operations-incidents-list';
|
import { OperationsIncidentsList } from './operations-incidents-list';
|
||||||
import { OperationsCreateIncident } from './operations-create-incident';
|
import { OperationsCreateIncident } from './operations-create-incident';
|
||||||
import { OperationsIncidentDetail } from './operations-incident-detail';
|
import { OperationsIncidentDetail } from './operations-incident-detail';
|
||||||
|
import { OperationsUsers } from './operations-users';
|
||||||
|
import { OperationsSupport } from './operations-support';
|
||||||
|
import { OperationsCms } from './operations-cms';
|
||||||
|
|
||||||
interface OperationsShellProps {
|
interface OperationsShellProps {
|
||||||
initialSession: OperatorSession;
|
initialSession: OperatorSession;
|
||||||
initialHealth: OperationsHealth;
|
initialHealth: OperationsHealth;
|
||||||
|
initialOverview?: OperationsOverviewData | null;
|
||||||
initialAudit?: {
|
initialAudit?: {
|
||||||
events: SafeAuditEvent[];
|
events: SafeAuditEvent[];
|
||||||
next_cursor?: string | null;
|
next_cursor?: string | null;
|
||||||
|
|
@ -32,6 +37,7 @@ interface OperationsShellProps {
|
||||||
export function OperationsShell({
|
export function OperationsShell({
|
||||||
initialSession,
|
initialSession,
|
||||||
initialHealth,
|
initialHealth,
|
||||||
|
initialOverview = null,
|
||||||
initialAudit,
|
initialAudit,
|
||||||
initialNav = 'overview',
|
initialNav = 'overview',
|
||||||
initialCaseRef = null,
|
initialCaseRef = null,
|
||||||
|
|
@ -46,6 +52,10 @@ export function OperationsShell({
|
||||||
);
|
);
|
||||||
const [selectedCaseRef, setSelectedCaseRef] = useState<string | null>(initialCaseRef);
|
const [selectedCaseRef, setSelectedCaseRef] = useState<string | null>(initialCaseRef);
|
||||||
|
|
||||||
|
// Overview state
|
||||||
|
const [overviewData, setOverviewData] = useState<OperationsOverviewData | null>(initialOverview);
|
||||||
|
const [isRefreshingOverview, setIsRefreshingOverview] = useState(false);
|
||||||
|
|
||||||
// Health state
|
// Health state
|
||||||
const [health, setHealth] = useState<OperationsHealth>(initialHealth);
|
const [health, setHealth] = useState<OperationsHealth>(initialHealth);
|
||||||
const [isRefreshingHealth, setIsRefreshingHealth] = useState(false);
|
const [isRefreshingHealth, setIsRefreshingHealth] = useState(false);
|
||||||
|
|
@ -97,6 +107,36 @@ export function OperationsShell({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual Overview Refresh (Zero automatic polling)
|
||||||
|
async function handleRefreshOverview() {
|
||||||
|
if (isRefreshingOverview) return;
|
||||||
|
setIsRefreshingOverview(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ops/overview', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as OperationsOverviewData;
|
||||||
|
setOverviewData(data);
|
||||||
|
if (data.health) {
|
||||||
|
setHealth(data.health);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Retain current state on failure
|
||||||
|
} finally {
|
||||||
|
setIsRefreshingOverview(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single initial fetch on mount (Zero interval polling)
|
||||||
|
React.useEffect(() => {
|
||||||
|
void handleRefreshOverview();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Next Page of Audit Events (Owner only, server cursor)
|
// Next Page of Audit Events (Owner only, server cursor)
|
||||||
async function handleLoadNextAuditPage() {
|
async function handleLoadNextAuditPage() {
|
||||||
if (!nextCursor || isLoadingMoreAudit) return;
|
if (!nextCursor || isLoadingMoreAudit) return;
|
||||||
|
|
@ -144,7 +184,7 @@ export function OperationsShell({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary-container selection:text-on-primary-container">
|
<div className="ops-shell-root min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary-container selection:text-on-primary-container w-full">
|
||||||
{/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */}
|
{/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */}
|
||||||
<OperationsSidebar
|
<OperationsSidebar
|
||||||
activeNav={activeNav}
|
activeNav={activeNav}
|
||||||
|
|
@ -165,15 +205,28 @@ export function OperationsShell({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Body Container */}
|
{/* Content Body Container */}
|
||||||
<main className="relative pt-20 px-4 sm:px-6 py-6 w-full max-w-[1440px] mx-auto flex-1">
|
<main className="relative pt-20 px-4 sm:px-6 lg:px-8 py-8 w-full flex-1">
|
||||||
{activeNav === 'overview' && (
|
{activeNav === 'overview' && (
|
||||||
<OperationsOverview
|
<OperationsOverview
|
||||||
health={health}
|
overview={overviewData}
|
||||||
onRefresh={handleRefreshHealth}
|
health={overviewData?.health ?? health}
|
||||||
isRefreshing={isRefreshingHealth}
|
onRefresh={async () => {
|
||||||
|
await Promise.all([handleRefreshHealth(), handleRefreshOverview()]);
|
||||||
|
}}
|
||||||
|
isRefreshing={isRefreshingHealth || isRefreshingOverview}
|
||||||
|
onSelectIncident={(caseRef) => {
|
||||||
|
setSelectedCaseRef(caseRef);
|
||||||
|
setIncidentView('detail');
|
||||||
|
setActiveNav('incidents');
|
||||||
|
}}
|
||||||
|
onNavigateNav={(nav) => setActiveNav(nav)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeNav === 'users' && (
|
||||||
|
<OperationsUsers role={session.role} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeNav === 'incidents' && (
|
{activeNav === 'incidents' && (
|
||||||
<>
|
<>
|
||||||
{incidentView === 'list' && (
|
{incidentView === 'list' && (
|
||||||
|
|
@ -210,6 +263,14 @@ export function OperationsShell({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeNav === 'support' && (
|
||||||
|
<OperationsSupport role={session.role} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeNav === 'cms' && (
|
||||||
|
<OperationsCms role={session.role} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeNav === 'audit' && (
|
{activeNav === 'audit' && (
|
||||||
<OperationsAudit
|
<OperationsAudit
|
||||||
role={session.role}
|
role={session.role}
|
||||||
|
|
|
||||||
|
|
@ -32,23 +32,38 @@ export function OperationsSidebar({
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
icon: 'dashboard',
|
icon: 'dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
label: 'Users',
|
||||||
|
icon: 'person_search',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'incidents',
|
id: 'incidents',
|
||||||
label: 'Incidents',
|
label: 'Incidents',
|
||||||
icon: 'assignment',
|
icon: 'assignment',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'support',
|
||||||
|
label: 'Support Center',
|
||||||
|
icon: 'contact_support',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cms',
|
||||||
|
label: 'Website Content',
|
||||||
|
icon: 'edit_note',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'audit',
|
id: 'audit',
|
||||||
label: 'Audit',
|
label: 'Audit',
|
||||||
icon: 'verified_user',
|
icon: 'verified_user',
|
||||||
badge: isOwner ? 'OWNER' : 'RESTRICTED',
|
badge: isOwner ? 'Owner' : 'Restricted',
|
||||||
badgeType: isOwner ? 'primary' : 'neutral',
|
badgeType: isOwner ? 'primary' : 'neutral',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'operators',
|
id: 'operators',
|
||||||
label: 'Operators',
|
label: 'Operators',
|
||||||
icon: 'group',
|
icon: 'group',
|
||||||
badge: 'DISABLED',
|
badge: 'Disabled',
|
||||||
badgeType: 'disabled',
|
badgeType: 'disabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -66,10 +81,10 @@ export function OperationsSidebar({
|
||||||
<span className="material-symbols-outlined text-[18px]">terminal</span>
|
<span className="material-symbols-outlined text-[18px]">terminal</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-headline-sm text-sm font-bold tracking-tight text-on-surface uppercase">
|
<span className="font-headline-sm text-sm font-bold tracking-tight text-on-surface">
|
||||||
Operations
|
Operations
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-mono text-primary-fixed-dim uppercase tracking-wider">
|
<span className="text-[10px] font-mono text-primary-fixed-dim">
|
||||||
Platform Console
|
Platform Console
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,7 +124,7 @@ export function OperationsSidebar({
|
||||||
|
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span
|
<span
|
||||||
className={`px-1.5 py-0.5 rounded text-[9px] font-mono uppercase tracking-wider ${
|
className={`px-1.5 py-0.5 rounded text-[10px] font-mono ${
|
||||||
item.badgeType === 'primary'
|
item.badgeType === 'primary'
|
||||||
? 'bg-primary-container/15 text-primary-container border border-primary-container/30'
|
? 'bg-primary-container/15 text-primary-container border border-primary-container/30'
|
||||||
: item.badgeType === 'disabled'
|
: item.badgeType === 'disabled'
|
||||||
|
|
@ -127,7 +142,7 @@ export function OperationsSidebar({
|
||||||
|
|
||||||
{/* Footer Authority Boundary Notice */}
|
{/* Footer Authority Boundary Notice */}
|
||||||
<div className="p-4 border-t border-outline-variant/20 bg-surface-container-lowest/50 text-[10px] text-outline space-y-1">
|
<div className="p-4 border-t border-outline-variant/20 bg-surface-container-lowest/50 text-[10px] text-outline space-y-1">
|
||||||
<div className="flex items-center gap-1 font-mono uppercase text-on-surface-variant">
|
<div className="flex items-center gap-1 font-mono text-on-surface-variant">
|
||||||
<span className="material-symbols-outlined text-[12px] text-primary-fixed-dim">
|
<span className="material-symbols-outlined text-[12px] text-primary-fixed-dim">
|
||||||
shield_lock
|
shield_lock
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OperatorSession } from './types';
|
import { OperatorSession } from './types';
|
||||||
|
|
@ -17,10 +17,12 @@ export function OperationsTopBar({
|
||||||
signingOut = false,
|
signingOut = false,
|
||||||
}: OperationsTopBarProps) {
|
}: OperationsTopBarProps) {
|
||||||
const isOwner = session.role === 'owner';
|
const isOwner = session.role === 'owner';
|
||||||
const environment = (session.environment ?? 'production').toUpperCase();
|
const environment = session.environment
|
||||||
|
? session.environment.charAt(0).toUpperCase() + session.environment.slice(1).toLowerCase()
|
||||||
|
: 'Production';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 right-0 left-0 lg:left-64 z-30 h-16 bg-surface-container-lowest/90 backdrop-blur-md border-b border-outline-variant/30 flex items-center justify-between px-4 sm:px-6">
|
<header className="fixed top-0 right-0 left-0 lg:left-64 z-30 h-16 bg-surface-container-lowest/90 backdrop-blur-md border-b border-outline-variant/30 flex items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||||
{/* Left: Mobile Drawer Button + Operations Branding Title */}
|
{/* Left: Mobile Drawer Button + Operations Branding Title */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<button
|
<button
|
||||||
|
|
@ -34,16 +36,16 @@ export function OperationsTopBar({
|
||||||
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="font-headline-sm text-sm sm:text-base font-bold text-on-surface tracking-wider uppercase">
|
<span className="font-headline-sm text-sm sm:text-base font-bold text-on-surface tracking-tight">
|
||||||
MotorSignal
|
MotorSignal
|
||||||
</span>
|
</span>
|
||||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold tracking-widest uppercase bg-surface-container-high text-primary-container border border-primary-container/30">
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-surface-container-high text-primary-container border border-primary-container/30">
|
||||||
OPS
|
Ops
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="hidden sm:inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-surface-container-low text-on-surface-variant border border-outline-variant/30">
|
<span className="hidden sm:inline-flex items-center px-2 py-0.5 rounded text-[10px] font-mono bg-surface-container-low text-on-surface-variant border border-outline-variant/30">
|
||||||
ENV: {environment}
|
Env: {environment}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,7 +64,7 @@ export function OperationsTopBar({
|
||||||
|
|
||||||
{/* Role Pill */}
|
{/* Role Pill */}
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wider ${
|
className={`px-2 py-0.5 rounded text-[11px] font-semibold capitalize ${
|
||||||
isOwner
|
isOwner
|
||||||
? 'bg-primary-container/15 text-primary-container border border-primary-container/40'
|
? 'bg-primary-container/15 text-primary-container border border-primary-container/40'
|
||||||
: 'bg-surface-container-high text-on-surface-variant border border-outline-variant/40'
|
: 'bg-surface-container-high text-on-surface-variant border border-outline-variant/40'
|
||||||
|
|
@ -74,7 +76,7 @@ export function OperationsTopBar({
|
||||||
{/* Session Status Dot/Pill */}
|
{/* Session Status Dot/Pill */}
|
||||||
<div className="hidden sm:inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-surface-container-low border border-outline-variant/30">
|
<div className="hidden sm:inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-surface-container-low border border-outline-variant/30">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-secondary-fixed"></span>
|
<span className="h-1.5 w-1.5 rounded-full bg-secondary-fixed"></span>
|
||||||
<span className="text-[10px] font-mono text-on-surface-variant uppercase">
|
<span className="text-[10px] font-mono text-on-surface-variant">
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,7 +52,10 @@ export interface OperatorSession {
|
||||||
|
|
||||||
export type OperationsNavId =
|
export type OperationsNavId =
|
||||||
| 'overview'
|
| 'overview'
|
||||||
|
| 'users'
|
||||||
| 'incidents'
|
| 'incidents'
|
||||||
|
| 'support'
|
||||||
|
| 'cms'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'operators'
|
| 'operators'
|
||||||
| 'session';
|
| 'session';
|
||||||
|
|
@ -116,3 +119,205 @@ export interface UpdateIncidentInput {
|
||||||
export interface AddIncidentNoteInput {
|
export interface AddIncidentNoteInput {
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OverviewUsersMetrics {
|
||||||
|
total_registered: number;
|
||||||
|
active_24h: number;
|
||||||
|
active_7d: number;
|
||||||
|
new_this_week: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewMembershipMetrics {
|
||||||
|
total_subscribers: number;
|
||||||
|
premium_members: number;
|
||||||
|
free_members: number;
|
||||||
|
conversion_rate_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewPlatformActivity {
|
||||||
|
web_active_sessions_24h: number;
|
||||||
|
web_logins_24h: number;
|
||||||
|
web_signups_7d: number;
|
||||||
|
mobile_active_sessions_24h: number;
|
||||||
|
mobile_rides_today: number;
|
||||||
|
mobile_rides_active: number;
|
||||||
|
mobile_datapoints_24h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewIncidentMetrics {
|
||||||
|
total_open: number;
|
||||||
|
critical_count: number;
|
||||||
|
high_count: number;
|
||||||
|
medium_count: number;
|
||||||
|
low_count: number;
|
||||||
|
recent_open: SafeIncidentSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewSecurityErrorItem {
|
||||||
|
id?: string;
|
||||||
|
timestamp: string;
|
||||||
|
action: string;
|
||||||
|
actor_identity: string;
|
||||||
|
reason_code?: string;
|
||||||
|
result: string;
|
||||||
|
target_resource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewSecurityErrors {
|
||||||
|
errors_24h: number;
|
||||||
|
denials_24h: number;
|
||||||
|
recent: OverviewSecurityErrorItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationsOverviewData {
|
||||||
|
generated_at: string;
|
||||||
|
users: OverviewUsersMetrics;
|
||||||
|
membership: OverviewMembershipMetrics;
|
||||||
|
activity: OverviewPlatformActivity;
|
||||||
|
incidents: OverviewIncidentMetrics;
|
||||||
|
errors: OverviewSecurityErrors;
|
||||||
|
health: OperationsHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeUserSummary {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
latest_app_version?: string | null;
|
||||||
|
last_sync_at?: string | null;
|
||||||
|
active_sessions_count: number;
|
||||||
|
total_rides: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeUserSession {
|
||||||
|
id: string;
|
||||||
|
device_name?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at?: string | null;
|
||||||
|
is_revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeUserSubscription {
|
||||||
|
plan: string;
|
||||||
|
status: string;
|
||||||
|
expires_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeUserRideSummary {
|
||||||
|
total_rides: number;
|
||||||
|
last_ride_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeUserRecentAudit {
|
||||||
|
action: string;
|
||||||
|
timestamp: string;
|
||||||
|
ip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeUserDetail {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
sessions: SafeUserSession[];
|
||||||
|
subscription?: SafeUserSubscription | null;
|
||||||
|
ride_summary: SafeUserRideSummary;
|
||||||
|
recent_audit: SafeUserRecentAudit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SupportTicketType =
|
||||||
|
| 'bug_report'
|
||||||
|
| 'contact_request'
|
||||||
|
| 'telemetry_issue'
|
||||||
|
| 'general';
|
||||||
|
|
||||||
|
export type SupportTicketSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export type SupportTicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
|
||||||
|
export interface SupportTicketNote {
|
||||||
|
id: string;
|
||||||
|
author_identity: string;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportDiagnosticBundle {
|
||||||
|
app_version: string;
|
||||||
|
os_version: string;
|
||||||
|
device_model: string;
|
||||||
|
stack_trace?: string | null;
|
||||||
|
sensor_status: string;
|
||||||
|
logs_snippet?: string | null;
|
||||||
|
submitted_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportTicketSummary {
|
||||||
|
id: string;
|
||||||
|
case_reference: string;
|
||||||
|
ticket_type: SupportTicketType;
|
||||||
|
reporter_email: string;
|
||||||
|
subject: string;
|
||||||
|
severity: SupportTicketSeverity;
|
||||||
|
status: SupportTicketStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
resolved_at?: string | null;
|
||||||
|
has_diagnostics: boolean;
|
||||||
|
notes_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportTicketDetail {
|
||||||
|
id: string;
|
||||||
|
case_reference: string;
|
||||||
|
ticket_type: SupportTicketType;
|
||||||
|
reporter_email: string;
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
severity: SupportTicketSeverity;
|
||||||
|
status: SupportTicketStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
resolved_at?: string | null;
|
||||||
|
has_diagnostics: boolean;
|
||||||
|
notes: SupportTicketNote[];
|
||||||
|
diagnostic_bundle?: SupportDiagnosticBundle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CmsRevisionItem {
|
||||||
|
revision_id: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
change_summary: string;
|
||||||
|
content: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CmsDocumentSummary {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
has_draft: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CmsDocumentDetail {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
published: Record<string, any>;
|
||||||
|
draft: Record<string, any> | null;
|
||||||
|
has_draft?: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string;
|
||||||
|
revisions: CmsRevisionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import {
|
||||||
SafeAuditEvent,
|
SafeAuditEvent,
|
||||||
ComponentHealthStatus,
|
ComponentHealthStatus,
|
||||||
HealthComponentKey,
|
HealthComponentKey,
|
||||||
|
OperationsOverviewData,
|
||||||
|
OverviewSecurityErrorItem,
|
||||||
|
SafeIncidentSummary,
|
||||||
} from '../components/ops/types';
|
} from '../components/ops/types';
|
||||||
|
|
||||||
export const HEALTH_COMPONENT_METADATA: Record<
|
export const HEALTH_COMPONENT_METADATA: Record<
|
||||||
|
|
@ -346,3 +349,125 @@ export function sanitizeIncidentNote(raw: unknown): import('../components/ops/ty
|
||||||
body,
|
body,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeOverviewResponse(raw: unknown): OperationsOverviewData {
|
||||||
|
const fallbackGeneratedAt = new Date().toISOString();
|
||||||
|
const defaultHealth = normalizeHealthResponse(null);
|
||||||
|
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return {
|
||||||
|
generated_at: fallbackGeneratedAt,
|
||||||
|
users: {
|
||||||
|
total_registered: 0,
|
||||||
|
active_24h: 0,
|
||||||
|
active_7d: 0,
|
||||||
|
new_this_week: 0,
|
||||||
|
},
|
||||||
|
membership: {
|
||||||
|
total_subscribers: 0,
|
||||||
|
premium_members: 0,
|
||||||
|
free_members: 0,
|
||||||
|
conversion_rate_pct: 0,
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
web_active_sessions_24h: 0,
|
||||||
|
web_logins_24h: 0,
|
||||||
|
web_signups_7d: 0,
|
||||||
|
mobile_active_sessions_24h: 0,
|
||||||
|
mobile_rides_today: 0,
|
||||||
|
mobile_rides_active: 0,
|
||||||
|
mobile_datapoints_24h: 0,
|
||||||
|
},
|
||||||
|
incidents: {
|
||||||
|
total_open: 0,
|
||||||
|
critical_count: 0,
|
||||||
|
high_count: 0,
|
||||||
|
medium_count: 0,
|
||||||
|
low_count: 0,
|
||||||
|
recent_open: [],
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
errors_24h: 0,
|
||||||
|
denials_24h: 0,
|
||||||
|
recent: [],
|
||||||
|
},
|
||||||
|
health: defaultHealth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
const rawUsers = obj.users && typeof obj.users === 'object' ? (obj.users as Record<string, unknown>) : {};
|
||||||
|
const rawMembership = obj.membership && typeof obj.membership === 'object' ? (obj.membership as Record<string, unknown>) : {};
|
||||||
|
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 rawRecentOpen = Array.isArray(rawIncidents.recent_open) ? rawIncidents.recent_open : [];
|
||||||
|
const safeRecentOpen: SafeIncidentSummary[] = rawRecentOpen
|
||||||
|
.map(sanitizeIncidentSummary)
|
||||||
|
.filter((i): i is SafeIncidentSummary => i !== null);
|
||||||
|
|
||||||
|
const rawRecentErrors = Array.isArray(rawErrors.recent) ? rawErrors.recent : [];
|
||||||
|
const safeRecentErrors: OverviewSecurityErrorItem[] = rawRecentErrors
|
||||||
|
.map((e): OverviewSecurityErrorItem | null => {
|
||||||
|
if (!e || typeof e !== 'object') return null;
|
||||||
|
const errObj = e as Record<string, unknown>;
|
||||||
|
if (typeof errObj.timestamp !== 'string' || !errObj.timestamp.trim()) return null;
|
||||||
|
if (typeof errObj.action !== 'string' || !errObj.action.trim()) return null;
|
||||||
|
if (typeof errObj.actor_identity !== 'string' || !errObj.actor_identity.trim()) return null;
|
||||||
|
return {
|
||||||
|
id: typeof errObj.id === 'string' && errObj.id.trim() ? errObj.id.trim() : undefined,
|
||||||
|
timestamp: errObj.timestamp.trim(),
|
||||||
|
action: errObj.action.trim(),
|
||||||
|
actor_identity: errObj.actor_identity.trim(),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((e): e is OverviewSecurityErrorItem => e !== null);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
membership: {
|
||||||
|
total_subscribers: typeof rawMembership.total_subscribers === 'number' ? rawMembership.total_subscribers : 0,
|
||||||
|
premium_members: typeof rawMembership.premium_members === 'number'
|
||||||
|
? rawMembership.premium_members
|
||||||
|
: typeof rawMembership.pro_members === 'number'
|
||||||
|
? rawMembership.pro_members
|
||||||
|
: 0,
|
||||||
|
free_members: typeof rawMembership.free_members === 'number' ? rawMembership.free_members : 0,
|
||||||
|
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_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,
|
||||||
|
mobile_rides_active: typeof rawActivity.mobile_rides_active === 'number' ? rawActivity.mobile_rides_active : 0,
|
||||||
|
mobile_datapoints_24h: typeof rawActivity.mobile_datapoints_24h === 'number' ? rawActivity.mobile_datapoints_24h : 0,
|
||||||
|
},
|
||||||
|
incidents: {
|
||||||
|
total_open: typeof rawIncidents.total_open === 'number' ? rawIncidents.total_open : safeRecentOpen.length,
|
||||||
|
critical_count: typeof rawIncidents.critical_count === 'number' ? rawIncidents.critical_count : 0,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
recent: safeRecentErrors,
|
||||||
|
},
|
||||||
|
health: normalizeHealthResponse(obj.health),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ console.log('--- STARTING OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3 & OPS-4 OPERATION
|
||||||
const opsData = loadTsModule('../lib/ops-data.ts');
|
const opsData = loadTsModule('../lib/ops-data.ts');
|
||||||
const {
|
const {
|
||||||
normalizeHealthResponse,
|
normalizeHealthResponse,
|
||||||
|
normalizeOverviewResponse,
|
||||||
sanitizeAuditEvent,
|
sanitizeAuditEvent,
|
||||||
isValidHealthStatus,
|
isValidHealthStatus,
|
||||||
HEALTH_COMPONENT_METADATA,
|
HEALTH_COMPONENT_METADATA,
|
||||||
|
|
@ -837,4 +838,311 @@ assert.ok(getNotesMatch && !getNotesMatch[0].includes('hasValidOrigin'), 'GET no
|
||||||
|
|
||||||
console.log(' PASS: Read-only GET operations are unaffected while mutations require valid origin.');
|
console.log(' PASS: Read-only GET operations are unaffected while mutations require valid origin.');
|
||||||
|
|
||||||
console.log('--- ALL OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3, OPS-4 & OPS-4.2 TESTS PASSED (38/38) ---');
|
// ============================================================
|
||||||
|
// 39. OPERATIONS OVERVIEW DATA NORMALIZATION & PROXY ROUTE (SECTION 1)
|
||||||
|
// ============================================================
|
||||||
|
console.log('Testing 39: Operations Overview Normalization & Command Dashboard Contract...');
|
||||||
|
const overviewRoutePath = path.resolve(__dirname, '../app/api/ops/overview/route.ts');
|
||||||
|
assert.ok(fs.existsSync(overviewRoutePath), 'GET /api/ops/overview proxy route must exist');
|
||||||
|
const overviewRouteSrc = fs.readFileSync(overviewRoutePath, 'utf8');
|
||||||
|
assert.ok(overviewRouteSrc.includes('__Host-motorsignal_ops'), 'Overview route must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(overviewRouteSrc.includes('/api/v1/ops/overview'), 'Overview route must query /api/v1/ops/overview');
|
||||||
|
assert.ok(overviewRouteSrc.includes('normalizeOverviewResponse'), 'Overview route must normalize response');
|
||||||
|
|
||||||
|
// Test normalizeOverviewResponse with null
|
||||||
|
const emptyOverview = normalizeOverviewResponse(null);
|
||||||
|
assert.equal(typeof emptyOverview.generated_at, 'string');
|
||||||
|
assert.equal(emptyOverview.users.total_registered, 0);
|
||||||
|
assert.equal(emptyOverview.membership.conversion_rate_pct, 0);
|
||||||
|
assert.equal(emptyOverview.incidents.total_open, 0);
|
||||||
|
assert.equal(emptyOverview.health.status, 'unavailable');
|
||||||
|
|
||||||
|
// Test normalizeOverviewResponse with sample backend payload
|
||||||
|
const sampleBackendOverview = {
|
||||||
|
generated_at: '2026-09-15T12:00:00Z',
|
||||||
|
users: {
|
||||||
|
total_registered: 1250,
|
||||||
|
active_24h: 320,
|
||||||
|
active_7d: 840,
|
||||||
|
new_this_week: 45,
|
||||||
|
},
|
||||||
|
membership: {
|
||||||
|
total_subscribers: 210,
|
||||||
|
premium_members: 180,
|
||||||
|
pro_members: 180,
|
||||||
|
free_members: 1070,
|
||||||
|
conversion_rate_pct: 14.4,
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
web_active_sessions_24h: 150,
|
||||||
|
web_logins_24h: 95,
|
||||||
|
web_signups_7d: 45,
|
||||||
|
mobile_active_sessions_24h: 210,
|
||||||
|
mobile_rides_today: 88,
|
||||||
|
mobile_rides_active: 7,
|
||||||
|
mobile_datapoints_24h: 1420500,
|
||||||
|
},
|
||||||
|
incidents: {
|
||||||
|
total_open: 1,
|
||||||
|
critical_count: 0,
|
||||||
|
high_count: 1,
|
||||||
|
medium_count: 0,
|
||||||
|
low_count: 0,
|
||||||
|
recent_open: [
|
||||||
|
{
|
||||||
|
id: 'db-uuid-incident-should-be-stripped',
|
||||||
|
case_reference: 'OPS-2026-000088',
|
||||||
|
title: 'API rate limit threshold alert',
|
||||||
|
severity: 'high',
|
||||||
|
status: 'investigating',
|
||||||
|
created_at: '2026-09-15T10:00:00Z',
|
||||||
|
updated_at: '2026-09-15T10:15:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
errors_24h: 3,
|
||||||
|
denials_24h: 2,
|
||||||
|
recent: [
|
||||||
|
{
|
||||||
|
id: 'sec-evt-1',
|
||||||
|
timestamp: '2026-09-15T11:45:00Z',
|
||||||
|
action: 'operator.login',
|
||||||
|
actor_identity: 'attacker@unknown.io',
|
||||||
|
reason_code: 'invalid_credential',
|
||||||
|
result: 'denied',
|
||||||
|
target_resource: 'ops.session',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
health: {
|
||||||
|
status: 'available',
|
||||||
|
checked_at: '2026-09-15T12:00:00Z',
|
||||||
|
components: {
|
||||||
|
operations_api: { status: 'available' },
|
||||||
|
operations_database: { status: 'available' },
|
||||||
|
session_validation: { status: 'available' },
|
||||||
|
audit_persistence: { status: 'available' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedOverview = normalizeOverviewResponse(sampleBackendOverview);
|
||||||
|
assert.equal(normalizedOverview.users.total_registered, 1250);
|
||||||
|
assert.equal(normalizedOverview.users.active_24h, 320);
|
||||||
|
assert.equal(normalizedOverview.membership.premium_members, 180);
|
||||||
|
assert.equal(normalizedOverview.activity.mobile_rides_today, 88);
|
||||||
|
assert.equal(normalizedOverview.activity.mobile_datapoints_24h, 1420500);
|
||||||
|
assert.equal(normalizedOverview.incidents.total_open, 1);
|
||||||
|
assert.equal(normalizedOverview.incidents.recent_open.length, 1);
|
||||||
|
assert.equal(normalizedOverview.incidents.recent_open[0].case_reference, 'OPS-2026-000088');
|
||||||
|
assert.equal(normalizedOverview.incidents.recent_open[0].id, undefined, 'Database UUID must be stripped from incidents');
|
||||||
|
assert.equal(normalizedOverview.errors.denials_24h, 2);
|
||||||
|
assert.equal(normalizedOverview.errors.recent.length, 1);
|
||||||
|
assert.equal(normalizedOverview.health.status, 'available');
|
||||||
|
|
||||||
|
// Verify dashboard component structure
|
||||||
|
assert.ok(overviewSource.includes('Registered Users'), 'Overview must render Registered Users KPI');
|
||||||
|
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('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('Operational Architecture & Data Boundary') || overviewSource.includes('Operational Architecture'), 'Overview must render Architecture Boundary');
|
||||||
|
|
||||||
|
// Shell integration check
|
||||||
|
assert.ok(shellSource.includes('/api/ops/overview'), 'OperationsShell must fetch /api/ops/overview');
|
||||||
|
assert.ok(shellSource.includes('overviewData') || shellSource.includes('overview='), 'OperationsShell must pass overview data to OperationsOverview');
|
||||||
|
|
||||||
|
console.log(' PASS: Operations overview DTO normalization, proxy route, and dashboard elements verified.');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 40. SECTION 2: USER MANAGEMENT ARCHITECTURE & SECURITY CONTRACT
|
||||||
|
// ============================================================
|
||||||
|
console.log('Testing 40: Section 2 User Management (Backend, Proxy Routes & UI)...');
|
||||||
|
|
||||||
|
// 40.1 Proxy routes verification
|
||||||
|
const usersRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/users/route.ts'), 'utf8');
|
||||||
|
const userDetailRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/users/[userId]/route.ts'), 'utf8');
|
||||||
|
const userStatusRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/users/[userId]/status/route.ts'), 'utf8');
|
||||||
|
const userRevokeRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/users/[userId]/revoke-sessions/route.ts'), 'utf8');
|
||||||
|
const userResetRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/users/[userId]/reset-password/route.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.ok(usersRouteSrc.includes('__Host-motorsignal_ops'), 'users/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(userDetailRouteSrc.includes('__Host-motorsignal_ops'), 'users/[userId]/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(userStatusRouteSrc.includes('__Host-motorsignal_ops'), 'users/[userId]/status/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(userRevokeRouteSrc.includes('__Host-motorsignal_ops'), 'users/[userId]/revoke-sessions/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(userResetRouteSrc.includes('__Host-motorsignal_ops'), 'users/[userId]/reset-password/route.ts must check __Host-motorsignal_ops');
|
||||||
|
|
||||||
|
// CSRF Origin enforcement on mutations
|
||||||
|
assert.ok(userStatusRouteSrc.includes('hasValidOrigin(request)'), 'User status route must enforce CSRF origin check');
|
||||||
|
assert.ok(userRevokeRouteSrc.includes('hasValidOrigin(request)'), 'User revoke sessions route must enforce CSRF origin check');
|
||||||
|
assert.ok(userResetRouteSrc.includes('hasValidOrigin(request)'), 'User reset password route must enforce CSRF origin check');
|
||||||
|
|
||||||
|
// 40.2 Navigation order & shell wiring
|
||||||
|
assert.ok(sidebarSource.includes("id: 'users'") && sidebarSource.includes("label: 'Users'") && sidebarSource.includes("icon: 'person_search'"), 'Sidebar must contain users nav item');
|
||||||
|
const usersNavIdx = sidebarSource.indexOf("id: 'users'");
|
||||||
|
assert.ok(overviewIdx < usersNavIdx && usersNavIdx < incidentsIdx, 'Users nav item must be placed right after overview and before incidents');
|
||||||
|
assert.ok(shellSource.includes("activeNav === 'users'") && shellSource.includes('<OperationsUsers'), 'OperationsShell must wire activeNav === users to OperationsUsers');
|
||||||
|
|
||||||
|
// 40.3 OperationsUsers component UI contract
|
||||||
|
const usersComponentSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-users.tsx'), 'utf8');
|
||||||
|
assert.ok(usersComponentSrc.includes('hidden md:block'), 'Users list must render desktop table view');
|
||||||
|
assert.ok(usersComponentSrc.includes('md:hidden'), 'Users list must render mobile stacked cards');
|
||||||
|
assert.ok(usersComponentSrc.includes('Search user by email or account ID'), 'Users list must provide search bar');
|
||||||
|
assert.ok(usersComponentSrc.includes('statusFilter') && usersComponentSrc.includes('verifiedFilter'), 'Users list must provide status and verification filters');
|
||||||
|
assert.ok(usersComponentSrc.includes('suspend') && usersComponentSrc.includes('reinstate'), 'Users detail must support suspend and reinstate actions');
|
||||||
|
assert.ok(usersComponentSrc.includes('revoke-sessions'), 'Users detail must support session revocation');
|
||||||
|
assert.ok(usersComponentSrc.includes('reset-password'), 'Users detail must support password reset');
|
||||||
|
assert.ok(usersComponentSrc.includes('length < 3') || usersComponentSrc.includes('min 3 characters'), 'Action confirmation must require reason with minimum 3 chars');
|
||||||
|
assert.ok(!usersComponentSrc.includes('setInterval'), 'Users component must have zero setInterval');
|
||||||
|
assert.ok(!usersComponentSrc.includes('setTimeout'), 'Users component must have zero setTimeout');
|
||||||
|
|
||||||
|
// 40.4 Backend main.py contract check
|
||||||
|
const backendMainSrc = fs.readFileSync(path.resolve(__dirname, '../../backend/motorsignal_api/app/main.py'), 'utf8');
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsUserStatusUpdate'), 'Backend must define OpsUserStatusUpdate');
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsUserActionRequest'), 'Backend must define OpsUserActionRequest');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/users")'), 'Backend must implement GET /api/v1/ops/users');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/users/{user_id}")'), 'Backend must implement GET /api/v1/ops/users/{user_id}');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.post("/api/v1/ops/users/{user_id}/status")'), 'Backend must implement POST /api/v1/ops/users/{user_id}/status');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.post("/api/v1/ops/users/{user_id}/revoke-sessions")'), 'Backend must implement POST /api/v1/ops/users/{user_id}/revoke-sessions');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.post("/api/v1/ops/users/{user_id}/reset-password")'), 'Backend must implement POST /api/v1/ops/users/{user_id}/reset-password');
|
||||||
|
assert.ok(backendMainSrc.includes('user.suspend') && backendMainSrc.includes('user.reinstate'), 'Backend must audit user.suspend and user.reinstate');
|
||||||
|
assert.ok(backendMainSrc.includes('user.sessions_revoked'), 'Backend must audit user.sessions_revoked');
|
||||||
|
assert.ok(backendMainSrc.includes('user.password_reset_initiated'), 'Backend must audit user.password_reset_initiated');
|
||||||
|
|
||||||
|
console.log(' PASS: Section 2 User Management backend, proxy routes, navigation, and UI contracts fully verified.');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 41. SECTION 3: SUPPORT CENTER & DIAGNOSTICS CONTRACT
|
||||||
|
// ============================================================
|
||||||
|
console.log('Testing 41: Section 3 Support Center & Diagnostics (Backend, Proxy Routes & UI)...');
|
||||||
|
|
||||||
|
// 41.1 Proxy routes verification
|
||||||
|
const supportTicketsRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/support/tickets/route.ts'), 'utf8');
|
||||||
|
const supportDetailRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/support/tickets/[ticketId]/route.ts'), 'utf8');
|
||||||
|
const supportNotesRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/support/tickets/[ticketId]/notes/route.ts'), 'utf8');
|
||||||
|
const supportDiagnosticsRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/support/tickets/[ticketId]/diagnostics/route.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.ok(supportTicketsRouteSrc.includes('__Host-motorsignal_ops'), 'support/tickets/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(supportDetailRouteSrc.includes('__Host-motorsignal_ops'), 'support/tickets/[ticketId]/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(supportNotesRouteSrc.includes('__Host-motorsignal_ops'), 'support/tickets/[ticketId]/notes/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(supportDiagnosticsRouteSrc.includes('__Host-motorsignal_ops'), 'support/tickets/[ticketId]/diagnostics/route.ts must check __Host-motorsignal_ops');
|
||||||
|
|
||||||
|
// CSRF Origin enforcement on mutations
|
||||||
|
assert.ok(supportTicketsRouteSrc.includes('hasValidOrigin(request)'), 'Support tickets create route must enforce CSRF origin check');
|
||||||
|
assert.ok(supportDetailRouteSrc.includes('hasValidOrigin(request)'), 'Support ticket patch route must enforce CSRF origin check');
|
||||||
|
assert.ok(supportNotesRouteSrc.includes('hasValidOrigin(request)'), 'Support notes route must enforce CSRF origin check');
|
||||||
|
|
||||||
|
// Diagnostics reason requirement
|
||||||
|
assert.ok(supportDiagnosticsRouteSrc.includes('reason.length < 3'), 'Support diagnostics route must require reason with min 3 chars');
|
||||||
|
|
||||||
|
// 41.2 Navigation order & shell wiring
|
||||||
|
assert.ok(sidebarSource.includes("id: 'support'") && sidebarSource.includes("label: 'Support Center'") && sidebarSource.includes("icon: 'contact_support'"), 'Sidebar must contain support nav item');
|
||||||
|
const supportNavIdx = sidebarSource.indexOf("id: 'support'");
|
||||||
|
assert.ok(incidentsIdx < supportNavIdx && supportNavIdx < auditIdx, 'Support nav item must be placed right after incidents and before audit');
|
||||||
|
assert.ok(shellSource.includes("activeNav === 'support'") && shellSource.includes('<OperationsSupport'), 'OperationsShell must wire activeNav === support to OperationsSupport');
|
||||||
|
|
||||||
|
// 41.3 OperationsSupport component UI contract
|
||||||
|
const supportComponentSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-support.tsx'), 'utf8');
|
||||||
|
assert.ok(supportComponentSrc.includes('hidden md:block'), 'Support list must render desktop table view');
|
||||||
|
assert.ok(supportComponentSrc.includes('md:hidden'), 'Support list must render mobile stacked cards');
|
||||||
|
assert.ok(supportComponentSrc.includes('Search tickets by case ref'), 'Support list must provide search bar');
|
||||||
|
assert.ok(supportComponentSrc.includes('bug_reports') && supportComponentSrc.includes('contact_requests'), 'Support list must provide type filter pills');
|
||||||
|
assert.ok(supportComponentSrc.includes('in_progress') && supportComponentSrc.includes('resolved'), 'Support list must provide status filter pills');
|
||||||
|
assert.ok(supportComponentSrc.includes('Mark In Progress'), 'Support detail must support Mark In Progress transition action');
|
||||||
|
assert.ok(supportComponentSrc.includes('Resolve'), 'Support detail must support Resolve transition action');
|
||||||
|
assert.ok(supportComponentSrc.includes('Close'), 'Support detail must support Close transition action');
|
||||||
|
assert.ok(supportComponentSrc.includes('Add Note') || supportComponentSrc.includes('Add Internal Note'), 'Support detail must support adding internal notes');
|
||||||
|
assert.ok(supportComponentSrc.includes('Inspect Submitted Diagnostics') || supportComponentSrc.includes('Inspect Diagnostics'), 'Support detail must provide Inspect Submitted Diagnostics button');
|
||||||
|
assert.ok(supportComponentSrc.includes('length < 3') || supportComponentSrc.includes('min 3 characters'), 'Diagnostics confirmation must require reason with minimum 3 chars');
|
||||||
|
assert.ok(!supportComponentSrc.includes('setInterval'), 'Support component must have zero setInterval');
|
||||||
|
assert.ok(!supportComponentSrc.includes('setTimeout'), 'Support component must have zero setTimeout');
|
||||||
|
|
||||||
|
// 41.4 Backend main.py contract check
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsSupportTicketCreate'), 'Backend must define OpsSupportTicketCreate');
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsSupportTicketPatch'), 'Backend must define OpsSupportTicketPatch');
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsSupportTicketNoteCreate'), 'Backend must define OpsSupportTicketNoteCreate');
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsSupportDiagnosticBundle'), 'Backend must define OpsSupportDiagnosticBundle');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/support/tickets")'), 'Backend must implement GET /api/v1/ops/support/tickets');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/support/tickets/{ticket_id}")'), 'Backend must implement GET /api/v1/ops/support/tickets/{ticket_id}');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.post("/api/v1/ops/support/tickets'), 'Backend must implement POST /api/v1/ops/support/tickets');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.post("/api/v1/ops/support/tickets/{ticket_id}/notes'), 'Backend must implement POST /api/v1/ops/support/tickets/{ticket_id}/notes');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.patch("/api/v1/ops/support/tickets/{ticket_id}")'), 'Backend must implement PATCH /api/v1/ops/support/tickets/{ticket_id}');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/support/tickets/{ticket_id}/diagnostics")'), 'Backend must implement GET /api/v1/ops/support/tickets/{ticket_id}/diagnostics');
|
||||||
|
assert.ok(backendMainSrc.includes('support.note_added'), 'Backend must audit support.note_added');
|
||||||
|
assert.ok(backendMainSrc.includes('support.ticket_updated'), 'Backend must audit support.ticket_updated');
|
||||||
|
assert.ok(backendMainSrc.includes('support.diagnostics_viewed'), 'Backend must audit support.diagnostics_viewed');
|
||||||
|
assert.ok(backendMainSrc.includes('SUP-'), 'Backend must generate SUP-YYYY-NNNNNN case references');
|
||||||
|
|
||||||
|
console.log(' PASS: Section 3 Support Center backend, proxy routes, navigation, and UI contracts fully verified.');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 42. SECTION 4: WEBSITE CONTENT CMS CONTRACT (Backend, Proxy Routes & UI)
|
||||||
|
// ============================================================
|
||||||
|
console.log('Testing 42: Section 4 Website Content CMS (Backend, Proxy Routes & UI)...');
|
||||||
|
|
||||||
|
// 42.1 Proxy routes verification
|
||||||
|
const cmsDocumentsRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/cms/documents/route.ts'), 'utf8');
|
||||||
|
const cmsDetailRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/cms/documents/[slug]/route.ts'), 'utf8');
|
||||||
|
const cmsRollbackRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/cms/documents/[slug]/rollback/route.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.ok(cmsDocumentsRouteSrc.includes('__Host-motorsignal_ops'), 'cms/documents/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(cmsDetailRouteSrc.includes('__Host-motorsignal_ops'), 'cms/documents/[slug]/route.ts must check __Host-motorsignal_ops');
|
||||||
|
assert.ok(cmsRollbackRouteSrc.includes('__Host-motorsignal_ops'), 'cms/documents/[slug]/rollback/route.ts must check __Host-motorsignal_ops');
|
||||||
|
|
||||||
|
// CSRF Origin enforcement on mutations
|
||||||
|
assert.ok(cmsDetailRouteSrc.includes('hasValidOrigin(request)'), 'CMS detail PUT route must enforce CSRF origin check');
|
||||||
|
assert.ok(cmsRollbackRouteSrc.includes('hasValidOrigin(request)'), 'CMS rollback route must enforce CSRF origin check');
|
||||||
|
|
||||||
|
// 42.2 Types & Navigation order & shell wiring
|
||||||
|
const typesSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/types.ts'), 'utf8');
|
||||||
|
assert.ok(typesSrc.includes("'cms'"), "types.ts must include 'cms' in OperationsNavId");
|
||||||
|
assert.ok(typesSrc.includes('CmsDocumentSummary'), 'types.ts must define CmsDocumentSummary');
|
||||||
|
assert.ok(typesSrc.includes('CmsDocumentDetail'), 'types.ts must define CmsDocumentDetail');
|
||||||
|
assert.ok(typesSrc.includes('CmsRevisionItem'), 'types.ts must define CmsRevisionItem');
|
||||||
|
|
||||||
|
assert.ok(sidebarSource.includes("id: 'cms'") && sidebarSource.includes("label: 'Website Content'") && sidebarSource.includes("icon: 'edit_note'"), 'Sidebar must contain cms nav item');
|
||||||
|
const cmsNavIdx = sidebarSource.indexOf("id: 'cms'");
|
||||||
|
assert.ok(supportNavIdx < cmsNavIdx && cmsNavIdx < auditIdx, 'CMS nav item must be placed right after support and before audit');
|
||||||
|
assert.ok(shellSource.includes("activeNav === 'cms'") && shellSource.includes('<OperationsCms'), 'OperationsShell must wire activeNav === cms to OperationsCms');
|
||||||
|
|
||||||
|
// 42.3 OperationsCms component UI contract
|
||||||
|
const cmsComponentSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-cms.tsx'), 'utf8');
|
||||||
|
assert.ok(cmsComponentSrc.includes('homepage_hero'), 'CMS component must support homepage_hero section');
|
||||||
|
assert.ok(cmsComponentSrc.includes('features'), 'CMS component must support features section');
|
||||||
|
assert.ok(cmsComponentSrc.includes('screenshots'), 'CMS component must support screenshots section');
|
||||||
|
assert.ok(cmsComponentSrc.includes('faqs'), 'CMS component must support faqs section');
|
||||||
|
assert.ok(cmsComponentSrc.includes('download_links'), 'CMS component must support download_links section');
|
||||||
|
assert.ok(cmsComponentSrc.includes('changelog'), 'CMS component must support changelog section');
|
||||||
|
assert.ok(cmsComponentSrc.includes('policy_pages'), 'CMS component must support policy_pages section');
|
||||||
|
|
||||||
|
assert.ok(cmsComponentSrc.includes('Published') && cmsComponentSrc.includes('Draft Pending'), 'CMS component must display Published vs Draft Pending status');
|
||||||
|
assert.ok(cmsComponentSrc.includes('Preview Changes'), 'CMS component must provide Preview Changes action');
|
||||||
|
assert.ok(cmsComponentSrc.includes('Save Draft'), 'CMS component must provide Save Draft action');
|
||||||
|
assert.ok(cmsComponentSrc.includes('Publish Live'), 'CMS component must provide Publish Live action');
|
||||||
|
assert.ok(cmsComponentSrc.includes('Revision History'), 'CMS component must provide Revision History action');
|
||||||
|
assert.ok(cmsComponentSrc.includes('Restore Revision'), 'CMS component must provide Restore Revision action');
|
||||||
|
assert.ok(cmsComponentSrc.includes('min 3 characters') || cmsComponentSrc.includes('length < 3'), 'Rollback confirmation must require reason with minimum 3 chars');
|
||||||
|
assert.ok(!cmsComponentSrc.includes('setInterval'), 'CMS component must have zero setInterval');
|
||||||
|
assert.ok(!cmsComponentSrc.includes('setTimeout'), 'CMS component must have zero setTimeout');
|
||||||
|
|
||||||
|
// 42.4 Backend main.py contract check
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsCmsUpdateRequest'), 'Backend must define OpsCmsUpdateRequest');
|
||||||
|
assert.ok(backendMainSrc.includes('class OpsCmsRollbackRequest'), 'Backend must define OpsCmsRollbackRequest');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/cms/documents")'), 'Backend must implement GET /api/v1/ops/cms/documents');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.get("/api/v1/ops/cms/documents/{slug}")'), 'Backend must implement GET /api/v1/ops/cms/documents/{slug}');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.put("/api/v1/ops/cms/documents/{slug}")'), 'Backend must implement PUT /api/v1/ops/cms/documents/{slug}');
|
||||||
|
assert.ok(backendMainSrc.includes('@app.post("/api/v1/ops/cms/documents/{slug}/rollback")'), 'Backend must implement POST /api/v1/ops/cms/documents/{slug}/rollback');
|
||||||
|
assert.ok(backendMainSrc.includes('cms.draft_saved'), 'Backend must audit cms.draft_saved');
|
||||||
|
assert.ok(backendMainSrc.includes('cms.published'), 'Backend must audit cms.published');
|
||||||
|
assert.ok(backendMainSrc.includes('cms.revision_restored'), 'Backend must audit cms.revision_restored');
|
||||||
|
assert.ok(backendMainSrc.includes('homepage_hero') && backendMainSrc.includes('features') && backendMainSrc.includes('screenshots') && backendMainSrc.includes('faqs') && backendMainSrc.includes('download_links') && backendMainSrc.includes('changelog') && backendMainSrc.includes('policy_pages'), 'Backend must include all 7 CMS default documents');
|
||||||
|
|
||||||
|
console.log(' PASS: Section 4 Website Content CMS backend, proxy routes, navigation, and UI contracts fully verified.');
|
||||||
|
|
||||||
|
console.log('--- ALL OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3, OPS-4, OPS-4.2, OVERVIEW, SECTION 2 USERS, SECTION 3 SUPPORT CENTER & SECTION 4 CMS TESTS PASSED (42/42) ---');
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue