140 lines
7.2 KiB
TypeScript
140 lines
7.2 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Clock3, RefreshCw } from "lucide-react";
|
|
|
|
import { api, ApiError } from "../lib/api";
|
|
import type {
|
|
ArtifactJob, BackupSet, CapacitySnapshot, CapabilityDeployment, CompatibilityAssessment, GatewayRequest, OperationalAlert,
|
|
HardwareState, MigrationCutoverOperation, MigrationPlan, RecoveryDashboard, RuntimeProbe, SchedulerBudget,
|
|
SecretSlotCatalog,
|
|
} from "../types";
|
|
import type { ProjectWorkspaceView } from "../model-management-types";
|
|
import { diagnoseTroubleshooting, type EvidenceRead, type TroubleshootingCheck, type TroubleshootingEvidence } from "../lib/troubleshooting";
|
|
|
|
const readFailed = <T,>(): EvidenceRead<T> => ({ state: "unknown", reason: "read_failed" });
|
|
const adminRequired = <T,>(): EvidenceRead<T> => ({ state: "unknown", reason: "admin_required" });
|
|
const identityUnavailable = <T,>(): EvidenceRead<T> => ({ state: "unknown", reason: "identity_unavailable" });
|
|
|
|
async function read<T>(request: () => Promise<T>): Promise<EvidenceRead<T>> {
|
|
try { return { state: "loaded", value: await request() }; }
|
|
catch (cause) {
|
|
if (cause instanceof ApiError) {
|
|
return {
|
|
state: "unknown",
|
|
reason: "read_failed",
|
|
errorCode: cause.code,
|
|
correlationId: cause.correlationId,
|
|
};
|
|
}
|
|
return readFailed();
|
|
}
|
|
}
|
|
|
|
function unavailableEvidence(reason: "read_failed" | "admin_required" | "identity_unavailable"): TroubleshootingEvidence {
|
|
const result = <T,>(): EvidenceRead<T> => ({ state: "unknown", reason });
|
|
return {
|
|
hardware: result<HardwareState>(), capacity: result<CapacitySnapshot[]>(), scheduler: result<SchedulerBudget[]>(),
|
|
artifactJobs: result<ArtifactJob[]>(), operationalAlerts: result<OperationalAlert[]>(),
|
|
assessments: result<CompatibilityAssessment[]>(), probes: result<RuntimeProbe[]>(),
|
|
deployments: result<CapabilityDeployment[]>(), projects: result<ProjectWorkspaceView[]>(), secretSlots: result<SecretSlotCatalog>(),
|
|
gatewayRequests: result<GatewayRequest[]>(), backups: result<BackupSet[]>(), recovery: result<RecoveryDashboard>(),
|
|
migrations: result<MigrationPlan[]>(), cutovers: result<MigrationCutoverOperation[]>(),
|
|
};
|
|
}
|
|
|
|
async function loadEvidence(token: string): Promise<TroubleshootingEvidence> {
|
|
if (!token) return unavailableEvidence("read_failed");
|
|
|
|
const [identity, common] = await Promise.all([
|
|
read(() => api.verifyOperatorCredential(token)),
|
|
Promise.all([
|
|
read(() => api.hardware()),
|
|
read(() => api.scheduler()),
|
|
read(() => api.artifactJobs()),
|
|
read(() => api.compatibilityAssessments()),
|
|
read(() => api.runtimeProbes()),
|
|
read(() => api.capabilityDeployments()),
|
|
read(() => api.projectWorkspace()),
|
|
read(() => api.secretSlots()),
|
|
]),
|
|
]);
|
|
|
|
let capacity: EvidenceRead<CapacitySnapshot[]>;
|
|
let operationalAlerts: EvidenceRead<OperationalAlert[]>;
|
|
let gatewayRequests: EvidenceRead<GatewayRequest[]>;
|
|
let backups: EvidenceRead<BackupSet[]>;
|
|
let recovery: EvidenceRead<RecoveryDashboard>;
|
|
let migrations: EvidenceRead<MigrationPlan[]>;
|
|
let cutovers: EvidenceRead<MigrationCutoverOperation[]>;
|
|
if (identity.state === "loaded" && identity.value.role === "admin") {
|
|
[capacity, operationalAlerts, gatewayRequests, backups, recovery, migrations, cutovers] = await Promise.all([
|
|
read(() => api.capacitySnapshots(token)),
|
|
read(() => api.operationalAlerts(token)),
|
|
read(() => api.gatewayRequests(token)),
|
|
read(() => api.backupSets(token)),
|
|
read(() => api.recoveryDashboard(token)),
|
|
read(() => api.migrationPlans(token)),
|
|
read(() => api.migrationCutovers(token)),
|
|
]);
|
|
} else if (identity.state === "loaded") {
|
|
capacity = adminRequired(); operationalAlerts = adminRequired(); gatewayRequests = adminRequired(); backups = adminRequired();
|
|
recovery = adminRequired(); migrations = adminRequired(); cutovers = adminRequired();
|
|
} else {
|
|
capacity = identityUnavailable(); operationalAlerts = identityUnavailable(); gatewayRequests = identityUnavailable(); backups = identityUnavailable();
|
|
recovery = identityUnavailable(); migrations = identityUnavailable(); cutovers = identityUnavailable();
|
|
}
|
|
|
|
const [hardware, scheduler, artifactJobs, assessments, probes, deployments, projects, secretSlots] = common;
|
|
return {
|
|
hardware, capacity, scheduler, artifactJobs, operationalAlerts, assessments, probes, deployments, projects, secretSlots,
|
|
gatewayRequests, backups, recovery, migrations, cutovers,
|
|
};
|
|
}
|
|
|
|
function stateClass(value: string): string { return value.toLowerCase().replaceAll("_", "-"); }
|
|
|
|
export function TroubleshootingPanel({ token }: { token: string }) {
|
|
const [checks, setChecks] = useState<TroubleshootingCheck[] | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const requestVersion = useRef(0);
|
|
|
|
const refresh = useCallback(async () => {
|
|
const version = ++requestVersion.current;
|
|
setLoading(true);
|
|
try {
|
|
const evidence = await loadEvidence(token);
|
|
let nextChecks: TroubleshootingCheck[];
|
|
try { nextChecks = diagnoseTroubleshooting(evidence); }
|
|
catch { nextChecks = diagnoseTroubleshooting(unavailableEvidence("read_failed")); }
|
|
if (requestVersion.current === version) setChecks(nextChecks);
|
|
} finally {
|
|
if (requestVersion.current === version) setLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
return () => { requestVersion.current += 1; };
|
|
}, [refresh, refreshKey]);
|
|
|
|
return <section className="troubleshooting-stack" aria-labelledby="troubleshooting-title">
|
|
<div className="operations-section-title">
|
|
<div><span className="eyebrow">READ-ONLY EVIDENCE</span><h2 id="troubleshooting-title">Troubleshooting</h2>
|
|
<p>Findings come from typed node, capacity, artifact, runtime, credential, backup, and migration records. No finding is not proof of health; unavailable or unauthorized sources stay unknown.</p>
|
|
</div>
|
|
<button type="button" onClick={() => setRefreshKey((value) => value + 1)} disabled={loading || !token}>
|
|
<RefreshCw size={15} className={loading ? "spin" : ""} />{loading ? "Reading evidence…" : "Refresh diagnosis"}
|
|
</button>
|
|
</div>
|
|
{checks ? <div className="operations-grid troubleshooting-grid">
|
|
{checks.map((check) => <article className="panel operation-card troubleshooting-card" key={check.id}>
|
|
<div><span className="eyebrow">{check.title}</span><span className={`status-chip ${stateClass(check.state)}`}>{check.state.replaceAll("_", " ")}</span></div>
|
|
<h3>{check.state === "ISSUE_OBSERVED" ? "Typed issue observed" : check.state === "UNKNOWN" ? "State unknown" : "No typed failure observed"}</h3>
|
|
<ul className="troubleshooting-evidence">{check.evidence.map((item, index) => <li key={`${check.id}-${index}`}>{item}</li>)}</ul>
|
|
<p><strong>Next action:</strong> {check.nextAction}</p>
|
|
<a href={check.runbook.href} target="_blank" rel="noreferrer">{check.runbook.label}</a>
|
|
</article>)}
|
|
</div> : <div className="state-card" role="status"><Clock3 size={18} />{loading ? "Reading typed operational evidence…" : "No operational evidence has been loaded."}</div>}
|
|
</section>;
|
|
}
|