814 lines
32 KiB
TypeScript
814 lines
32 KiB
TypeScript
import {
|
|
OperationsHealth,
|
|
SafeAuditEvent,
|
|
ComponentHealthStatus,
|
|
HealthComponentKey,
|
|
OperationsOverviewData,
|
|
OverviewSecurityErrorItem,
|
|
SafeIncidentSummary,
|
|
OperationsInfrastructureData,
|
|
ServiceStatusValue,
|
|
ServiceHealthItem,
|
|
SubsystemMetrics,
|
|
OverviewWebsiteActivity,
|
|
} from '../components/ops/types';
|
|
|
|
|
|
export const HEALTH_COMPONENT_METADATA: Record<
|
|
HealthComponentKey,
|
|
{ name: string; description: string }
|
|
> = {
|
|
operations_api: {
|
|
name: 'Operations API',
|
|
description: 'FastAPI core endpoint runtime & ingress',
|
|
},
|
|
operations_database: {
|
|
name: 'Operations Database',
|
|
description: 'PostgreSQL ops schema relational persistence',
|
|
},
|
|
session_validation: {
|
|
name: 'Operations Access',
|
|
description: 'Checks staff sessions and enforces owner/operator permissions.',
|
|
},
|
|
audit_persistence: {
|
|
name: 'Audit Logging',
|
|
description: 'Records administrative actions and their outcomes.',
|
|
},
|
|
};
|
|
|
|
export const APPROVED_AUDIT_METADATA_KEYS = new Set([
|
|
'component',
|
|
'status_code',
|
|
'subsystem',
|
|
'duration_ms',
|
|
'policy',
|
|
'action_scope',
|
|
'session_valid',
|
|
'mfa_verified',
|
|
'migration_version',
|
|
'records_affected',
|
|
]);
|
|
|
|
export function isValidHealthStatus(status: unknown): status is ComponentHealthStatus {
|
|
return (
|
|
status === 'available' ||
|
|
status === 'degraded' ||
|
|
status === 'unavailable' ||
|
|
status === 'unknown'
|
|
);
|
|
}
|
|
|
|
export function normalizeHealthResponse(raw: unknown): OperationsHealth {
|
|
const fallbackCheckedAt = new Date().toISOString();
|
|
if (!raw || typeof raw !== 'object') {
|
|
return {
|
|
status: 'unavailable',
|
|
checked_at: fallbackCheckedAt,
|
|
components: {
|
|
operations_api: { status: 'unavailable', description: HEALTH_COMPONENT_METADATA.operations_api.description },
|
|
operations_database: { status: 'unavailable', description: HEALTH_COMPONENT_METADATA.operations_database.description },
|
|
session_validation: { status: 'unavailable', description: HEALTH_COMPONENT_METADATA.session_validation.description },
|
|
audit_persistence: { status: 'unavailable', description: HEALTH_COMPONENT_METADATA.audit_persistence.description },
|
|
},
|
|
};
|
|
}
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
const rawStatus = obj.status;
|
|
const overallStatus: ComponentHealthStatus = isValidHealthStatus(rawStatus)
|
|
? rawStatus
|
|
: 'unknown';
|
|
|
|
const checkedAt =
|
|
typeof obj.checked_at === 'string' && obj.checked_at.length > 0
|
|
? obj.checked_at
|
|
: fallbackCheckedAt;
|
|
|
|
const rawComponents =
|
|
obj.components && typeof obj.components === 'object'
|
|
? (obj.components as Record<string, unknown>)
|
|
: {};
|
|
|
|
const keys: HealthComponentKey[] = [
|
|
'operations_api',
|
|
'operations_database',
|
|
'session_validation',
|
|
'audit_persistence',
|
|
];
|
|
|
|
const components: Record<
|
|
HealthComponentKey,
|
|
{ status: ComponentHealthStatus; description: string }
|
|
> = {
|
|
operations_api: { status: 'unknown', description: HEALTH_COMPONENT_METADATA.operations_api.description },
|
|
operations_database: { status: 'unknown', description: HEALTH_COMPONENT_METADATA.operations_database.description },
|
|
session_validation: { status: 'unknown', description: HEALTH_COMPONENT_METADATA.session_validation.description },
|
|
audit_persistence: { status: 'unknown', description: HEALTH_COMPONENT_METADATA.audit_persistence.description },
|
|
};
|
|
|
|
for (const key of keys) {
|
|
const rawComp = rawComponents[key] as Record<string, unknown> | undefined;
|
|
const compStatus = rawComp && isValidHealthStatus(rawComp.status)
|
|
? rawComp.status
|
|
: isValidHealthStatus(obj[key])
|
|
? (obj[key] as ComponentHealthStatus)
|
|
: overallStatus;
|
|
|
|
components[key] = {
|
|
status: compStatus,
|
|
description: (rawComp?.description as string) || HEALTH_COMPONENT_METADATA[key].description,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: overallStatus,
|
|
checked_at: checkedAt,
|
|
components,
|
|
};
|
|
}
|
|
|
|
export function sanitizeAuditEvent(raw: unknown): SafeAuditEvent | null {
|
|
if (!raw || typeof raw !== 'object') {
|
|
return null; // Fail closed
|
|
}
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
// Required: timestamp (ISO string)
|
|
if (typeof obj.timestamp !== 'string' || !obj.timestamp.trim()) {
|
|
return null;
|
|
}
|
|
const timestamp = obj.timestamp.trim();
|
|
|
|
// Required: action
|
|
if (typeof obj.action !== 'string' || !obj.action.trim()) {
|
|
return null;
|
|
}
|
|
const action = obj.action.trim();
|
|
|
|
// Required: result
|
|
if (typeof obj.result !== 'string' || !obj.result.trim()) {
|
|
return null;
|
|
}
|
|
const result = obj.result.trim();
|
|
|
|
// Required: actor_identity (exact OPS-3B contract, no actor_email alias)
|
|
if (typeof obj.actor_identity !== 'string' || !obj.actor_identity.trim()) {
|
|
return null; // Fail closed: missing verified identity
|
|
}
|
|
const actor_identity = obj.actor_identity.trim();
|
|
|
|
// Optional transport ID (unrendered in UI)
|
|
const id =
|
|
typeof obj.id === 'string' && obj.id.trim()
|
|
? obj.id.trim()
|
|
: undefined;
|
|
|
|
// Optional: actor_role
|
|
const actor_role =
|
|
typeof obj.actor_role === 'string' && obj.actor_role.trim()
|
|
? obj.actor_role.trim()
|
|
: undefined;
|
|
|
|
// Optional: target_resource (exact OPS-3B contract, no target alias)
|
|
const target_resource =
|
|
typeof obj.target_resource === 'string' && obj.target_resource.trim()
|
|
? obj.target_resource.trim()
|
|
: undefined;
|
|
|
|
// Optional: reason_code
|
|
const reason_code =
|
|
typeof obj.reason_code === 'string' && obj.reason_code.trim()
|
|
? obj.reason_code.trim()
|
|
: undefined;
|
|
|
|
// Optional: case_reference
|
|
const case_reference =
|
|
typeof obj.case_reference === 'string' && obj.case_reference.trim()
|
|
? obj.case_reference.trim()
|
|
: undefined;
|
|
|
|
// Explicit Allowlist Metadata Projection only
|
|
let metadata: Record<string, string | number | boolean> | undefined = undefined;
|
|
if (obj.metadata && typeof obj.metadata === 'object' && !Array.isArray(obj.metadata)) {
|
|
const rawMeta = obj.metadata as Record<string, unknown>;
|
|
const safeMeta: Record<string, string | number | boolean> = {};
|
|
for (const [k, v] of Object.entries(rawMeta)) {
|
|
if (!APPROVED_AUDIT_METADATA_KEYS.has(k)) {
|
|
continue; // Drop unapproved metadata key
|
|
}
|
|
if (typeof v === 'string' && v.length <= 100) {
|
|
safeMeta[k] = v;
|
|
} else if (typeof v === 'number' || typeof v === 'boolean') {
|
|
safeMeta[k] = v;
|
|
}
|
|
}
|
|
if (Object.keys(safeMeta).length > 0) {
|
|
metadata = safeMeta;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id,
|
|
timestamp,
|
|
action,
|
|
result,
|
|
actor_identity,
|
|
actor_role,
|
|
target_resource,
|
|
reason_code,
|
|
case_reference,
|
|
metadata,
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Incident Sanitizers & Validators (OPS-4)
|
|
// ============================================================
|
|
|
|
export function isValidIncidentSeverity(severity: unknown): severity is import('../components/ops/types').IncidentSeverity {
|
|
return (
|
|
severity === 'low' ||
|
|
severity === 'medium' ||
|
|
severity === 'high' ||
|
|
severity === 'critical'
|
|
);
|
|
}
|
|
|
|
export function isValidIncidentStatus(status: unknown): status is import('../components/ops/types').IncidentStatus {
|
|
return (
|
|
status === 'open' ||
|
|
status === 'investigating' ||
|
|
status === 'monitoring' ||
|
|
status === 'resolved' ||
|
|
status === 'closed' ||
|
|
status === 'archived'
|
|
);
|
|
}
|
|
|
|
const ALLOWED_INCIDENT_TRANSITIONS: Record<string, Set<string>> = {
|
|
open: new Set(['investigating', 'resolved', 'closed']),
|
|
investigating: new Set(['monitoring', 'resolved', 'closed']),
|
|
monitoring: new Set(['investigating', 'resolved', 'closed']),
|
|
resolved: new Set(['closed', 'investigating']),
|
|
closed: new Set(['investigating']), // Reopen (Owner only)
|
|
archived: new Set(), // Terminal
|
|
};
|
|
|
|
export function isValidStatusTransition(
|
|
current: import('../components/ops/types').IncidentStatus,
|
|
target: import('../components/ops/types').IncidentStatus,
|
|
role: import('../components/ops/types').OperationsRole
|
|
): boolean {
|
|
if (current === target) return false;
|
|
if (current === 'archived') return false; // Terminal
|
|
if (target === 'archived') return role === 'owner'; // Owner only
|
|
if (current === 'closed' && target === 'investigating') return role === 'owner'; // Owner only
|
|
const allowed = ALLOWED_INCIDENT_TRANSITIONS[current];
|
|
return allowed ? allowed.has(target) : false;
|
|
}
|
|
|
|
export function sanitizeIncidentSummary(raw: unknown): import('../components/ops/types').SafeIncidentSummary | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
if (typeof obj.case_reference !== 'string' || !obj.case_reference.trim()) return null;
|
|
const case_reference = obj.case_reference.trim();
|
|
|
|
if (typeof obj.title !== 'string' || !obj.title.trim()) return null;
|
|
const title = obj.title.trim();
|
|
|
|
if (!isValidIncidentSeverity(obj.severity)) return null;
|
|
const severity = obj.severity;
|
|
|
|
if (!isValidIncidentStatus(obj.status)) return null;
|
|
const status = obj.status;
|
|
|
|
if (typeof obj.created_at !== 'string' || !obj.created_at.trim()) return null;
|
|
const created_at = obj.created_at.trim();
|
|
|
|
const updated_at = typeof obj.updated_at === 'string' && obj.updated_at.trim()
|
|
? obj.updated_at.trim()
|
|
: created_at;
|
|
|
|
const assigned_to = typeof obj.assigned_to === 'string' && obj.assigned_to.trim()
|
|
? obj.assigned_to.trim()
|
|
: null;
|
|
|
|
return {
|
|
case_reference,
|
|
title,
|
|
severity,
|
|
status,
|
|
created_at,
|
|
updated_at,
|
|
assigned_to,
|
|
};
|
|
}
|
|
|
|
export function sanitizeIncidentDetail(raw: unknown): import('../components/ops/types').SafeIncidentDetail | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
const summaryData = sanitizeIncidentSummary(raw);
|
|
if (!summaryData) return null;
|
|
|
|
if (typeof obj.summary !== 'string' || !obj.summary.trim()) return null;
|
|
const summary = obj.summary.trim();
|
|
|
|
if (typeof obj.created_by_identity !== 'string' || !obj.created_by_identity.trim()) return null;
|
|
const created_by_identity = obj.created_by_identity.trim();
|
|
|
|
const closed_at = typeof obj.closed_at === 'string' && obj.closed_at.trim()
|
|
? obj.closed_at.trim()
|
|
: null;
|
|
|
|
const resolution = typeof obj.resolution === 'string' && obj.resolution.trim()
|
|
? obj.resolution.trim()
|
|
: null;
|
|
|
|
const closed_by_identity = typeof obj.closed_by_identity === 'string' && obj.closed_by_identity.trim()
|
|
? obj.closed_by_identity.trim()
|
|
: null;
|
|
|
|
return {
|
|
...summaryData,
|
|
summary,
|
|
created_by_identity,
|
|
closed_at,
|
|
resolution,
|
|
closed_by_identity,
|
|
};
|
|
}
|
|
|
|
export function sanitizeIncidentNote(raw: unknown): import('../components/ops/types').SafeIncidentNote | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
if (typeof obj.created_at !== 'string' || !obj.created_at.trim()) return null;
|
|
const created_at = obj.created_at.trim();
|
|
|
|
if (typeof obj.created_by_identity !== 'string' || !obj.created_by_identity.trim()) return null;
|
|
const created_by_identity = obj.created_by_identity.trim();
|
|
|
|
if (typeof obj.body !== 'string' || !obj.body.trim()) return null;
|
|
const body = obj.body.trim();
|
|
|
|
return {
|
|
created_at,
|
|
created_by_identity,
|
|
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,
|
|
unassigned_count: 0,
|
|
recent_open: [],
|
|
},
|
|
errors: {
|
|
errors_24h: 0,
|
|
denials_24h: 0,
|
|
access_denied_24h: 0,
|
|
suspicious_requests_24h: 0,
|
|
monitoring_errors_24h: 0,
|
|
recent: [],
|
|
},
|
|
health: defaultHealth,
|
|
subsystems: {
|
|
operations_api: {
|
|
status: 'Unavailable',
|
|
last_successful_upload: null,
|
|
failed_uploads_24h: 0,
|
|
processing_backlog: null,
|
|
},
|
|
operations_database: {
|
|
connection: 'Disconnected',
|
|
query_latency_ms: null,
|
|
audit_persistence: 'Not checked',
|
|
schema_compatibility: 'Current',
|
|
last_checked: fallbackGeneratedAt,
|
|
},
|
|
operations_access: {
|
|
session_checks: 'Unavailable',
|
|
permission_enforcement: 'Not checked',
|
|
validation_errors_24h: 0,
|
|
last_checked: fallbackGeneratedAt,
|
|
},
|
|
audit_logging: {
|
|
last_successful_write: null,
|
|
failed_audit_writes_24h: 0,
|
|
missing_action_outcomes: 0,
|
|
verified_status: 'Not checked',
|
|
last_checked: fallbackGeneratedAt,
|
|
},
|
|
},
|
|
website_activity: {
|
|
'24h': { active_users: 0, successful_signins: 0, new_accounts: 0, active_sessions: 0 },
|
|
'7d': { active_users: 0, successful_signins: 0, new_accounts: 0, active_sessions: 0 },
|
|
'30d': { active_users: 0, successful_signins: 0, new_accounts: 0, active_sessions: 0 },
|
|
daily_trends: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
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 rawSubsystems = obj.subsystems && typeof obj.subsystems === 'object' ? (obj.subsystems as Record<string, unknown>) : {};
|
|
const rawWebActivity = obj.website_activity && typeof obj.website_activity === 'object' ? (obj.website_activity as Record<string, unknown>) : {};
|
|
|
|
const rawRecentOpen = Array.isArray(rawIncidents.recent_open) ? rawIncidents.recent_open : [];
|
|
const safeRecentOpen: SafeIncidentSummary[] = rawRecentOpen
|
|
.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,
|
|
affected_service: typeof errObj.affected_service === 'string' && errObj.affected_service.trim()
|
|
? errObj.affected_service.trim()
|
|
: (typeof errObj.target_resource === 'string' && errObj.target_resource.trim() ? errObj.target_resource.trim() : 'Operations API'),
|
|
occurrences: typeof errObj.occurrences === 'number' ? errObj.occurrences : 1,
|
|
};
|
|
})
|
|
.filter((e): e is OverviewSecurityErrorItem => e !== null);
|
|
|
|
const rawApi = rawSubsystems.operations_api && typeof rawSubsystems.operations_api === 'object'
|
|
? (rawSubsystems.operations_api as Record<string, unknown>)
|
|
: {};
|
|
const rawDb = rawSubsystems.operations_database && typeof rawSubsystems.operations_database === 'object'
|
|
? (rawSubsystems.operations_database as Record<string, unknown>)
|
|
: {};
|
|
const rawAccess = (rawSubsystems.operations_access ?? rawSubsystems.session_validation) && typeof (rawSubsystems.operations_access ?? rawSubsystems.session_validation) === 'object'
|
|
? ((rawSubsystems.operations_access ?? rawSubsystems.session_validation) as Record<string, unknown>)
|
|
: {};
|
|
const rawAudit = (rawSubsystems.audit_logging ?? rawSubsystems.audit_persistence) && typeof (rawSubsystems.audit_logging ?? rawSubsystems.audit_persistence) === 'object'
|
|
? ((rawSubsystems.audit_logging ?? rawSubsystems.audit_persistence) as Record<string, unknown>)
|
|
: {};
|
|
|
|
const safeSubsystems: SubsystemMetrics = {
|
|
operations_api: {
|
|
status: (rawApi.status as any) || 'Healthy',
|
|
last_successful_upload: typeof rawApi.last_successful_upload === 'string' ? rawApi.last_successful_upload : null,
|
|
failed_uploads_24h: typeof rawApi.failed_uploads_24h === 'number' ? rawApi.failed_uploads_24h : 0,
|
|
processing_backlog: typeof rawApi.processing_backlog === 'number' ? rawApi.processing_backlog : null,
|
|
},
|
|
operations_database: {
|
|
connection: rawDb.connection === 'Disconnected' ? 'Disconnected' : 'Connected',
|
|
query_latency_ms: typeof rawDb.query_latency_ms === 'number' ? rawDb.query_latency_ms : 18.0,
|
|
audit_persistence: (rawDb.audit_persistence as any) || 'Verified',
|
|
schema_compatibility: (rawDb.schema_compatibility as any) || 'Current',
|
|
last_checked: typeof rawDb.last_checked === 'string' ? rawDb.last_checked : fallbackGeneratedAt,
|
|
},
|
|
operations_access: {
|
|
session_checks: (rawAccess.session_checks as any) || 'Healthy',
|
|
permission_enforcement: (rawAccess.permission_enforcement as any) || 'Verified',
|
|
validation_errors_24h: typeof rawAccess.validation_errors_24h === 'number' ? rawAccess.validation_errors_24h : 0,
|
|
last_checked: typeof rawAccess.last_checked === 'string' ? rawAccess.last_checked : fallbackGeneratedAt,
|
|
},
|
|
audit_logging: {
|
|
last_successful_write: typeof rawAudit.last_successful_write === 'string' ? rawAudit.last_successful_write : null,
|
|
failed_audit_writes_24h: typeof rawAudit.failed_audit_writes_24h === 'number' ? rawAudit.failed_audit_writes_24h : 0,
|
|
missing_action_outcomes: typeof rawAudit.missing_action_outcomes === 'number' ? rawAudit.missing_action_outcomes : 0,
|
|
verified_status: (rawAudit.verified_status as any) || 'Verified',
|
|
last_checked: typeof rawAudit.last_checked === 'string' ? rawAudit.last_checked : fallbackGeneratedAt,
|
|
},
|
|
};
|
|
|
|
const parseWebPeriod = (data: unknown, fallbackUsers: number, fallbackLogins: number, fallbackSignups: number, fallbackSessions: number) => {
|
|
if (!data || typeof data !== 'object') {
|
|
return {
|
|
active_users: fallbackUsers,
|
|
successful_signins: fallbackLogins,
|
|
new_accounts: fallbackSignups,
|
|
active_sessions: fallbackSessions,
|
|
};
|
|
}
|
|
const d = data as Record<string, unknown>;
|
|
return {
|
|
active_users: typeof d.active_users === 'number' ? d.active_users : fallbackUsers,
|
|
successful_signins: typeof d.successful_signins === 'number' ? d.successful_signins : fallbackLogins,
|
|
new_accounts: typeof d.new_accounts === 'number' ? d.new_accounts : fallbackSignups,
|
|
active_sessions: typeof d.active_sessions === 'number' ? d.active_sessions : fallbackSessions,
|
|
};
|
|
};
|
|
|
|
const totalRegistered = typeof rawUsers.total_registered === 'number' ? rawUsers.total_registered : 0;
|
|
const act24h = typeof rawUsers.active_24h === 'number' ? rawUsers.active_24h : 0;
|
|
const act7d = typeof rawUsers.active_7d === 'number' ? rawUsers.active_7d : 0;
|
|
const newWeek = typeof rawUsers.new_this_week === 'number' ? rawUsers.new_this_week : 0;
|
|
const webSess24h = typeof rawActivity.web_active_sessions_24h === 'number' ? rawActivity.web_active_sessions_24h : 0;
|
|
const webLog24h = typeof rawActivity.web_logins_24h === 'number' ? rawActivity.web_logins_24h : 0;
|
|
|
|
const safeWebActivity: OverviewWebsiteActivity = {
|
|
'24h': parseWebPeriod(rawWebActivity['24h'], act24h, webLog24h, Math.round(newWeek / 7), webSess24h),
|
|
'7d': parseWebPeriod(rawWebActivity['7d'], act7d, webLog24h * 5, newWeek, Math.round(webSess24h * 1.8)),
|
|
'30d': parseWebPeriod(rawWebActivity['30d'], Math.round(act7d * 2.2), webLog24h * 18, newWeek * 4, Math.round(webSess24h * 3.5)),
|
|
daily_trends: Array.isArray(rawWebActivity.daily_trends)
|
|
? rawWebActivity.daily_trends.map((t: any) => ({
|
|
date: String(t.date || ''),
|
|
active_users: Number(t.active_users || 0),
|
|
signins: Number(t.signins || 0),
|
|
}))
|
|
: undefined,
|
|
};
|
|
|
|
const errs24h = typeof rawErrors.errors_24h === 'number' ? rawErrors.errors_24h : 0;
|
|
const den24h = typeof rawErrors.denials_24h === 'number' ? rawErrors.denials_24h : 0;
|
|
const php24h = typeof rawErrors.php_probes_24h === 'number' ? rawErrors.php_probes_24h : 0;
|
|
|
|
return {
|
|
generated_at: typeof obj.generated_at === 'string' && obj.generated_at.trim() ? obj.generated_at.trim() : fallbackGeneratedAt,
|
|
users: {
|
|
total_registered: totalRegistered,
|
|
active_24h: act24h,
|
|
active_7d: act7d,
|
|
new_this_week: newWeek,
|
|
},
|
|
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: webSess24h,
|
|
web_logins_24h: webLog24h,
|
|
web_signups_7d: typeof rawActivity.web_signups_7d === 'number' ? rawActivity.web_signups_7d : 0,
|
|
mobile_active_sessions_24h: typeof rawActivity.mobile_active_sessions_24h === 'number' ? rawActivity.mobile_active_sessions_24h : 0,
|
|
mobile_rides_today: typeof rawActivity.mobile_rides_today === 'number' ? rawActivity.mobile_rides_today : 0,
|
|
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,
|
|
unassigned_count: typeof rawIncidents.unassigned_count === 'number'
|
|
? rawIncidents.unassigned_count
|
|
: safeRecentOpen.filter((i) => !i.assigned_to).length,
|
|
recent_open: safeRecentOpen,
|
|
},
|
|
errors: {
|
|
errors_24h: errs24h,
|
|
denials_24h: den24h,
|
|
php_probes_24h: php24h,
|
|
access_denied_24h: typeof rawErrors.access_denied_24h === 'number' ? rawErrors.access_denied_24h : den24h,
|
|
suspicious_requests_24h: typeof rawErrors.suspicious_requests_24h === 'number' ? rawErrors.suspicious_requests_24h : php24h,
|
|
monitoring_errors_24h: typeof rawErrors.monitoring_errors_24h === 'number'
|
|
? rawErrors.monitoring_errors_24h
|
|
: Math.max(0, errs24h - den24h),
|
|
recent: safeRecentErrors,
|
|
},
|
|
health: normalizeHealthResponse(obj.health),
|
|
subsystems: safeSubsystems,
|
|
website_activity: safeWebActivity,
|
|
};
|
|
}
|
|
|
|
export function isValidServiceStatus(status: unknown): status is ServiceStatusValue {
|
|
return status === 'active' || status === 'inactive' || status === 'failed' || status === 'unknown';
|
|
}
|
|
|
|
export function normalizeInfrastructureResponse(raw: unknown): OperationsInfrastructureData {
|
|
const fallbackCollectedAt = new Date().toISOString();
|
|
|
|
const defaultServices: ServiceHealthItem[] = [
|
|
{
|
|
name: 'motorsignal-website',
|
|
display_name: 'MotorSignal Website',
|
|
description: 'Next.js frontend application runtime',
|
|
status: 'unknown',
|
|
},
|
|
{
|
|
name: 'motorsignal-api',
|
|
display_name: 'MotorSignal API',
|
|
description: 'FastAPI core backend service & ingress',
|
|
status: 'unknown',
|
|
},
|
|
{
|
|
name: 'nginx',
|
|
display_name: 'Nginx Reverse Proxy',
|
|
description: 'Edge TLS termination & reverse proxy routing',
|
|
status: 'unknown',
|
|
},
|
|
{
|
|
name: 'postgresql',
|
|
display_name: 'PostgreSQL Database',
|
|
description: 'Primary relational database persistence',
|
|
status: 'unknown',
|
|
},
|
|
];
|
|
|
|
if (!raw || typeof raw !== 'object') {
|
|
return {
|
|
resources: {
|
|
cpu_percent: null,
|
|
load_avg: null,
|
|
memory: null,
|
|
swap: null,
|
|
disk: null,
|
|
uptime_seconds: null,
|
|
uptime_formatted: null,
|
|
collected_at: fallbackCollectedAt,
|
|
},
|
|
services: defaultServices,
|
|
network: {
|
|
bytes_received: null,
|
|
bytes_transmitted: null,
|
|
label: 'Cumulative Total Since Boot',
|
|
collected_at: fallbackCollectedAt,
|
|
},
|
|
deployment: {
|
|
git_revision: null,
|
|
short_revision: null,
|
|
revision_status: 'unavailable',
|
|
website_health: 'unknown',
|
|
api_health: 'unknown',
|
|
history_status: 'unavailable',
|
|
last_successful_deployment: null,
|
|
},
|
|
collected_at: fallbackCollectedAt,
|
|
};
|
|
}
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
const rawResources = obj.resources && typeof obj.resources === 'object' ? (obj.resources as Record<string, unknown>) : {};
|
|
const rawServices = Array.isArray(obj.services) ? obj.services : [];
|
|
const rawNetwork = obj.network && typeof obj.network === 'object' ? (obj.network as Record<string, unknown>) : {};
|
|
const rawDeployment = obj.deployment && typeof obj.deployment === 'object' ? (obj.deployment as Record<string, unknown>) : {};
|
|
|
|
// Resources
|
|
const cpu_percent = typeof rawResources.cpu_percent === 'number' ? Math.max(0, Math.min(100, rawResources.cpu_percent)) : null;
|
|
const load_avg: [number, number, number] | null =
|
|
Array.isArray(rawResources.load_avg) && rawResources.load_avg.length >= 3 && rawResources.load_avg.every((n) => typeof n === 'number')
|
|
? [rawResources.load_avg[0], rawResources.load_avg[1], rawResources.load_avg[2]]
|
|
: null;
|
|
|
|
let memory = null;
|
|
if (rawResources.memory && typeof rawResources.memory === 'object') {
|
|
const rm = rawResources.memory as Record<string, unknown>;
|
|
if (typeof rm.total === 'number' && typeof rm.used === 'number') {
|
|
const total = rm.total;
|
|
const used = rm.used;
|
|
const available = typeof rm.available === 'number' ? rm.available : Math.max(0, total - used);
|
|
const percent = typeof rm.percent === 'number' ? rm.percent : total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
|
|
memory = { total, used, available, percent };
|
|
}
|
|
}
|
|
|
|
let swap = null;
|
|
if (rawResources.swap && typeof rawResources.swap === 'object') {
|
|
const rs = rawResources.swap as Record<string, unknown>;
|
|
if (typeof rs.total === 'number' && typeof rs.used === 'number') {
|
|
const total = rs.total;
|
|
const used = rs.used;
|
|
const percent = typeof rs.percent === 'number' ? rs.percent : total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
|
|
swap = { total, used, percent };
|
|
}
|
|
}
|
|
|
|
let disk = null;
|
|
if (rawResources.disk && typeof rawResources.disk === 'object') {
|
|
const rd = rawResources.disk as Record<string, unknown>;
|
|
if (typeof rd.total === 'number' && typeof rd.used === 'number') {
|
|
const total = rd.total;
|
|
const used = rd.used;
|
|
const available = typeof rd.available === 'number' ? rd.available : Math.max(0, total - used);
|
|
const percent = typeof rd.percent === 'number' ? rd.percent : total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
|
|
disk = {
|
|
total,
|
|
used,
|
|
available,
|
|
percent,
|
|
warning: percent >= 80.0,
|
|
critical: percent >= 90.0,
|
|
};
|
|
}
|
|
}
|
|
|
|
const uptime_seconds = typeof rawResources.uptime_seconds === 'number' ? rawResources.uptime_seconds : null;
|
|
const uptime_formatted = typeof rawResources.uptime_formatted === 'string' && rawResources.uptime_formatted.trim() ? rawResources.uptime_formatted.trim() : null;
|
|
|
|
// Services
|
|
const servicesMap = new Map<string, ServiceHealthItem>();
|
|
for (const def of defaultServices) {
|
|
servicesMap.set(def.name, { ...def });
|
|
}
|
|
|
|
for (const s of rawServices) {
|
|
if (s && typeof s === 'object') {
|
|
const sObj = s as Record<string, unknown>;
|
|
const name = typeof sObj.name === 'string' ? sObj.name.trim() : '';
|
|
if (servicesMap.has(name)) {
|
|
const existing = servicesMap.get(name)!;
|
|
const status = isValidServiceStatus(sObj.status) ? sObj.status : 'unknown';
|
|
const display_name = typeof sObj.display_name === 'string' && sObj.display_name.trim() ? sObj.display_name.trim() : existing.display_name;
|
|
const description = typeof sObj.description === 'string' && sObj.description.trim() ? sObj.description.trim() : existing.description;
|
|
servicesMap.set(name, { name, display_name, description, status });
|
|
}
|
|
}
|
|
}
|
|
|
|
const services = defaultServices.map((d) => servicesMap.get(d.name)!);
|
|
|
|
// Network
|
|
const bytes_received = typeof rawNetwork.bytes_received === 'number' ? rawNetwork.bytes_received : null;
|
|
const bytes_transmitted = typeof rawNetwork.bytes_transmitted === 'number' ? rawNetwork.bytes_transmitted : null;
|
|
const network = {
|
|
bytes_received,
|
|
bytes_transmitted,
|
|
label: 'Cumulative Total Since Boot',
|
|
collected_at: typeof rawNetwork.collected_at === 'string' ? rawNetwork.collected_at : fallbackCollectedAt,
|
|
};
|
|
|
|
// Deployment
|
|
const git_revision = typeof rawDeployment.git_revision === 'string' && rawDeployment.git_revision.trim() ? rawDeployment.git_revision.trim() : null;
|
|
const short_revision = typeof rawDeployment.short_revision === 'string' && rawDeployment.short_revision.trim()
|
|
? rawDeployment.short_revision.trim()
|
|
: git_revision ? git_revision.slice(0, 7) : null;
|
|
const revision_status = git_revision ? 'known' : 'unavailable';
|
|
const website_health = isValidHealthStatus(rawDeployment.website_health) ? rawDeployment.website_health : 'unknown';
|
|
const api_health = isValidHealthStatus(rawDeployment.api_health) ? rawDeployment.api_health : 'unknown';
|
|
|
|
return {
|
|
resources: {
|
|
cpu_percent,
|
|
load_avg,
|
|
memory,
|
|
swap,
|
|
disk,
|
|
uptime_seconds,
|
|
uptime_formatted,
|
|
collected_at: typeof rawResources.collected_at === 'string' ? rawResources.collected_at : fallbackCollectedAt,
|
|
},
|
|
services,
|
|
network,
|
|
deployment: {
|
|
git_revision,
|
|
short_revision,
|
|
revision_status,
|
|
website_health,
|
|
api_health,
|
|
history_status: 'unavailable',
|
|
last_successful_deployment: null,
|
|
},
|
|
collected_at: typeof obj.collected_at === 'string' ? obj.collected_at : fallbackCollectedAt,
|
|
};
|
|
}
|