3740 lines
134 KiB
TypeScript
3740 lines
134 KiB
TypeScript
import {
|
||
FormEvent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
Background,
|
||
Controls,
|
||
MarkerType,
|
||
MiniMap,
|
||
ReactFlow,
|
||
type Connection,
|
||
type Edge,
|
||
type ReactFlowInstance,
|
||
} from "@xyflow/react";
|
||
import "@xyflow/react/dist/style.css";
|
||
|
||
import {
|
||
api,
|
||
type AvailabilitySummary,
|
||
type ComponentRole,
|
||
type Criticality,
|
||
type DiscoveryResult,
|
||
type DiscoveryRun,
|
||
type DiscoveryStatus,
|
||
type DiscoveryObservation,
|
||
type RelationEvidence,
|
||
type ProbeRepairReport,
|
||
type TopologySuggestion,
|
||
type TopologyDiff,
|
||
type TopologySnapshot,
|
||
type ProviderStatus,
|
||
type DemoState,
|
||
type DockerCandidate,
|
||
type EventRecord,
|
||
type HealthStatus,
|
||
type IncidentDetail,
|
||
type IncidentOverview,
|
||
type IncidentRecord,
|
||
type MonitoringState,
|
||
type ServiceInput,
|
||
type ServiceKind,
|
||
type ServiceSnapshot,
|
||
type SemanticTopology,
|
||
} from "./api";
|
||
import { LivingEdge } from "./LivingEdge";
|
||
import { DemoWalkthrough } from "./demo/DemoWalkthrough";
|
||
import { ServiceNodeView, type ServiceNode, type ServiceNodeData } from "./topology/ServiceNode";
|
||
import {
|
||
applicationEdges,
|
||
applicationName,
|
||
applicationTopologyLayout,
|
||
topologyLayout,
|
||
type LegacyTopologyMode,
|
||
} from "./topology/legacyLayout";
|
||
export {
|
||
applicationEdges,
|
||
applicationTopologyLayout,
|
||
landscapeGroups,
|
||
topologyLayout,
|
||
} from "./topology/legacyLayout";
|
||
import { semanticTopologyGraph, type SemanticView } from "./topology/semanticGraph";
|
||
import { TopologyCommandBar } from "./topology/TopologyCommandBar";
|
||
import { arrangeTopology, topologyStructureKey } from "./topologyLayout";
|
||
import { useRealtimeEvents } from "./realtime/useRealtimeEvents";
|
||
import { changedFields, filterSuggestions } from "./auditViewModel";
|
||
import {
|
||
applicationLocation,
|
||
domainLocation,
|
||
isTopologyRoot,
|
||
topologyRoot,
|
||
topologyUp,
|
||
type TopologyLocation,
|
||
} from "./topologyNavigation";
|
||
|
||
type Panel =
|
||
| "overview"
|
||
| "topology"
|
||
| "incidents"
|
||
| "services"
|
||
| "discovery"
|
||
| "changes"
|
||
| "config";
|
||
type FilterStatus = "all" | "unresolved" | HealthStatus;
|
||
type TopologyMode = LegacyTopologyMode;
|
||
type ServiceForm = Omit<ServiceInput, "tags"> & {
|
||
depends_on: string[];
|
||
tags: string;
|
||
};
|
||
type MonitoringFilter = "all" | MonitoringState;
|
||
|
||
const emptyForm = (): ServiceForm => ({
|
||
name: "",
|
||
kind: "http",
|
||
url: "",
|
||
host: "",
|
||
port: undefined,
|
||
depends_on: [],
|
||
tags: "",
|
||
group: "",
|
||
environment: "production",
|
||
application: "",
|
||
domain: "",
|
||
component_role: "unknown",
|
||
application_locked: false,
|
||
domain_locked: false,
|
||
component_role_locked: false,
|
||
criticality: "medium",
|
||
description: "",
|
||
category: "unknown",
|
||
visibility: "visible",
|
||
lifecycle: "active",
|
||
enabled: true,
|
||
monitoring_enabled: true,
|
||
interval_seconds: undefined,
|
||
timeout_seconds: undefined,
|
||
retries: undefined,
|
||
failure_threshold: 1,
|
||
recovery_threshold: 1,
|
||
availability_target: undefined,
|
||
maintenance_enabled: false,
|
||
});
|
||
|
||
const effectiveStatus = (snapshot: ServiceSnapshot): HealthStatus =>
|
||
snapshot.effective_status ?? snapshot.latest_probe?.status ?? "unknown";
|
||
const statusLabel = (status: HealthStatus) =>
|
||
status === "unknown"
|
||
? "Health unknown"
|
||
: status === "unmonitored"
|
||
? "Unmonitored"
|
||
: status;
|
||
const statusIcon = (status: HealthStatus) =>
|
||
({
|
||
healthy: "✓",
|
||
degraded: "△",
|
||
down: "!",
|
||
recovering: "↗",
|
||
maintenance: "Ⅱ",
|
||
unknown: "?",
|
||
unmonitored: "○",
|
||
})[status];
|
||
const formatDate = (value: string | null | undefined) =>
|
||
value ? new Date(value).toLocaleString() : "—";
|
||
const monitoringLabel = (state: MonitoringState) =>
|
||
({
|
||
active_probe: "Active probe",
|
||
docker_health: "Docker health",
|
||
appops_observation: "AppOps observed",
|
||
observed: "Runtime observed",
|
||
unmonitored: "Not monitored",
|
||
disabled: "Monitoring disabled",
|
||
})[state];
|
||
const hasCurrentNetworkProbe = (snapshot: ServiceSnapshot) =>
|
||
snapshot.service.monitoring_state === "active_probe";
|
||
|
||
const nodeTypes = { service: ServiceNodeView };
|
||
const edgeTypes = { living: LivingEdge };
|
||
|
||
function downloadText(filename: string, content: string, type: string) {
|
||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = filename;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
export default function App() {
|
||
const [services, setServices] = useState<ServiceSnapshot[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
const [busyAll, setBusyAll] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
const [incidentSearch, setIncidentSearch] = useState("");
|
||
const [incidentStatusFilter, setIncidentStatusFilter] = useState("active");
|
||
const [suggestionSearch, setSuggestionSearch] = useState("");
|
||
const [suggestionConfidence, setSuggestionConfidence] = useState("actionable");
|
||
const [suggestionType, setSuggestionType] = useState("all");
|
||
const [suggestionLimit, setSuggestionLimit] = useState(30);
|
||
const [changeLimit, setChangeLimit] = useState(30);
|
||
const [reconcileConfirm, setReconcileConfirm] = useState(false);
|
||
const [panel, setPanel] = useState<Panel>("topology");
|
||
const [form, setForm] = useState<ServiceForm>(emptyForm);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [formOpen, setFormOpen] = useState(false);
|
||
const [history, setHistory] = useState<import("./api").ProbeResult[]>([]);
|
||
const [historyServiceId, setHistoryServiceId] = useState<string | null>(null);
|
||
const [availability, setAvailability] = useState<
|
||
Record<number, AvailabilitySummary | null>
|
||
>({ 24: null, 168: null, 720: null });
|
||
const [incident, setIncident] = useState<IncidentOverview | null>(null);
|
||
const [overview, setOverview] = useState<
|
||
import("./api").OverviewSummary | null
|
||
>(null);
|
||
const [semanticTopology, setSemanticTopology] =
|
||
useState<SemanticTopology | null>(null);
|
||
const [events, setEvents] = useState<EventRecord[]>([]);
|
||
const [demo, setDemo] = useState<DemoState | null>(null);
|
||
const [demoConfirm, setDemoConfirm] = useState(false);
|
||
const [demoBusy, setDemoBusy] = useState(false);
|
||
const [demoStep, setDemoStep] = useState(0);
|
||
const [configText, setConfigText] = useState("version: 1\nservices: []\n");
|
||
const [configFormat, setConfigFormat] = useState<"yaml" | "json">("yaml");
|
||
const [configMode, setConfigMode] = useState<"merge" | "replace">("merge");
|
||
const [dockerHost, setDockerHost] = useState("unix:///var/run/docker.sock");
|
||
const [dockerCandidates, setDockerCandidates] = useState<DockerCandidate[]>(
|
||
[],
|
||
);
|
||
const [dockerBusy, setDockerBusy] = useState(false);
|
||
const [appopsUrl, setAppopsUrl] = useState("");
|
||
const [discoveryRun, setDiscoveryRun] = useState<DiscoveryRun | null>(null);
|
||
const [discoveryStatus, setDiscoveryStatus] =
|
||
useState<DiscoveryStatus | null>(null);
|
||
const [discoveryItems, setDiscoveryItems] = useState<DiscoveryObservation[]>(
|
||
[],
|
||
);
|
||
const [discoverySuggestions, setDiscoverySuggestions] = useState<
|
||
TopologySuggestion[]
|
||
>([]);
|
||
const [discoveryBusy, setDiscoveryBusy] = useState(false);
|
||
const [probeRepair, setProbeRepair] = useState<ProbeRepairReport | null>(
|
||
null,
|
||
);
|
||
const [editMode, setEditMode] = useState(false);
|
||
const [showMiniMap, setShowMiniMap] = useState(false);
|
||
const [edgeEvidence, setEdgeEvidence] = useState<RelationEvidence[]>([]);
|
||
const [providerStatuses, setProviderStatuses] = useState<ProviderStatus[]>([]);
|
||
const [topologySnapshots, setTopologySnapshots] = useState<TopologySnapshot[]>([]);
|
||
const [topologyDiff, setTopologyDiff] = useState<TopologyDiff | null>(null);
|
||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | null>(null);
|
||
const [snapshotCheckedAt, setSnapshotCheckedAt] = useState(0);
|
||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||
const [search, setSearch] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState<FilterStatus>("all");
|
||
const [monitoringFilter, setMonitoringFilter] =
|
||
useState<MonitoringFilter>("all");
|
||
const [groupFilter, setGroupFilter] = useState("all");
|
||
const [environmentFilter, setEnvironmentFilter] = useState("all");
|
||
const [criticalityFilter, setCriticalityFilter] = useState("all");
|
||
const [domainFilter, setDomainFilter] = useState("all");
|
||
const [serviceApplicationFilter, setServiceApplicationFilter] =
|
||
useState("all");
|
||
const [lifecycleFilter, setLifecycleFilter] = useState("all");
|
||
const [focusMode, setFocusMode] = useState(false);
|
||
const [blastRadius, setBlastRadius] = useState(false);
|
||
const [topologyMode, setTopologyMode] = useState<TopologyMode>("landscape");
|
||
const [applicationFilter, setApplicationFilter] = useState("all");
|
||
const [componentScope, setComponentScope] = useState<{
|
||
label: string;
|
||
memberIds: string[];
|
||
} | null>(null);
|
||
const [topologyLocation, setTopologyLocation] = useState<TopologyLocation>(
|
||
topologyRoot,
|
||
);
|
||
// Compatibility adapter for legacy non-semantic controls. Navigation state
|
||
// itself remains the single TopologyLocation value above.
|
||
const semanticScope = topologyLocation;
|
||
const setSemanticScope = (
|
||
update:
|
||
| Pick<TopologyLocation, "domainId" | "applicationId">
|
||
| ((scope: TopologyLocation) => Pick<TopologyLocation, "domainId" | "applicationId">),
|
||
) => {
|
||
setTopologyLocation((current) => {
|
||
const next = typeof update === "function" ? update(current) : update;
|
||
if (!next.domainId) return topologyRoot();
|
||
return next.applicationId
|
||
? applicationLocation(next.domainId, next.applicationId)
|
||
: domainLocation(next.domainId);
|
||
});
|
||
};
|
||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||
const [selectedIncident, setSelectedIncident] =
|
||
useState<IncidentRecord | null>(null);
|
||
const [incidentDetail, setIncidentDetail] = useState<IncidentDetail | null>(
|
||
null,
|
||
);
|
||
const flowInstance = useRef<ReactFlowInstance<ServiceNode, Edge> | null>(
|
||
null,
|
||
);
|
||
const sidePanelRef = useRef<HTMLElement | null>(null);
|
||
const demoFocusApplied = useRef(false);
|
||
const [layoutPositions, setLayoutPositions] = useState<
|
||
Record<string, { x: number; y: number }>
|
||
>({});
|
||
const [layoutRoutes, setLayoutRoutes] = useState<
|
||
Record<string, { x: number; y: number }[]>
|
||
>({});
|
||
|
||
const refreshDiscovery = useCallback(async () => {
|
||
try {
|
||
const [status, items, suggestions, evidence, providers, snapshots] = await Promise.all([
|
||
api.discoveryStatus(),
|
||
api.discoveryItems(),
|
||
api.discoverySuggestions("open"),
|
||
api.topologyEdges(),
|
||
api.providers(),
|
||
api.topologySnapshots(),
|
||
]);
|
||
setDiscoveryStatus(status);
|
||
setDiscoveryRun(status.current ?? status.last_completed);
|
||
setDiscoveryItems(items);
|
||
setDiscoverySuggestions(suggestions);
|
||
setEdgeEvidence(evidence);
|
||
setProviderStatuses(providers);
|
||
setTopologySnapshots(snapshots);
|
||
setSnapshotCheckedAt(Date.now());
|
||
if (!snapshots.length) setTopologyDiff(null);
|
||
setSelectedSnapshotId((current) =>
|
||
current && snapshots.some((item) => item.id === current)
|
||
? current
|
||
: snapshots[0]?.id ?? null,
|
||
);
|
||
} catch (err) {
|
||
// Discovery is an optional enrichment path. Keep the primary topology usable
|
||
// when an older API or an unavailable source is encountered during rollout.
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to load discovery state",
|
||
);
|
||
}
|
||
}, []);
|
||
useEffect(() => {
|
||
if (!selectedSnapshotId) return;
|
||
const index = topologySnapshots.findIndex((item) => item.id === selectedSnapshotId);
|
||
if (index < 0) return;
|
||
let active = true;
|
||
void api.topologyDiff(selectedSnapshotId, topologySnapshots[index + 1]?.id)
|
||
.then((diff) => { if (active) setTopologyDiff(diff); })
|
||
.catch(() => { if (active) setTopologyDiff(null); });
|
||
return () => { active = false; };
|
||
}, [selectedSnapshotId, topologySnapshots]);
|
||
|
||
const refresh = useCallback(async () => {
|
||
try {
|
||
const [nextServices, nextIncident, nextDemo, nextOverview, nextEvents] =
|
||
await Promise.all([
|
||
api.listServices(),
|
||
api.incidents(),
|
||
api.demoState(),
|
||
api.overview(),
|
||
api.events(30),
|
||
]);
|
||
setServices(nextServices);
|
||
setSelectedId((current) =>
|
||
current && !nextServices.some((item) => item.service.id === current)
|
||
? null
|
||
: current,
|
||
);
|
||
setIncident(nextIncident);
|
||
setDemo(nextDemo);
|
||
if (!nextDemo.active) {
|
||
demoFocusApplied.current = false;
|
||
} else if (!demoFocusApplied.current && nextDemo.root_cause_service_id) {
|
||
setSelectedId(nextDemo.root_cause_service_id);
|
||
setPanel("topology");
|
||
demoFocusApplied.current = true;
|
||
}
|
||
if (!nextDemo.active && nextIncident.candidates[0]) {
|
||
setSelectedId((current) => current ?? nextIncident.candidates[0].service_id);
|
||
}
|
||
setOverview(nextOverview);
|
||
setEvents(nextEvents);
|
||
setError(null);
|
||
void refreshDiscovery();
|
||
void api
|
||
.semanticTopology()
|
||
.then(setSemanticTopology)
|
||
.catch(() => {
|
||
// The component-level graph remains available while a rolling deploy
|
||
// brings older API instances forward.
|
||
setSemanticTopology(null);
|
||
});
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to refresh ServiceMap",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [refreshDiscovery]);
|
||
|
||
const refreshTimerRef = useRef<number | undefined>(undefined);
|
||
const scheduleRefresh = useCallback(() => {
|
||
if (refreshTimerRef.current !== undefined) {
|
||
window.clearTimeout(refreshTimerRef.current);
|
||
}
|
||
refreshTimerRef.current = window.setTimeout(() => void refresh(), 180);
|
||
}, [refresh]);
|
||
useRealtimeEvents({ onEvent: scheduleRefresh, onFallback: scheduleRefresh });
|
||
|
||
useEffect(() => {
|
||
// This starts async API reads; refresh applies its state only after those reads settle.
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
void refresh();
|
||
return () => {
|
||
if (refreshTimerRef.current !== undefined) {
|
||
window.clearTimeout(refreshTimerRef.current);
|
||
}
|
||
};
|
||
}, [refresh]);
|
||
|
||
useEffect(() => {
|
||
if (selectedId || selectedEdgeId)
|
||
sidePanelRef.current?.scrollTo({ top: 0 });
|
||
}, [selectedId, selectedEdgeId, demo?.active]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedId || !services.some((item) => item.service.id === selectedId)) return;
|
||
let active = true;
|
||
void Promise.all([
|
||
api.history(selectedId, 48),
|
||
api.availability(selectedId, 24),
|
||
api.availability(selectedId, 168),
|
||
api.availability(selectedId, 720),
|
||
])
|
||
.then(([nextHistory, day, week, month]) => {
|
||
if (!active) return;
|
||
setHistory(nextHistory);
|
||
setAvailability({ 24: day, 168: week, 720: month });
|
||
setHistoryServiceId(selectedId);
|
||
})
|
||
.catch((err) => {
|
||
if (active)
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Failed to load service history",
|
||
);
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [selectedId, services]);
|
||
|
||
const groups = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(
|
||
services
|
||
.map((item) => item.service.group)
|
||
.filter(Boolean) as string[],
|
||
),
|
||
].sort(),
|
||
[services],
|
||
);
|
||
const applications = useMemo(
|
||
() => [...new Set(services.map(applicationName))].sort(),
|
||
[services],
|
||
);
|
||
const semanticDomainByService = useMemo(() => {
|
||
const names = new Map(
|
||
(semanticTopology?.domains ?? []).map((item) => [item.id, item.name]),
|
||
);
|
||
return new Map(
|
||
(semanticTopology?.components ?? []).map((item) => [
|
||
item.service_id,
|
||
names.get(item.domain_id) ?? null,
|
||
]),
|
||
);
|
||
}, [semanticTopology]);
|
||
const domains = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(
|
||
services
|
||
.map(
|
||
(item) =>
|
||
item.service.domain ?? semanticDomainByService.get(item.service.id),
|
||
)
|
||
.filter(Boolean) as string[],
|
||
),
|
||
].sort(),
|
||
[semanticDomainByService, services],
|
||
);
|
||
const environments = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(
|
||
services
|
||
.map((item) => item.service.environment)
|
||
.filter(Boolean) as string[],
|
||
),
|
||
].sort(),
|
||
[services],
|
||
);
|
||
const filteredServices = useMemo(() => {
|
||
const matchesFilter = (snapshot: ServiceSnapshot) => {
|
||
const name =
|
||
`${snapshot.service.name} ${snapshot.service.description ?? ""} ${snapshot.service.tags.join(" ")}`.toLowerCase();
|
||
return (
|
||
(!search || name.includes(search.toLowerCase())) &&
|
||
(statusFilter === "all" ||
|
||
(statusFilter === "unresolved"
|
||
? ["unknown", "unmonitored"].includes(effectiveStatus(snapshot))
|
||
: effectiveStatus(snapshot) === statusFilter)) &&
|
||
(monitoringFilter === "all" ||
|
||
snapshot.service.monitoring_state === monitoringFilter) &&
|
||
(groupFilter === "all" || snapshot.service.group === groupFilter) &&
|
||
(environmentFilter === "all" ||
|
||
snapshot.service.environment === environmentFilter) &&
|
||
(criticalityFilter === "all" ||
|
||
snapshot.service.criticality === criticalityFilter) &&
|
||
(domainFilter === "all" ||
|
||
(snapshot.service.domain ?? semanticDomainByService.get(snapshot.service.id)) ===
|
||
domainFilter) &&
|
||
(serviceApplicationFilter === "all" ||
|
||
applicationName(snapshot) === serviceApplicationFilter) &&
|
||
(lifecycleFilter === "all" ||
|
||
snapshot.service.lifecycle === lifecycleFilter)
|
||
);
|
||
};
|
||
return services.filter(matchesFilter);
|
||
}, [
|
||
services,
|
||
search,
|
||
statusFilter,
|
||
monitoringFilter,
|
||
groupFilter,
|
||
environmentFilter,
|
||
criticalityFilter,
|
||
domainFilter,
|
||
serviceApplicationFilter,
|
||
lifecycleFilter,
|
||
semanticDomainByService,
|
||
]);
|
||
const directDependents = useMemo(() => {
|
||
const result = new Map<string, string[]>();
|
||
for (const snapshot of services)
|
||
for (const dependencyId of snapshot.service.depends_on)
|
||
result.set(dependencyId, [
|
||
...(result.get(dependencyId) ?? []),
|
||
snapshot.service.id,
|
||
]);
|
||
return result;
|
||
}, [services]);
|
||
const relatedIds = useMemo(() => {
|
||
if (!selectedId || !focusMode) return null;
|
||
const result = new Set([selectedId]);
|
||
const visit = (id: string, depth: number) => {
|
||
if (depth <= 0) return;
|
||
const service = services.find((item) => item.service.id === id)?.service;
|
||
for (const dependencyId of service?.depends_on ?? []) {
|
||
result.add(dependencyId);
|
||
visit(dependencyId, depth - 1);
|
||
}
|
||
for (const dependentId of directDependents.get(id) ?? []) {
|
||
result.add(dependentId);
|
||
visit(dependentId, depth - 1);
|
||
}
|
||
};
|
||
visit(selectedId, 2);
|
||
return result;
|
||
}, [selectedId, focusMode, services, directDependents]);
|
||
const impactIds = useMemo(
|
||
() =>
|
||
new Set(
|
||
demo?.affected_service_ids ??
|
||
selectedIncident?.affected_service_ids ??
|
||
[],
|
||
),
|
||
[demo?.affected_service_ids, selectedIncident?.affected_service_ids],
|
||
);
|
||
const graphServices = useMemo(() => {
|
||
const base = filteredServices.filter(
|
||
(item) => !relatedIds || relatedIds.has(item.service.id),
|
||
);
|
||
if (!blastRadius) return base;
|
||
const allowed = new Set(
|
||
[
|
||
demo?.root_cause_service_id ?? selectedIncident?.root_service_id,
|
||
...impactIds,
|
||
].filter(Boolean),
|
||
);
|
||
return base.filter((item) => allowed.has(item.service.id));
|
||
}, [
|
||
filteredServices,
|
||
relatedIds,
|
||
blastRadius,
|
||
demo?.root_cause_service_id,
|
||
selectedIncident?.root_service_id,
|
||
impactIds,
|
||
]);
|
||
const componentServices = useMemo(() => {
|
||
if (topologyLocation.level !== "components") return graphServices;
|
||
if (componentScope) {
|
||
const members = new Set(componentScope.memberIds);
|
||
return graphServices.filter((item) => members.has(item.service.id));
|
||
}
|
||
return applicationFilter !== "all"
|
||
? graphServices.filter(
|
||
(item) => applicationName(item) === applicationFilter,
|
||
)
|
||
: graphServices;
|
||
}, [topologyLocation.level, applicationFilter, componentScope, graphServices]);
|
||
const activeGraphServices =
|
||
topologyLocation.level === "components" ? componentServices : graphServices;
|
||
const rootCauseId =
|
||
demo?.root_cause_service_id ?? selectedIncident?.root_service_id ?? null;
|
||
const legacyNodes = useMemo(
|
||
() =>
|
||
topologyLocation.level === "components"
|
||
? topologyLayout(activeGraphServices, rootCauseId, impactIds)
|
||
: applicationTopologyLayout(
|
||
activeGraphServices,
|
||
rootCauseId,
|
||
impactIds,
|
||
),
|
||
[topologyLocation.level, activeGraphServices, rootCauseId, impactIds],
|
||
);
|
||
const legacyEdges = useMemo<Edge[]>(
|
||
() =>
|
||
topologyLocation.level !== "components"
|
||
? applicationEdges(
|
||
activeGraphServices,
|
||
topologyMode,
|
||
impactIds,
|
||
rootCauseId,
|
||
)
|
||
: activeGraphServices.flatMap((snapshot) =>
|
||
snapshot.service.depends_on
|
||
.filter((dependencyId) =>
|
||
activeGraphServices.some(
|
||
(item) => item.service.id === dependencyId,
|
||
),
|
||
)
|
||
.map((dependencyId) => ({
|
||
id: `${dependencyId}-${snapshot.service.id}`,
|
||
source: dependencyId,
|
||
target: snapshot.service.id,
|
||
animated: effectiveStatus(snapshot) !== "healthy",
|
||
className: `edge-${effectiveStatus(snapshot)}${blastRadius && !impactIds.has(snapshot.service.id) ? " edge-dimmed" : ""}`,
|
||
markerEnd: { type: MarkerType.ArrowClosed },
|
||
})),
|
||
),
|
||
[
|
||
topologyLocation.level,
|
||
activeGraphServices,
|
||
impactIds,
|
||
blastRadius,
|
||
rootCauseId,
|
||
topologyMode,
|
||
],
|
||
);
|
||
const semanticView: SemanticView = topologyLocation.level;
|
||
const semanticGraph = useMemo(
|
||
() =>
|
||
semanticTopology
|
||
? semanticTopologyGraph(
|
||
semanticTopology,
|
||
semanticView,
|
||
semanticView === "components"
|
||
? topologyLocation.applicationId
|
||
: topologyLocation.domainId,
|
||
topologyMode === "impact" || blastRadius,
|
||
search,
|
||
statusFilter,
|
||
)
|
||
: null,
|
||
[
|
||
semanticTopology,
|
||
semanticView,
|
||
topologyLocation.domainId,
|
||
topologyLocation.applicationId,
|
||
topologyMode,
|
||
blastRadius,
|
||
search,
|
||
statusFilter,
|
||
],
|
||
);
|
||
const graphNodes = semanticGraph?.nodes ?? legacyNodes;
|
||
const baseEdges = semanticGraph?.edges ?? legacyEdges;
|
||
const edges = useMemo<Edge[]>(
|
||
() =>
|
||
baseEdges.map((edge) => {
|
||
const touchesSelected = Boolean(
|
||
selectedId && (edge.source === selectedId || edge.target === selectedId),
|
||
);
|
||
const touchesHovered = Boolean(
|
||
hoveredNodeId &&
|
||
(edge.source === hoveredNodeId || edge.target === hoveredNodeId),
|
||
);
|
||
const incidentPath = Boolean(
|
||
rootCauseId &&
|
||
(edge.source === rootCauseId || edge.target === rootCauseId) &&
|
||
(impactIds.has(edge.source) || impactIds.has(edge.target)),
|
||
);
|
||
return {
|
||
...edge,
|
||
type: "living",
|
||
data: {
|
||
relation: edge.className?.includes("routes")
|
||
? "routes_to"
|
||
: edge.className?.includes("review") || edge.className?.includes("communicates")
|
||
? "review"
|
||
: "depends_on",
|
||
selected: edge.id === selectedEdgeId,
|
||
route: layoutRoutes[edge.id],
|
||
signal:
|
||
edge.id === selectedEdgeId ||
|
||
touchesSelected ||
|
||
touchesHovered ||
|
||
incidentPath,
|
||
},
|
||
};
|
||
}),
|
||
[
|
||
baseEdges,
|
||
hoveredNodeId,
|
||
impactIds,
|
||
layoutRoutes,
|
||
rootCauseId,
|
||
selectedEdgeId,
|
||
selectedId,
|
||
],
|
||
);
|
||
const graphLayoutKey = useMemo(
|
||
() => topologyStructureKey(graphNodes, edges),
|
||
[graphNodes, edges],
|
||
);
|
||
const layoutPositionKey = useMemo(
|
||
() =>
|
||
graphNodes
|
||
.map((node) => {
|
||
const position = layoutPositions[node.id] ?? node.position;
|
||
return `${node.id}:${position.x}:${position.y}`;
|
||
})
|
||
.join("|"),
|
||
[graphNodes, layoutPositions],
|
||
);
|
||
const autoArrangeGraph = async () => {
|
||
const layout = await arrangeTopology(graphNodes, edges);
|
||
setLayoutPositions(layout.positions);
|
||
setLayoutRoutes(layout.routes);
|
||
};
|
||
useEffect(() => {
|
||
if (!graphNodes.length) return;
|
||
let active = true;
|
||
void arrangeTopology(graphNodes, edges).then((layout) => {
|
||
if (!active) return;
|
||
setLayoutPositions(layout.positions);
|
||
setLayoutRoutes(layout.routes);
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
// Layout work is keyed only by structure; health updates retain positions.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [graphLayoutKey]);
|
||
const nodes = useMemo(
|
||
() =>
|
||
graphNodes.map((node) => ({
|
||
...node,
|
||
position: layoutPositions[node.id] ?? node.position,
|
||
})),
|
||
[graphNodes, layoutPositions],
|
||
);
|
||
const selected =
|
||
services.find((item) => item.service.id === selectedId) ?? null;
|
||
const hasCurrentHistory = Boolean(
|
||
selectedId && historyServiceId === selectedId,
|
||
);
|
||
const selectedEdge =
|
||
edgeEvidence.find((item) => item.id === selectedEdgeId) ?? null;
|
||
const selectedIsRootCause = Boolean(
|
||
selected &&
|
||
(demo?.root_cause_service_id === selected.service.id ||
|
||
selectedIncident?.root_service_id === selected.service.id),
|
||
);
|
||
const counts = useMemo(
|
||
() =>
|
||
services.reduce(
|
||
(result, item) => {
|
||
const key = effectiveStatus(item);
|
||
result[key] = (result[key] ?? 0) + 1;
|
||
return result;
|
||
},
|
||
{} as Record<string, number>,
|
||
),
|
||
[services],
|
||
);
|
||
const monitoredCount = useMemo(
|
||
() =>
|
||
services.filter((item) =>
|
||
["active_probe", "docker_health", "appops_observation", "observed"].includes(
|
||
item.service.monitoring_state,
|
||
),
|
||
).length,
|
||
[services],
|
||
);
|
||
const resolvedComponentCount = useMemo(
|
||
() =>
|
||
semanticTopology?.components.filter(
|
||
(item) => !["unknown", "unmonitored"].includes(item.direct_status),
|
||
).length ?? monitoredCount,
|
||
[monitoredCount, semanticTopology],
|
||
);
|
||
const unresolvedComponentCount = useMemo(
|
||
() =>
|
||
semanticTopology?.components.filter((item) =>
|
||
["unknown", "unmonitored"].includes(item.direct_status),
|
||
).length ?? Math.max(0, services.length - monitoredCount),
|
||
[monitoredCount, semanticTopology, services.length],
|
||
);
|
||
const coverageCounts = useMemo(
|
||
() =>
|
||
services.reduce<Record<string, number>>((result, item) => {
|
||
const state = item.service.monitoring_state;
|
||
result[state] = (result[state] ?? 0) + 1;
|
||
return result;
|
||
}, {}),
|
||
[services],
|
||
);
|
||
const visibleSuggestions = useMemo(
|
||
() => filterSuggestions(discoverySuggestions, {
|
||
query: suggestionSearch,
|
||
confidence: suggestionConfidence,
|
||
type: suggestionType,
|
||
}),
|
||
[discoverySuggestions, suggestionSearch, suggestionConfidence, suggestionType],
|
||
);
|
||
const visibleIncidents = useMemo(
|
||
() => (incident?.incidents ?? []).filter((item) =>
|
||
(incidentStatusFilter === "all" ||
|
||
(incidentStatusFilter === "active" ? item.status !== "resolved" : item.status === incidentStatusFilter)) &&
|
||
(!incidentSearch.trim() || `${item.title} ${item.root_service_name} ${item.root_cause_reason}`
|
||
.toLowerCase().includes(incidentSearch.trim().toLowerCase())),
|
||
),
|
||
[incident, incidentSearch, incidentStatusFilter],
|
||
);
|
||
const selectedDomain = semanticTopology?.domains.find(
|
||
(item) => item.id === topologyLocation.domainId,
|
||
);
|
||
const selectedApplication = semanticTopology?.applications.find(
|
||
(item) => item.id === topologyLocation.applicationId,
|
||
);
|
||
const activeTopologyFilters: Array<{
|
||
label: string;
|
||
value: string;
|
||
clear: () => void;
|
||
}> = [
|
||
{ label: "Status", value: statusFilter, clear: () => setStatusFilter("all") },
|
||
{ label: "Monitoring", value: monitoringFilter, clear: () => setMonitoringFilter("all") },
|
||
{ label: "Domain", value: domainFilter, clear: () => setDomainFilter("all") },
|
||
{ label: "Application", value: serviceApplicationFilter, clear: () => setServiceApplicationFilter("all") },
|
||
{ label: "Environment", value: environmentFilter, clear: () => setEnvironmentFilter("all") },
|
||
{ label: "Criticality", value: criticalityFilter, clear: () => setCriticalityFilter("all") },
|
||
{ label: "Lifecycle", value: lifecycleFilter, clear: () => setLifecycleFilter("all") },
|
||
];
|
||
|
||
useEffect(() => {
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
const target = event.target as HTMLElement | null;
|
||
if (
|
||
!target ||
|
||
panel !== "topology" ||
|
||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) ||
|
||
target.isContentEditable ||
|
||
isTopologyRoot(topologyLocation)
|
||
)
|
||
return;
|
||
if (event.key === "Escape" || event.key === "Backspace") {
|
||
event.preventDefault();
|
||
setTopologyLocation((location) => topologyUp(location));
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKeyDown);
|
||
return () => window.removeEventListener("keydown", onKeyDown);
|
||
}, [panel, topologyLocation]);
|
||
|
||
useEffect(() => {
|
||
if (!nodes.length || !flowInstance.current || panel !== "topology") return;
|
||
const frame = window.requestAnimationFrame(() =>
|
||
flowInstance.current?.fitView({
|
||
padding: 0.16,
|
||
duration: demo?.active ? 240 : 0,
|
||
}),
|
||
);
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [
|
||
nodes.length,
|
||
demo?.active,
|
||
panel,
|
||
graphLayoutKey,
|
||
layoutPositionKey,
|
||
topologyMode,
|
||
focusMode,
|
||
blastRadius,
|
||
]);
|
||
|
||
const clearMessages = () => {
|
||
setError(null);
|
||
setNotice(null);
|
||
};
|
||
const resetForm = () => {
|
||
setEditingId(null);
|
||
setForm(emptyForm());
|
||
setFormOpen(false);
|
||
};
|
||
const payloadFromForm = (): ServiceInput => {
|
||
const common = {
|
||
name: form.name,
|
||
kind: form.kind,
|
||
depends_on: form.depends_on,
|
||
description: form.description,
|
||
tags: form.tags
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
group: form.group,
|
||
environment: form.environment,
|
||
criticality: form.criticality,
|
||
interval_seconds: form.interval_seconds,
|
||
timeout_seconds: form.timeout_seconds,
|
||
retries: form.retries,
|
||
failure_threshold: form.failure_threshold,
|
||
recovery_threshold: form.recovery_threshold,
|
||
availability_target: form.availability_target,
|
||
enabled: form.enabled,
|
||
monitoring_enabled: form.monitoring_enabled,
|
||
application: form.application || undefined,
|
||
domain: form.domain || undefined,
|
||
component_role: form.component_role,
|
||
application_locked: form.application_locked,
|
||
domain_locked: form.domain_locked,
|
||
component_role_locked: form.component_role_locked,
|
||
source_type: form.source_type,
|
||
source_ref: form.source_ref,
|
||
category: form.category,
|
||
visibility: form.visibility,
|
||
lifecycle: form.lifecycle,
|
||
first_seen_at: form.first_seen_at,
|
||
last_seen_at: form.last_seen_at,
|
||
last_discovery_status: form.last_discovery_status,
|
||
container_image: form.container_image,
|
||
compose_project: form.compose_project,
|
||
layout_x: form.layout_x,
|
||
layout_y: form.layout_y,
|
||
layout_pinned: form.layout_pinned,
|
||
maintenance_enabled: form.maintenance_enabled,
|
||
maintenance_reason: form.maintenance_reason,
|
||
};
|
||
if (form.kind === "http") return { ...common, kind: "http", url: form.url };
|
||
if (form.kind === "dns") return { ...common, kind: "dns", host: form.host };
|
||
return {
|
||
...common,
|
||
kind: form.kind,
|
||
host: form.host,
|
||
port: Number(form.port),
|
||
};
|
||
};
|
||
const payloadFromService = (
|
||
service: ServiceSnapshot["service"],
|
||
overrides: Partial<ServiceInput> = {},
|
||
): ServiceInput => ({
|
||
name: service.name,
|
||
kind: service.kind,
|
||
url: service.url ?? undefined,
|
||
host: service.host ?? undefined,
|
||
port: service.port ?? undefined,
|
||
depends_on: [...service.depends_on],
|
||
description: service.description ?? undefined,
|
||
tags: [...service.tags],
|
||
group: service.group ?? undefined,
|
||
environment: service.environment ?? undefined,
|
||
owner: service.owner ?? undefined,
|
||
enabled: service.enabled,
|
||
monitoring_enabled: service.monitoring_enabled,
|
||
application: service.application ?? undefined,
|
||
domain: service.domain ?? undefined,
|
||
component_role: service.component_role,
|
||
application_locked: service.application_locked,
|
||
domain_locked: service.domain_locked,
|
||
component_role_locked: service.component_role_locked,
|
||
criticality: service.criticality,
|
||
category: service.category,
|
||
visibility: service.visibility,
|
||
lifecycle: service.lifecycle,
|
||
source_type: service.source_type ?? undefined,
|
||
source_ref: service.source_ref ?? undefined,
|
||
first_seen_at: service.first_seen_at ?? undefined,
|
||
last_seen_at: service.last_seen_at ?? undefined,
|
||
last_discovery_status: service.last_discovery_status ?? undefined,
|
||
container_image: service.container_image ?? undefined,
|
||
compose_project: service.compose_project ?? undefined,
|
||
layout_x: service.layout_x ?? undefined,
|
||
layout_y: service.layout_y ?? undefined,
|
||
layout_pinned: service.layout_pinned,
|
||
interval_seconds: service.interval_seconds ?? undefined,
|
||
timeout_seconds: service.timeout_seconds ?? undefined,
|
||
retries: service.retries ?? undefined,
|
||
failure_threshold: service.failure_threshold,
|
||
recovery_threshold: service.recovery_threshold,
|
||
expected_status_min: service.expected_status_min ?? undefined,
|
||
expected_status_max: service.expected_status_max ?? undefined,
|
||
availability_target: service.availability_target ?? undefined,
|
||
maintenance_enabled: service.maintenance_enabled,
|
||
maintenance_reason: service.maintenance_reason ?? undefined,
|
||
...overrides,
|
||
});
|
||
const submit = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
clearMessages();
|
||
try {
|
||
const service = editingId
|
||
? await api.updateService(editingId, payloadFromForm())
|
||
: await api.createService(payloadFromForm());
|
||
setSelectedId(service.id);
|
||
setNotice(editingId ? "Service updated." : "Service added.");
|
||
resetForm();
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to save service");
|
||
}
|
||
};
|
||
const beginEdit = (snapshot: ServiceSnapshot) => {
|
||
const service = snapshot.service;
|
||
setEditingId(service.id);
|
||
setForm({
|
||
name: service.name,
|
||
kind: service.kind,
|
||
url: service.url ?? "",
|
||
host: service.host ?? "",
|
||
port: service.port ?? undefined,
|
||
depends_on: [...service.depends_on],
|
||
tags: service.tags.join(", "),
|
||
group: service.group ?? "",
|
||
environment: service.environment ?? "",
|
||
application: service.application ?? "",
|
||
domain: service.domain ?? "",
|
||
component_role: service.component_role,
|
||
application_locked: service.application_locked,
|
||
domain_locked: service.domain_locked,
|
||
component_role_locked: service.component_role_locked,
|
||
criticality: service.criticality,
|
||
category: service.category,
|
||
visibility: service.visibility,
|
||
lifecycle: service.lifecycle,
|
||
description: service.description ?? "",
|
||
interval_seconds: service.interval_seconds ?? undefined,
|
||
timeout_seconds: service.timeout_seconds ?? undefined,
|
||
retries: service.retries ?? undefined,
|
||
failure_threshold: service.failure_threshold,
|
||
recovery_threshold: service.recovery_threshold,
|
||
availability_target: service.availability_target ?? undefined,
|
||
enabled: service.enabled,
|
||
monitoring_enabled: service.monitoring_enabled,
|
||
source_type: service.source_type ?? undefined,
|
||
source_ref: service.source_ref ?? undefined,
|
||
first_seen_at: service.first_seen_at ?? undefined,
|
||
last_seen_at: service.last_seen_at ?? undefined,
|
||
last_discovery_status: service.last_discovery_status ?? undefined,
|
||
container_image: service.container_image ?? undefined,
|
||
compose_project: service.compose_project ?? undefined,
|
||
maintenance_enabled: service.maintenance_enabled,
|
||
maintenance_reason: service.maintenance_reason ?? undefined,
|
||
layout_x: service.layout_x ?? undefined,
|
||
layout_y: service.layout_y ?? undefined,
|
||
layout_pinned: service.layout_pinned,
|
||
});
|
||
setFormOpen(true);
|
||
setInspectorOpen(true);
|
||
setPanel("services");
|
||
};
|
||
const probe = async (serviceId: string) => {
|
||
clearMessages();
|
||
setBusyId(serviceId);
|
||
try {
|
||
await api.probeService(serviceId);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Probe failed");
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
const probeAll = async () => {
|
||
clearMessages();
|
||
setBusyAll(true);
|
||
try {
|
||
const result = await api.probeAll();
|
||
setNotice(`Completed ${result.completed}/${result.requested} probes.`);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Bulk probe failed");
|
||
} finally {
|
||
setBusyAll(false);
|
||
}
|
||
};
|
||
const removeSelected = async () => {
|
||
if (!selected) return;
|
||
clearMessages();
|
||
try {
|
||
await api.deleteService(selected.service.id);
|
||
setSelectedId(null);
|
||
setNotice(`${selected.service.name} deleted.`);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Delete failed");
|
||
}
|
||
};
|
||
const focusDemo = (result: DemoState) => {
|
||
setSelectedId(result.root_cause_service_id);
|
||
setDemoStep(0);
|
||
setTopologyLocation(topologyRoot());
|
||
setTopologyMode("landscape");
|
||
setBlastRadius(false);
|
||
setPanel("topology");
|
||
demoFocusApplied.current = true;
|
||
};
|
||
const startDemo = async () => {
|
||
clearMessages();
|
||
setDemoBusy(true);
|
||
try {
|
||
const result = await api.loadDemo();
|
||
setDemoConfirm(false);
|
||
await refresh();
|
||
focusDemo(result);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to load demo");
|
||
} finally {
|
||
setDemoBusy(false);
|
||
}
|
||
};
|
||
const loadDemo = async () => {
|
||
clearMessages();
|
||
if (demo?.active) {
|
||
await resetDemo();
|
||
return;
|
||
}
|
||
if (services.length) {
|
||
setDemoConfirm(true);
|
||
return;
|
||
}
|
||
await startDemo();
|
||
};
|
||
const resetDemo = async () => {
|
||
clearMessages();
|
||
setDemoBusy(true);
|
||
try {
|
||
const result = await api.resetDemo();
|
||
await refresh();
|
||
focusDemo(result);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to reset demo");
|
||
} finally {
|
||
setDemoBusy(false);
|
||
}
|
||
};
|
||
const exitDemo = async () => {
|
||
clearMessages();
|
||
setDemoBusy(true);
|
||
try {
|
||
const result = await api.exitDemo();
|
||
demoFocusApplied.current = false;
|
||
setDemoStep(0);
|
||
setSelectedId(null);
|
||
setNotice(
|
||
result.restored
|
||
? "Demo closed. Your previous topology was restored."
|
||
: "No active demo session.",
|
||
);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to exit demo");
|
||
} finally {
|
||
setDemoBusy(false);
|
||
}
|
||
};
|
||
const setDemoWalkthroughStep = (step: number) => {
|
||
setDemoStep(step);
|
||
setPanel("topology");
|
||
setInspectorOpen(true);
|
||
if (step === 0) {
|
||
setTopologyLocation(topologyRoot());
|
||
setTopologyMode("landscape");
|
||
setBlastRadius(false);
|
||
return;
|
||
}
|
||
if (step === 1) {
|
||
const dataDomain = semanticTopology?.domains.find(
|
||
(item) => item.name === "Data",
|
||
);
|
||
if (dataDomain) setTopologyLocation(domainLocation(dataDomain.id));
|
||
setTopologyMode("landscape");
|
||
setBlastRadius(false);
|
||
return;
|
||
}
|
||
if (step === 2) {
|
||
setTopologyLocation(topologyRoot());
|
||
setTopologyMode("impact");
|
||
setBlastRadius(true);
|
||
return;
|
||
}
|
||
setSelectedId(demo?.root_cause_service_id ?? null);
|
||
setTopologyLocation(topologyRoot());
|
||
setTopologyMode("impact");
|
||
setBlastRadius(true);
|
||
};
|
||
const exportConfig = async (format: "yaml" | "json") => {
|
||
clearMessages();
|
||
try {
|
||
const content = await api.exportConfig(format);
|
||
downloadText(
|
||
`servicemap.${format}`,
|
||
content,
|
||
format === "json" ? "application/json" : "application/yaml",
|
||
);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Export failed");
|
||
}
|
||
};
|
||
const importConfig = async () => {
|
||
clearMessages();
|
||
try {
|
||
const result = await api.importConfig(
|
||
configText,
|
||
configFormat,
|
||
configMode,
|
||
);
|
||
setNotice(
|
||
`Configuration applied: ${result.created} created, ${result.updated} updated.`,
|
||
);
|
||
setSelectedId(null);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Import failed");
|
||
}
|
||
};
|
||
const discoverDocker = async () => {
|
||
clearMessages();
|
||
setDockerBusy(true);
|
||
try {
|
||
const result = await api.discoverDocker(dockerHost);
|
||
setDockerCandidates(result.candidates);
|
||
setNotice(
|
||
`Discovered ${result.candidates.length} Docker containers for review.`,
|
||
);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Docker discovery failed");
|
||
} finally {
|
||
setDockerBusy(false);
|
||
}
|
||
};
|
||
const runProductionDiscovery = async () => {
|
||
clearMessages();
|
||
setDiscoveryBusy(true);
|
||
try {
|
||
const result: DiscoveryResult = await api.runDiscovery(
|
||
dockerHost,
|
||
appopsUrl || undefined,
|
||
false,
|
||
);
|
||
setDiscoveryRun(result.run);
|
||
setDiscoveryItems(result.observations);
|
||
setDiscoverySuggestions(
|
||
result.suggestions.filter((item) => item.status === "open"),
|
||
);
|
||
setDockerCandidates(result.candidates);
|
||
setDiscoveryStatus({
|
||
current: null,
|
||
last_completed: result.run,
|
||
active: false,
|
||
});
|
||
setNotice(
|
||
`Discovery complete: ${result.run.diff.new} new, ${result.run.diff.changed} changed, ${result.run.diff.missing} missing, ${result.run.diff.new_suggestions} new suggestions.`,
|
||
);
|
||
await refreshDiscovery();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Production discovery failed",
|
||
);
|
||
} finally {
|
||
setDiscoveryBusy(false);
|
||
}
|
||
};
|
||
const reconcileProduction = async () => {
|
||
clearMessages();
|
||
setDiscoveryBusy(true);
|
||
try {
|
||
const result = await api.reconcileDiscovery(discoveryRun?.id, true);
|
||
setReconcileConfirm(false);
|
||
setDiscoveryRun(result.run);
|
||
setNotice(
|
||
`Canonical topology updated: ${result.created} services created, ${result.updated} changed, ${result.accepted_relations} high-confidence relationships accepted.`,
|
||
);
|
||
await refresh();
|
||
await refreshDiscovery();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Discovery reconciliation failed",
|
||
);
|
||
} finally {
|
||
setDiscoveryBusy(false);
|
||
}
|
||
};
|
||
const repairDiscoveredProbes = async (dryRun: boolean) => {
|
||
clearMessages();
|
||
setDiscoveryBusy(true);
|
||
try {
|
||
const report = await api.repairDiscoveredProbes(dryRun, discoveryRun?.id);
|
||
setProbeRepair(report);
|
||
setNotice(
|
||
dryRun
|
||
? `Probe safety preview: ${report.changed} services would change; ${report.disabled_unsafe} unsafe probes would be disabled.`
|
||
: `Probe safety applied: ${report.changed} services updated; ${report.resolved_incidents} false incidents resolved.`,
|
||
);
|
||
if (!dryRun) await refresh();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Failed to repair discovered probes",
|
||
);
|
||
} finally {
|
||
setDiscoveryBusy(false);
|
||
}
|
||
};
|
||
const reviewSuggestion = async (
|
||
suggestionId: string,
|
||
action: "accept" | "reject",
|
||
) => {
|
||
clearMessages();
|
||
setDiscoveryBusy(true);
|
||
try {
|
||
if (action === "accept") await api.acceptSuggestion(suggestionId);
|
||
else await api.rejectSuggestion(suggestionId);
|
||
await refresh();
|
||
await refreshDiscovery();
|
||
setNotice(
|
||
action === "accept"
|
||
? "Discovery suggestion accepted."
|
||
: "Discovery suggestion rejected.",
|
||
);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Failed to update discovery suggestion",
|
||
);
|
||
} finally {
|
||
setDiscoveryBusy(false);
|
||
}
|
||
};
|
||
const prefillDockerCandidate = (
|
||
candidate: DockerCandidate,
|
||
suggestion = candidate.suggestions[0],
|
||
) => {
|
||
if (!suggestion) return;
|
||
setEditingId(null);
|
||
setForm({
|
||
...emptyForm(),
|
||
name: suggestion.name,
|
||
kind: suggestion.kind,
|
||
url: suggestion.url ?? "",
|
||
host: suggestion.host ?? "",
|
||
port: suggestion.port ?? undefined,
|
||
group: suggestion.group ?? "",
|
||
category: suggestion.category,
|
||
depends_on: [],
|
||
tags: "docker-discovery",
|
||
source_type: suggestion.source_type ?? "docker",
|
||
source_ref:
|
||
suggestion.stable_source_ref ??
|
||
suggestion.source_ref ??
|
||
`docker:${candidate.container_id}`,
|
||
});
|
||
setFormOpen(true);
|
||
setPanel("services");
|
||
setNotice(
|
||
"Docker suggestion copied into the service form. Review before saving.",
|
||
);
|
||
};
|
||
const persistNodePosition = async (node: ServiceNode) => {
|
||
const snapshot = services.find((item) => item.service.id === node.id);
|
||
if (!snapshot || !editMode) return;
|
||
try {
|
||
await api.updateService(
|
||
node.id,
|
||
payloadFromService(snapshot.service, {
|
||
layout_x: node.position.x,
|
||
layout_y: node.position.y,
|
||
layout_pinned: true,
|
||
}),
|
||
);
|
||
setNotice(`${snapshot.service.name} position saved.`);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to save node position",
|
||
);
|
||
}
|
||
};
|
||
const createManualEdge = async (connection: Connection) => {
|
||
if (
|
||
!editMode ||
|
||
!connection.source ||
|
||
!connection.target ||
|
||
connection.source === connection.target
|
||
)
|
||
return;
|
||
clearMessages();
|
||
try {
|
||
// A React Flow connection starts at the dependent node and points to
|
||
// the dependency. Canonical graph edges render in the opposite direction.
|
||
const dependent = services.find(
|
||
(item) => item.service.id === connection.source,
|
||
)?.service;
|
||
const dependency = services.find(
|
||
(item) => item.service.id === connection.target,
|
||
)?.service;
|
||
if (!dependent || !dependency) return;
|
||
await api.createEdge({
|
||
source_service_id: dependent.id,
|
||
target_service_id: dependency.id,
|
||
relation_type: "depends_on",
|
||
lock: true,
|
||
explanation: "Manual dependency created in Edit topology mode",
|
||
});
|
||
setNotice(`${dependent.name} now depends on ${dependency.name}.`);
|
||
await refresh();
|
||
await refreshDiscovery();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to create dependency",
|
||
);
|
||
}
|
||
};
|
||
const handleEdgeAction = async (
|
||
action: "confirm" | "reject" | "lock" | "remove",
|
||
) => {
|
||
if (!selectedEdgeId) return;
|
||
clearMessages();
|
||
try {
|
||
await api.edgeAction(selectedEdgeId, action);
|
||
setSelectedEdgeId(null);
|
||
await refresh();
|
||
await refreshDiscovery();
|
||
const label = {
|
||
confirm: "confirmed",
|
||
reject: "rejected",
|
||
lock: "locked",
|
||
remove: "removed",
|
||
}[action];
|
||
setNotice(`Edge ${label}.`);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to update relationship",
|
||
);
|
||
}
|
||
};
|
||
const toggleDependency = (dependencyId: string) =>
|
||
setForm((current) => ({
|
||
...current,
|
||
depends_on: current.depends_on.includes(dependencyId)
|
||
? current.depends_on.filter((item) => item !== dependencyId)
|
||
: [...current.depends_on, dependencyId],
|
||
}));
|
||
const openIncident = async (item: IncidentRecord) => {
|
||
setSelectedIncident(item);
|
||
setSelectedId(item.root_service_id);
|
||
setPanel("incidents");
|
||
try {
|
||
setIncidentDetail(await api.incident(item.id));
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to load incident timeline",
|
||
);
|
||
}
|
||
};
|
||
const acknowledge = async () => {
|
||
if (!selectedIncident) return;
|
||
try {
|
||
const detail = await api.acknowledgeIncident(selectedIncident.id);
|
||
setSelectedIncident(detail.incident);
|
||
setIncidentDetail(detail);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Failed to acknowledge incident",
|
||
);
|
||
}
|
||
};
|
||
const toggleMaintenance = async () => {
|
||
if (!selected) return;
|
||
try {
|
||
await api.maintenance(
|
||
selected.service.id,
|
||
!selected.service.maintenance_enabled,
|
||
"Manual maintenance window",
|
||
);
|
||
await refresh();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Failed to change maintenance state",
|
||
);
|
||
}
|
||
};
|
||
|
||
const renderEdgeInspector = () => {
|
||
if (!selectedEdge) return null;
|
||
const source = services.find(
|
||
(item) => item.service.id === selectedEdge.source_service_id,
|
||
)?.service;
|
||
const target = selectedEdge.target_service_id
|
||
? services.find(
|
||
(item) => item.service.id === selectedEdge.target_service_id,
|
||
)?.service
|
||
: null;
|
||
return (
|
||
<section
|
||
className="panel-section edge-inspector"
|
||
aria-label="Relationship inspector"
|
||
>
|
||
<div className="inspector-title">
|
||
<div>
|
||
<span className="eyebrow">Relationship inspector</span>
|
||
<h3>
|
||
{source?.name ?? "Unknown"} →{" "}
|
||
{target?.name ?? selectedEdge.target_ref ?? "Unknown"}
|
||
</h3>
|
||
</div>
|
||
<span
|
||
className={`health-pill status-${selectedEdge.active ? "healthy" : "unknown"}`}
|
||
>
|
||
{selectedEdge.active ? "active" : "inactive"}
|
||
</span>
|
||
</div>
|
||
<dl className="detail-grid">
|
||
<div>
|
||
<dt>Direction</dt>
|
||
<dd>
|
||
{source?.name ?? selectedEdge.source_service_id} depends on{" "}
|
||
{target?.name ?? selectedEdge.target_ref ?? "unknown"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Confidence</dt>
|
||
<dd>{selectedEdge.confidence}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Evidence</dt>
|
||
<dd>{selectedEdge.evidence_type}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Locked</dt>
|
||
<dd>{selectedEdge.locked ? "Yes" : "No"}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>First seen</dt>
|
||
<dd>{formatDate(selectedEdge.first_seen_at)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Last seen</dt>
|
||
<dd>{formatDate(selectedEdge.last_seen_at)}</dd>
|
||
</div>
|
||
</dl>
|
||
<p className="probe-detail">{selectedEdge.explanation}</p>
|
||
<div className="button-row wrap">
|
||
<button
|
||
onClick={() => void handleEdgeAction("confirm")}
|
||
disabled={!selectedEdge.active}
|
||
>
|
||
Confirm
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void handleEdgeAction("lock")}
|
||
disabled={!selectedEdge.active || selectedEdge.locked}
|
||
>
|
||
Lock
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void handleEdgeAction("reject")}
|
||
disabled={!selectedEdge.active}
|
||
>
|
||
Reject
|
||
</button>
|
||
<button
|
||
className="danger"
|
||
onClick={() => void handleEdgeAction("remove")}
|
||
disabled={!selectedEdge.active}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const renderInspector = () => (
|
||
<section className="panel-section inspector-section">
|
||
<div className="inspector-title">
|
||
<div>
|
||
<span className="eyebrow">Inspector</span>
|
||
<h3>{selected?.service.name ?? "Select a service"}</h3>
|
||
</div>
|
||
{selected && (
|
||
<span className={`health-pill status-${effectiveStatus(selected)}`}>
|
||
<i aria-hidden="true">{statusIcon(effectiveStatus(selected))}</i>{" "}
|
||
{statusLabel(effectiveStatus(selected))}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{!selected ? (
|
||
<p className="muted compact">
|
||
Select a node to inspect health, history, dependencies and incident
|
||
context.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<div className="inspector-callout current-evidence-callout">
|
||
<strong>Why {statusLabel(effectiveStatus(selected))} now?</strong>
|
||
<span>{selected.health_resolution.explanation || "No current health explanation is available."}</span>
|
||
<small>
|
||
{selected.health_resolution.selected_provider?.replaceAll("_", " ") ?? "No selected evidence"}
|
||
{selected.health_resolution.selected_evidence_id
|
||
? ` · ${formatDate(selected.health_evidence.find((item) => item.id === selected.health_resolution.selected_evidence_id)?.observed_at)}`
|
||
: ""}
|
||
</small>
|
||
</div>
|
||
{panel === "topology" && !nodes.some((node) => node.id === selected.service.id) && (
|
||
<p className="muted compact">The selected service is outside this graph level. Open its application or use Services to inspect the component in context.</p>
|
||
)}
|
||
{selectedIsRootCause && (
|
||
<div className="inspector-callout root-cause-callout">
|
||
<strong>Likely root cause</strong>
|
||
<span>
|
||
{demo?.affected_count ??
|
||
selectedIncident?.affected_service_ids.length ??
|
||
0}{" "}
|
||
downstream services are affected.
|
||
</span>
|
||
<small>
|
||
{(
|
||
demo?.affected_service_names ??
|
||
selectedIncident?.affected_service_names ??
|
||
[]
|
||
).join(" · ")}
|
||
</small>
|
||
</div>
|
||
)}
|
||
{!selectedIsRootCause && impactIds.has(selected.service.id) && (
|
||
<div className="inspector-callout impacted-callout">
|
||
<strong>Dependency impact</strong>
|
||
<span>
|
||
This service is affected through the current root cause.
|
||
</span>
|
||
</div>
|
||
)}
|
||
{!hasCurrentNetworkProbe(selected) && (
|
||
<div className="inspector-callout observation-callout">
|
||
<strong>{monitoringLabel(selected.service.monitoring_state)}</strong>
|
||
<span>
|
||
{selected.service.monitoring_state === "docker_health"
|
||
? "Docker supplies the current health signal; no network probe is configured."
|
||
: selected.service.monitoring_state === "appops_observation"
|
||
? "AppOps supplies a read-only application health observation; no network probe is configured."
|
||
: selected.service.monitoring_state === "observed"
|
||
? "Docker runtime is observed, but no verified externally reachable endpoint was detected."
|
||
: "No current monitoring signal is configured for this service."}
|
||
</span>
|
||
{selected.service.probe_reachability === "internal_only" && (
|
||
<small>
|
||
Internal Docker endpoint evidence is retained for discovery and is never used as an active probe.
|
||
</small>
|
||
)}
|
||
</div>
|
||
)}
|
||
<dl className="detail-grid">
|
||
<div>
|
||
<dt>Effective health</dt>
|
||
<dd className={`status-${effectiveStatus(selected)}`}>
|
||
{statusIcon(effectiveStatus(selected))}{" "}
|
||
{statusLabel(effectiveStatus(selected))}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Direct health</dt>
|
||
<dd>
|
||
{statusIcon(selected.direct_status)}{" "}
|
||
{statusLabel(selected.direct_status)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Monitoring</dt>
|
||
<dd>
|
||
{selected.service.monitoring_state.replace("_", " ")} ·{" "}
|
||
{selected.service.probe_source.replace("_", " ")}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Reachability</dt>
|
||
<dd>
|
||
{selected.service.probe_reachability.replace("_", " ")} ·{" "}
|
||
{selected.service.probe_confidence}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Runtime</dt>
|
||
<dd>
|
||
{selected.service.runtime_state ?? "—"}
|
||
{selected.service.runtime_health
|
||
? ` · ${selected.service.monitoring_state === "appops_observation" ? "AppOps" : "Docker"} ${selected.service.runtime_health}`
|
||
: ""}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Application</dt>
|
||
<dd>
|
||
{selected.service.application ?? selected.service.group ?? "—"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Target</dt>
|
||
<dd>
|
||
{selected.service.url ??
|
||
`${selected.service.host ?? "—"}${selected.service.port ? `:${selected.service.port}` : ""}`}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Probe</dt>
|
||
<dd>
|
||
{selected.service.kind.toUpperCase()} ·{" "}
|
||
{hasCurrentNetworkProbe(selected) &&
|
||
selected.latest_probe?.latency_ms != null
|
||
? `${selected.latest_probe.latency_ms} ms`
|
||
: selected.latest_probe
|
||
? "Historical probe data"
|
||
: "—"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>{hasCurrentNetworkProbe(selected) ? "Last probe" : "Probe history"}</dt>
|
||
<dd>
|
||
{selected.latest_probe
|
||
? `${hasCurrentNetworkProbe(selected) ? "Current" : "Historical"} · ${formatDate(selected.latest_probe.checked_at)}`
|
||
: "—"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Next scheduled</dt>
|
||
<dd>{hasCurrentNetworkProbe(selected) ? formatDate(selected.next_probe_at) : "No active probe"}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Availability</dt>
|
||
<dd>
|
||
{hasCurrentNetworkProbe(selected) &&
|
||
hasCurrentHistory &&
|
||
availability[24]?.uptime_percent != null
|
||
? `${availability[24]?.uptime_percent}% (24h)`
|
||
: selected.latest_probe
|
||
? "Historical probe data"
|
||
: "—"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>7d / 30d</dt>
|
||
<dd>
|
||
{hasCurrentNetworkProbe(selected) && hasCurrentHistory
|
||
? `${availability[168]?.uptime_percent ?? "—"}% / ${availability[720]?.uptime_percent ?? "—"}%`
|
||
: "Not current"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Failures / recovery</dt>
|
||
<dd>
|
||
{selected.service.failure_streak} /{" "}
|
||
{selected.service.recovery_streak}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Dependencies</dt>
|
||
<dd>{selected.service.depends_on.length}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Direct dependents</dt>
|
||
<dd>{directDependents.get(selected.service.id)?.length ?? 0}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Group / env</dt>
|
||
<dd>
|
||
{selected.service.group ?? "—"} /{" "}
|
||
{selected.service.environment ?? "—"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Category / lifecycle</dt>
|
||
<dd>
|
||
{selected.service.category.replace("_", " ")} /{" "}
|
||
{selected.service.lifecycle}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Discovery source</dt>
|
||
<dd>
|
||
{selected.service.source_type
|
||
? `${selected.service.source_type} · ${selected.service.source_ref ?? "—"}`
|
||
: "Manual"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Last seen</dt>
|
||
<dd>{formatDate(selected.service.last_seen_at)}</dd>
|
||
</div>
|
||
</dl>
|
||
{hasCurrentNetworkProbe(selected) && selected.latest_probe && <p className="probe-detail">{selected.latest_probe.detail}</p>}
|
||
<div className="evidence-list health-evidence-list">
|
||
<strong>Health evidence</strong>
|
||
<small>
|
||
{selected.health_resolution.selected_provider
|
||
? `${selected.health_resolution.selected_provider} selected${selected.health_resolution.conflict ? " · conflicting signals retained" : ""}`
|
||
: `No current component signal selected · ${selected.health_resolution.reason_code.replaceAll("_", " ")}`}
|
||
</small>
|
||
<small>{selected.health_resolution.explanation}</small>
|
||
{selected.health_evidence.slice(0, 8).map((item) => (
|
||
<small key={item.id}>
|
||
{item.scope} · {item.provider} · {statusLabel(item.status)} · {item.freshness} · {(item.confidence * 100).toFixed(0)}% · {formatDate(item.observed_at)}
|
||
</small>
|
||
))}
|
||
</div>
|
||
{!!selected.sources.length && (
|
||
<div className="evidence-list">
|
||
<strong>Sources</strong>
|
||
{selected.sources.map((source) => (
|
||
<small key={source.id}>
|
||
{source.source_type}: {source.source_name ?? source.source_id}{" "}
|
||
· {source.lifecycle}
|
||
</small>
|
||
))}
|
||
</div>
|
||
)}
|
||
{!!selected.evidence.length && (
|
||
<div className="evidence-list">
|
||
<strong>Relationship evidence</strong>
|
||
{selected.evidence
|
||
.filter((item) => item.active)
|
||
.map((item) => (
|
||
<small key={item.id}>
|
||
{item.confidence} · {item.explanation}
|
||
</small>
|
||
))}
|
||
</div>
|
||
)}
|
||
{hasCurrentHistory && !!history.length && (
|
||
<div
|
||
className="history-strip"
|
||
title="Probe history, oldest to newest"
|
||
aria-label="Probe history, oldest to newest"
|
||
>
|
||
{[...history].reverse().map((item) => (
|
||
<i
|
||
key={item.id}
|
||
className={`history-dot status-${item.status}`}
|
||
title={`${item.status} · ${formatDate(item.checked_at)}`}
|
||
aria-label={item.status}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="button-row wrap">
|
||
<button
|
||
onClick={() => void probe(selected.service.id)}
|
||
disabled={
|
||
busyId === selected.service.id ||
|
||
Boolean(demo?.active) ||
|
||
!hasCurrentNetworkProbe(selected)
|
||
}
|
||
>
|
||
{demo?.active
|
||
? "Synthetic data"
|
||
: busyId === selected.service.id
|
||
? "Probing…"
|
||
: hasCurrentNetworkProbe(selected)
|
||
? "Probe now"
|
||
: "No active probe"}
|
||
</button>
|
||
{!hasCurrentNetworkProbe(selected) && (
|
||
<button className="ghost" onClick={() => beginEdit(selected)}>
|
||
Configure endpoint
|
||
</button>
|
||
)}
|
||
<button className="ghost" onClick={() => beginEdit(selected)}>
|
||
Edit
|
||
</button>
|
||
<button className="ghost" onClick={() => void toggleMaintenance()}>
|
||
{selected.service.maintenance_enabled
|
||
? "End maintenance"
|
||
: "Maintenance"}
|
||
</button>
|
||
<button className="danger" onClick={() => void removeSelected()}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
|
||
const renderServiceForm = () => (
|
||
<details
|
||
className="panel-section form-section"
|
||
open={formOpen}
|
||
onToggle={(event) => setFormOpen((event.target as HTMLDetailsElement).open)}
|
||
>
|
||
<summary className="panel-heading">
|
||
<span className="eyebrow">
|
||
{editingId ? "Edit service" : "Add service"}
|
||
</span>
|
||
</summary>
|
||
<form className="service-form" onSubmit={(event) => void submit(event)}>
|
||
<label>
|
||
Name
|
||
<input
|
||
required
|
||
value={form.name}
|
||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||
/>
|
||
</label>
|
||
<div className="field-row">
|
||
<label>
|
||
Probe type
|
||
<select
|
||
value={form.kind}
|
||
onChange={(event) =>
|
||
setForm({ ...form, kind: event.target.value as ServiceKind })
|
||
}
|
||
>
|
||
<option value="http">HTTP(S)</option>
|
||
<option value="tcp">TCP</option>
|
||
<option value="dns">DNS</option>
|
||
<option value="tls">TLS certificate</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Criticality
|
||
<select
|
||
value={form.criticality}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
criticality: event.target.value as Criticality,
|
||
})
|
||
}
|
||
>
|
||
<option value="low">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
<option value="critical">Critical</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{form.kind === "http" ? (
|
||
<label>
|
||
URL
|
||
<input
|
||
required
|
||
value={form.url}
|
||
onChange={(event) =>
|
||
setForm({ ...form, url: event.target.value })
|
||
}
|
||
placeholder="https://service.local/health"
|
||
/>
|
||
</label>
|
||
) : (
|
||
<div className="field-row">
|
||
<label>
|
||
{form.kind === "dns" ? "Hostname" : "Host"}
|
||
<input
|
||
required
|
||
value={form.host}
|
||
onChange={(event) =>
|
||
setForm({ ...form, host: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
{form.kind !== "dns" && (
|
||
<label>
|
||
Port
|
||
<input
|
||
required
|
||
type="number"
|
||
min="1"
|
||
max="65535"
|
||
value={form.port ?? ""}
|
||
onChange={(event) =>
|
||
setForm({ ...form, port: Number(event.target.value) })
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="field-row">
|
||
<label>
|
||
Group
|
||
<input
|
||
value={form.group}
|
||
onChange={(event) =>
|
||
setForm({ ...form, group: event.target.value })
|
||
}
|
||
placeholder="Applications"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Environment
|
||
<input
|
||
value={form.environment}
|
||
onChange={(event) =>
|
||
setForm({ ...form, environment: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<fieldset className="semantic-fields">
|
||
<legend>Topology identity</legend>
|
||
<div className="field-row">
|
||
<label>
|
||
Application
|
||
<input
|
||
value={form.application ?? ""}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
application: event.target.value,
|
||
application_locked: Boolean(event.target.value),
|
||
})
|
||
}
|
||
placeholder="Derived when blank"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Domain
|
||
<input
|
||
value={form.domain ?? ""}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
domain: event.target.value,
|
||
domain_locked: Boolean(event.target.value),
|
||
})
|
||
}
|
||
placeholder="Derived when blank"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="field-row">
|
||
<label>
|
||
Component role
|
||
<select
|
||
value={form.component_role ?? "unknown"}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
component_role: event.target.value as ComponentRole,
|
||
component_role_locked: event.target.value !== "unknown",
|
||
})
|
||
}
|
||
>
|
||
{(
|
||
[
|
||
"unknown",
|
||
"frontend",
|
||
"api",
|
||
"database",
|
||
"cache",
|
||
"proxy",
|
||
"worker",
|
||
"storage",
|
||
"monitoring",
|
||
"agent",
|
||
"scheduler",
|
||
"auxiliary",
|
||
] as ComponentRole[]
|
||
).map((role) => (
|
||
<option key={role} value={role}>
|
||
{role}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<p className="muted compact">
|
||
Set a value to lock the operator override; leave it blank/unknown
|
||
for deterministic discovery metadata.
|
||
</p>
|
||
</div>
|
||
</fieldset>
|
||
<div className="field-row">
|
||
<label>
|
||
Category
|
||
<select
|
||
value={form.category ?? "unknown"}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
category: event.target.value as ServiceInput["category"],
|
||
})
|
||
}
|
||
>
|
||
{[
|
||
"application",
|
||
"database",
|
||
"cache",
|
||
"proxy",
|
||
"monitoring",
|
||
"development",
|
||
"storage",
|
||
"infrastructure",
|
||
"background_worker",
|
||
"system",
|
||
"unknown",
|
||
].map((item) => (
|
||
<option key={item} value={item}>
|
||
{item.replace("_", " ")}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Visibility
|
||
<select
|
||
value={form.visibility ?? "visible"}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
visibility: event.target.value as ServiceInput["visibility"],
|
||
})
|
||
}
|
||
>
|
||
<option value="visible">Visible</option>
|
||
<option value="hidden">Hidden</option>
|
||
<option value="ignored">Ignored</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
Description
|
||
<input
|
||
value={form.description}
|
||
onChange={(event) =>
|
||
setForm({ ...form, description: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Tags
|
||
<input
|
||
value={form.tags}
|
||
onChange={(event) => setForm({ ...form, tags: event.target.value })}
|
||
placeholder="api, customer-facing"
|
||
/>
|
||
</label>
|
||
<div className="field-row">
|
||
<label>
|
||
Interval (s)
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={form.interval_seconds ?? ""}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
interval_seconds: event.target.value
|
||
? Number(event.target.value)
|
||
: undefined,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Timeout (s)
|
||
<input
|
||
type="number"
|
||
min="0.1"
|
||
step="0.1"
|
||
value={form.timeout_seconds ?? ""}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
timeout_seconds: event.target.value
|
||
? Number(event.target.value)
|
||
: undefined,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="field-row">
|
||
<label>
|
||
Fail threshold
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={form.failure_threshold ?? 1}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
failure_threshold: Number(event.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Recovery threshold
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={form.recovery_threshold ?? 1}
|
||
onChange={(event) =>
|
||
setForm({
|
||
...form,
|
||
recovery_threshold: Number(event.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<fieldset className="dependency-fieldset">
|
||
<legend>Dependencies</legend>
|
||
{services
|
||
.filter((item) => item.service.id !== editingId)
|
||
.map((item) => (
|
||
<label className="check-row" key={item.service.id}>
|
||
<input
|
||
type="checkbox"
|
||
checked={form.depends_on.includes(item.service.id)}
|
||
onChange={() => toggleDependency(item.service.id)}
|
||
/>
|
||
{item.service.name}
|
||
</label>
|
||
))}
|
||
{!services.length && (
|
||
<span className="muted compact">No services yet.</span>
|
||
)}
|
||
</fieldset>
|
||
<div className="button-row">
|
||
<button type="submit">
|
||
{editingId ? "Save changes" : "Add service"}
|
||
</button>
|
||
{editingId && (
|
||
<button type="button" className="ghost" onClick={resetForm}>
|
||
Cancel
|
||
</button>
|
||
)}
|
||
</div>
|
||
</form>
|
||
</details>
|
||
);
|
||
|
||
const renderOverview = () => (
|
||
<div className="overview-grid">
|
||
<section className="panel-section overview-lead">
|
||
<span className="eyebrow">Service intelligence</span>
|
||
<h2>
|
||
{demo?.active
|
||
? "Northstar incident at a glance"
|
||
: "Operational overview"}
|
||
</h2>
|
||
<p className="muted">
|
||
{demo?.active
|
||
? "Synthetic data shows how one unavailable dependency propagates through a production topology."
|
||
: "A compact view of the real infrastructure topology, health evidence and active incidents."}
|
||
</p>
|
||
<div className="summary-cards">
|
||
<button onClick={() => { setStatusFilter("all"); setPanel("services"); }}>
|
||
<strong>{services.length}</strong>
|
||
<span>visible services</span>
|
||
</button>
|
||
<button className="status-healthy" onClick={() => { setStatusFilter("healthy"); setPanel("services"); }}>
|
||
<strong>{counts.healthy ?? 0}</strong>
|
||
<span>healthy</span>
|
||
</button>
|
||
<button className="status-degraded" onClick={() => { setStatusFilter("degraded"); setPanel("services"); }}>
|
||
<strong>{counts.degraded ?? 0}</strong>
|
||
<span>degraded</span>
|
||
</button>
|
||
<button className="status-down" onClick={() => { setStatusFilter("down"); setPanel("services"); }}>
|
||
<strong>{counts.down ?? 0}</strong>
|
||
<span>down</span>
|
||
</button>
|
||
</div>
|
||
{(overview?.total_services ?? services.length) > services.length && (
|
||
<p className="muted compact">{(overview?.total_services ?? services.length) - services.length} hidden services are excluded from this operational view.</p>
|
||
)}
|
||
{!!providerStatuses.length && (
|
||
<div className="provider-statuses" aria-label="Health data providers">
|
||
{providerStatuses.map((provider) => (
|
||
<small key={provider.provider} className={`status-${provider.status}`}>
|
||
{provider.provider.replace("_", " ")}: {provider.status}
|
||
</small>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
<section className="panel-section overview-incident">
|
||
<span className="eyebrow">Incident posture</span>
|
||
<h3>
|
||
{incident?.active
|
||
? `${incident.candidates.length} likely root cause${incident.candidates.length === 1 ? "" : "s"}`
|
||
: "No active incident"}
|
||
</h3>
|
||
{incident?.candidates.length ? (
|
||
<div className="candidate-list overview-root-list">
|
||
{incident.candidates.map((candidate) => (
|
||
<button key={candidate.service_id} className="candidate" onClick={() => {
|
||
const record = incident.incidents.find((item) =>
|
||
item.root_service_id === candidate.service_id && item.status !== "resolved");
|
||
if (record) void openIncident(record);
|
||
else { setSelectedId(candidate.service_id); setPanel("topology"); }
|
||
}}>
|
||
<strong>{candidate.service_name}</strong>
|
||
<small>Affects {candidate.affected_service_ids.length} downstream services</small>
|
||
<small>{candidate.reason}</small>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="muted compact">
|
||
Healthy dependency graph. Events will appear here when state
|
||
changes.
|
||
</p>
|
||
)}
|
||
</section>
|
||
<section className="panel-section coverage-overview">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="eyebrow">Monitoring coverage</span>
|
||
<h3>
|
||
{services.length
|
||
? `${resolvedComponentCount}/${services.length} with current health evidence`
|
||
: "No services yet"}
|
||
</h3>
|
||
</div>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setStatusFilter("unresolved");
|
||
setMonitoringFilter("all");
|
||
setPanel("services");
|
||
}}
|
||
disabled={unresolvedComponentCount === 0}
|
||
>
|
||
Review unresolved
|
||
</button>
|
||
</div>
|
||
<div className="coverage-breakdown">
|
||
{(
|
||
[
|
||
"active_probe",
|
||
"docker_health",
|
||
"appops_observation",
|
||
"observed",
|
||
"unmonitored",
|
||
] as MonitoringState[]
|
||
).map((state) => (
|
||
<div key={state}>
|
||
<strong>{coverageCounts[state] ?? 0}</strong>
|
||
<span>{monitoringLabel(state)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="muted compact">
|
||
{unresolvedComponentCount} services still have unresolved health. Runtime observation alone confirms presence, not health. Active probes require a verified endpoint.
|
||
</p>
|
||
</section>
|
||
<section className="panel-section discovery-overview">
|
||
<span className="eyebrow">Infrastructure discovery</span>
|
||
<h3>
|
||
{discoverySuggestions.length
|
||
? `${discoverySuggestions.length} open findings across runs`
|
||
: "Discovery is up to date"}
|
||
</h3>
|
||
<p className="muted compact">
|
||
{discoveryRun
|
||
? `Most recent run ${formatDate(discoveryRun.completed_at ?? discoveryRun.started_at)}: ${discoveryRun.diff.new} new, ${discoveryRun.diff.changed} changed and ${discoveryRun.diff.missing} missing in that run.`
|
||
: "Run discovery to compare Docker and AppOps observations with the canonical topology."}
|
||
</p>
|
||
<div className="button-row wrap">
|
||
<button onClick={() => setPanel("discovery")}>Open discovery</button>
|
||
{!demo?.active && (
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void runProductionDiscovery()}
|
||
disabled={discoveryBusy}
|
||
>
|
||
Run now
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<section className="panel-section event-panel">
|
||
<div className="panel-heading">
|
||
<span className="eyebrow">Recent events</span>
|
||
<button className="text-button" onClick={() => setPanel("incidents")}>
|
||
Open journal
|
||
</button>
|
||
</div>
|
||
<div className="event-list">
|
||
{(overview?.recent_events ?? events.slice(0, 8)).map((item) => (
|
||
<div className="event-row" key={item.id}>
|
||
<span className="event-dot" />
|
||
<div>
|
||
<strong>{item.message}</strong>
|
||
<small>{formatDate(item.created_at)}</small>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{!(overview?.recent_events.length || events.length) && (
|
||
<div className="empty-inline">No events yet.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
const renderIncidents = () => (
|
||
<div className="incident-workspace">
|
||
<section className="panel-section incident-list-panel">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="eyebrow">Persistent incident journal</span>
|
||
<h3>
|
||
{incident?.active
|
||
? "Active and historical incidents"
|
||
: "Incident history"}
|
||
</h3>
|
||
</div>
|
||
<span className="health-pill">
|
||
{incident?.incidents.length ?? 0} records
|
||
</span>
|
||
</div>
|
||
<div className="review-filters">
|
||
<label>Search incidents<input value={incidentSearch} onChange={(event) => setIncidentSearch(event.target.value)} placeholder="Service or cause" /></label>
|
||
<label>Status<select value={incidentStatusFilter} onChange={(event) => setIncidentStatusFilter(event.target.value)}>
|
||
<option value="active">Active</option><option value="all">All</option><option value="open">Open</option><option value="acknowledged">Acknowledged</option><option value="resolved">Resolved</option>
|
||
</select></label>
|
||
</div>
|
||
<p className="muted compact">Showing {visibleIncidents.length} of {incident?.incidents.length ?? 0} records.</p>
|
||
<div className="candidate-list">
|
||
{visibleIncidents.map((item) => (
|
||
<button
|
||
className={`candidate incident-card ${item.status}`}
|
||
key={item.id}
|
||
onClick={() => void openIncident(item)}
|
||
>
|
||
<div className="incident-card-top">
|
||
<span
|
||
className={`health-pill status-${item.status === "resolved" ? "healthy" : item.status === "acknowledged" ? "degraded" : "down"}`}
|
||
>
|
||
{item.status}
|
||
</span>
|
||
<span>{item.severity}</span>
|
||
</div>
|
||
<strong>{item.title}</strong>
|
||
<small>Root cause · {item.root_service_name}</small>
|
||
<small>
|
||
{item.affected_service_ids.length} downstream affected · started{" "}
|
||
{formatDate(item.started_at)}
|
||
</small>
|
||
</button>
|
||
))}
|
||
{!visibleIncidents.length && (
|
||
<div className="empty-inline">No incidents match these filters.</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<section className="panel-section incident-detail-panel">
|
||
{incidentDetail ? (
|
||
<>
|
||
<span className="eyebrow">Incident timeline</span>
|
||
<h3>{incidentDetail.incident.title}</h3>
|
||
<div className="button-row wrap">
|
||
<span
|
||
className={`health-pill status-${incidentDetail.incident.status === "resolved" ? "healthy" : incidentDetail.incident.status === "acknowledged" ? "degraded" : "down"}`}
|
||
>
|
||
{incidentDetail.incident.status}
|
||
</span>
|
||
{incidentDetail.incident.status === "open" && (
|
||
<button onClick={() => void acknowledge()}>
|
||
Acknowledge incident
|
||
</button>
|
||
)}
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setPanel("topology");
|
||
setBlastRadius(true);
|
||
}}
|
||
>
|
||
Show impact
|
||
</button>
|
||
</div>
|
||
<p className="muted compact">
|
||
{incidentDetail.incident.root_cause_reason}
|
||
</p>
|
||
<div className="timeline">
|
||
{incidentDetail.timeline.map((item) => (
|
||
<div className="timeline-row" key={item.id}>
|
||
<span className="timeline-line" />
|
||
<div>
|
||
<strong>{item.message}</strong>
|
||
<small>{formatDate(item.created_at)}</small>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="muted compact">
|
||
Select an incident to inspect its root cause, acknowledgement state,
|
||
timeline and blast radius.
|
||
</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
const renderServicesTable = () => (
|
||
<div className="services-workspace">
|
||
<section className="panel-section table-panel">
|
||
<div className="table-toolbar">
|
||
<label className="search-field">
|
||
Search
|
||
<input
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder="Name, tag or description"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
resetForm();
|
||
setSelectedId(null);
|
||
setFormOpen(true);
|
||
}}
|
||
>
|
||
+ Add service
|
||
</button>
|
||
<label>
|
||
Status
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(event) =>
|
||
setStatusFilter(event.target.value as FilterStatus)
|
||
}
|
||
>
|
||
<option value="all">All statuses</option>
|
||
<option value="unresolved">Unresolved health</option>
|
||
{(
|
||
[
|
||
"healthy",
|
||
"degraded",
|
||
"down",
|
||
"recovering",
|
||
"unknown",
|
||
"unmonitored",
|
||
"maintenance",
|
||
] as HealthStatus[]
|
||
).map((item) => (
|
||
<option key={item} value={item}>
|
||
{statusLabel(item)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Monitoring
|
||
<select
|
||
value={monitoringFilter}
|
||
onChange={(event) =>
|
||
setMonitoringFilter(event.target.value as MonitoringFilter)
|
||
}
|
||
>
|
||
<option value="all">Any source</option>
|
||
{(
|
||
[
|
||
"active_probe",
|
||
"docker_health",
|
||
"appops_observation",
|
||
"observed",
|
||
"unmonitored",
|
||
] as MonitoringState[]
|
||
).map((item) => (
|
||
<option key={item} value={item}>
|
||
{monitoringLabel(item)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Group
|
||
<select
|
||
value={groupFilter}
|
||
onChange={(event) => setGroupFilter(event.target.value)}
|
||
>
|
||
<option value="all">All groups</option>
|
||
{groups.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Environment
|
||
<select
|
||
value={environmentFilter}
|
||
onChange={(event) => setEnvironmentFilter(event.target.value)}
|
||
>
|
||
<option value="all">All environments</option>
|
||
{environments.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Criticality
|
||
<select
|
||
value={criticalityFilter}
|
||
onChange={(event) => setCriticalityFilter(event.target.value)}
|
||
>
|
||
<option value="all">All criticality</option>
|
||
{(["low", "medium", "high", "critical"] as Criticality[]).map(
|
||
(item) => (
|
||
<option key={item} value={item}>
|
||
{item}
|
||
</option>
|
||
),
|
||
)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="service-table">
|
||
<div className="service-table-head">
|
||
<span>Service</span>
|
||
<span>Application</span>
|
||
<span>Runtime</span>
|
||
<span>Monitoring</span>
|
||
<span>Health</span>
|
||
<span>Latency</span>
|
||
<span>24h</span>
|
||
</div>
|
||
{filteredServices.map((item) => (
|
||
<button
|
||
className="service-table-row"
|
||
key={item.service.id}
|
||
aria-pressed={selectedId === item.service.id}
|
||
onClick={() => setSelectedId(item.service.id)}
|
||
>
|
||
<span>
|
||
<strong>{item.service.name}</strong>
|
||
<small>
|
||
{item.service.group ?? "Ungrouped"} ·{" "}
|
||
{item.service.environment ?? "—"} · {item.service.criticality}
|
||
</small>
|
||
</span>
|
||
<span>
|
||
{item.service.application ?? item.service.group ?? "—"}
|
||
</span>
|
||
<span>
|
||
{item.service.runtime_state ?? "—"}
|
||
{item.service.runtime_health
|
||
? ` · ${item.service.runtime_health}`
|
||
: ""}
|
||
</span>
|
||
<span className="monitoring-cell">
|
||
{monitoringLabel(item.service.monitoring_state)}
|
||
<small>{item.service.probe_source.replace("_", " ")}</small>
|
||
</span>
|
||
<span className={`status-${effectiveStatus(item)}`}>
|
||
{statusIcon(effectiveStatus(item))}{" "}
|
||
{statusLabel(effectiveStatus(item))}
|
||
</span>
|
||
<span>
|
||
{hasCurrentNetworkProbe(item) && item.latest_probe?.latency_ms != null
|
||
? `${item.latest_probe.latency_ms} ms`
|
||
: "—"}
|
||
</span>
|
||
<span>
|
||
{hasCurrentNetworkProbe(item) && item.availability_24h?.uptime_percent != null
|
||
? `${item.availability_24h.uptime_percent.toFixed(1)}%`
|
||
: item.latest_probe
|
||
? "Historical"
|
||
: "—"}
|
||
</span>
|
||
</button>
|
||
))}
|
||
{!filteredServices.length && (
|
||
<div className="empty-inline">
|
||
No services match the current filters.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
{(selected || formOpen) && (
|
||
<aside className="service-drawer" aria-label="Service details">
|
||
<div className="drawer-header">
|
||
<span className="eyebrow">Service details</span>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setSelectedId(null);
|
||
resetForm();
|
||
}}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
{selected && renderInspector()}
|
||
{formOpen && renderServiceForm()}
|
||
</aside>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderDiscovery = () => {
|
||
const diff = discoveryRun?.diff;
|
||
const currentRun = discoveryStatus?.current;
|
||
return (
|
||
<div className="discovery-workspace">
|
||
<section className="panel-section discovery-panel">
|
||
<span className="eyebrow">Production infrastructure</span>
|
||
<h2>Discover the real environment</h2>
|
||
<p className="muted compact">
|
||
Read-only discovery inspects Docker metadata and optional AppOps
|
||
enrichment. A run records observations and suggestions; it does not
|
||
change the canonical topology until you explicitly reconcile it.
|
||
</p>
|
||
<div className="field-row">
|
||
<label>
|
||
Docker host
|
||
<input
|
||
value={dockerHost}
|
||
onChange={(event) => setDockerHost(event.target.value)}
|
||
placeholder="unix:///var/run/docker.sock"
|
||
/>
|
||
</label>
|
||
<label>
|
||
AppOps URL
|
||
<input
|
||
value={appopsUrl}
|
||
onChange={(event) => setAppopsUrl(event.target.value)}
|
||
placeholder="optional read-only URL"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="button-row wrap">
|
||
<button
|
||
onClick={() => void runProductionDiscovery()}
|
||
disabled={discoveryBusy}
|
||
>
|
||
{discoveryBusy ? "Discovering…" : "Run discovery"}
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void refreshDiscovery()}
|
||
disabled={discoveryBusy}
|
||
>
|
||
Refresh state
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void discoverDocker()}
|
||
disabled={dockerBusy}
|
||
>
|
||
Preview Docker candidates
|
||
</button>
|
||
</div>
|
||
{currentRun && (
|
||
<p className="notice-inline">
|
||
Discovery is currently {currentRun.status}.
|
||
</p>
|
||
)}
|
||
{discoveryRun && (
|
||
<div className="discovery-summary">
|
||
<div>
|
||
<strong>{discoveryRun.status}</strong>
|
||
<span>last run</span>
|
||
</div>
|
||
<div>
|
||
<strong>{diff?.new ?? 0}</strong>
|
||
<span>new</span>
|
||
</div>
|
||
<div>
|
||
<strong>{diff?.changed ?? 0}</strong>
|
||
<span>changed</span>
|
||
</div>
|
||
<div>
|
||
<strong>{diff?.missing ?? 0}</strong>
|
||
<span>missing</span>
|
||
</div>
|
||
<div>
|
||
<strong>{diff?.new_suggestions ?? 0}</strong>
|
||
<span>new suggestions</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="discovery-callout">
|
||
<strong>Canonical topology is deliberate.</strong>
|
||
<span>
|
||
Importing high-confidence services and relationships is explicit.
|
||
Low-confidence network hints remain review-only and never affect
|
||
health propagation.
|
||
</span>
|
||
</div>
|
||
<div className="probe-safety-callout">
|
||
<strong>Monitoring coverage repair</strong>
|
||
<span>
|
||
Published endpoints are preflighted before activation. Container-only
|
||
Docker DNS is discovery evidence only; AppOps, Docker health and
|
||
runtime observations retain their own provenance.
|
||
</span>
|
||
<div className="button-row wrap">
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void repairDiscoveredProbes(true)}
|
||
disabled={discoveryBusy || !discoveryRun}
|
||
>
|
||
Preview coverage repair
|
||
</button>
|
||
<button
|
||
onClick={() => void repairDiscoveredProbes(false)}
|
||
disabled={
|
||
discoveryBusy || !probeRepair || probeRepair.dry_run === false || probeRepair.changed === 0
|
||
}
|
||
>
|
||
Apply verified changes
|
||
</button>
|
||
</div>
|
||
{probeRepair && (
|
||
<small>
|
||
{probeRepair.dry_run ? "Preview" : "Applied"} ·{" "}
|
||
{probeRepair.changed} changed · {probeRepair.disabled_unsafe}{" "}
|
||
unsafe disabled · {probeRepair.enabled_published} published
|
||
endpoints verified · {probeRepair.docker_health_observed} Docker
|
||
health · {probeRepair.appops_observed} AppOps ·{" "}
|
||
{probeRepair.runtime_observed} runtime observed ·{" "}
|
||
{probeRepair.unmonitored} unmonitored
|
||
</small>
|
||
)}
|
||
{!!probeRepair?.changes.length && (
|
||
<div className="coverage-change-list">
|
||
{probeRepair.changes.slice(0, 12).map((change) => (
|
||
<div key={change.service_id}>
|
||
<strong>{change.service_name}</strong>
|
||
<small>
|
||
{monitoringLabel(change.next_monitoring_state)} ·{" "}
|
||
{change.next_probe_source
|
||
? change.next_probe_source.replace("_", " ")
|
||
: "no source"}
|
||
{change.next_endpoint ? ` · ${change.next_endpoint}` : ""}
|
||
{change.next_probe_confidence
|
||
? ` · ${change.next_probe_confidence}`
|
||
: ""}
|
||
</small>
|
||
<small>{change.reason}</small>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="button-row wrap">
|
||
<button
|
||
onClick={() => setReconcileConfirm(true)}
|
||
disabled={
|
||
discoveryBusy ||
|
||
!discoveryRun ||
|
||
discoveryRun.status !== "completed"
|
||
}
|
||
>
|
||
Review import
|
||
</button>
|
||
<span className="muted compact">
|
||
{discoverySuggestions.length} open suggestions ·{" "}
|
||
{discoveryItems.length} observations
|
||
</span>
|
||
</div>
|
||
{reconcileConfirm && (
|
||
<div className="review-confirm" role="alert">
|
||
<strong>Import discovery findings?</strong>
|
||
<p>Last run: {formatDate(discoveryRun?.completed_at ?? discoveryRun?.started_at)}. {discoveryRun?.diff.new ?? 0} new and {discoveryRun?.diff.changed ?? 0} changed candidates; {discoverySuggestions.filter((item) => ["high", "confirmed"].includes(item.confidence)).length} open high-confidence suggestions. Reconciliation can also update lifecycle and retire missing services. Review the candidates before applying.</p>
|
||
<div className="button-row wrap"><button className="ghost" onClick={() => setReconcileConfirm(false)}>Cancel</button><button onClick={() => void reconcileProduction()} disabled={discoveryBusy}>Apply import</button></div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
<section className="panel-section suggestion-panel">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="eyebrow">Evidence review</span>
|
||
<h3>Open discovery suggestions</h3>
|
||
</div>
|
||
<span className="health-pill">
|
||
{discoverySuggestions.length} open
|
||
</span>
|
||
</div>
|
||
<p className="muted compact">
|
||
Accept individual findings only after checking their evidence.
|
||
Rejected findings are retained and will not reappear on every scan.
|
||
</p>
|
||
<div className="review-filters">
|
||
<label>Search findings<input value={suggestionSearch} onChange={(event) => { setSuggestionSearch(event.target.value); setSuggestionLimit(30); }} placeholder="Service, source or evidence" /></label>
|
||
<label>Confidence<select value={suggestionConfidence} onChange={(event) => { setSuggestionConfidence(event.target.value); setSuggestionLimit(30); }}><option value="actionable">High + confirmed</option><option value="all">All</option><option value="confirmed">Confirmed</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option></select></label>
|
||
<label>Type<select value={suggestionType} onChange={(event) => { setSuggestionType(event.target.value); setSuggestionLimit(30); }}><option value="all">All</option><option value="service">Service</option><option value="relation">Relationship</option></select></label>
|
||
</div>
|
||
<p className="muted compact">Showing {Math.min(suggestionLimit, visibleSuggestions.length)} of {visibleSuggestions.length} matching findings ({discoverySuggestions.length} open).</p>
|
||
<div className="suggestion-list">
|
||
{visibleSuggestions.slice(0, suggestionLimit).map((suggestion) => {
|
||
const source = services.find(
|
||
(item) => item.service.id === suggestion.source_service_id,
|
||
)?.service;
|
||
const target = services.find(
|
||
(item) => item.service.id === suggestion.target_service_id,
|
||
)?.service;
|
||
return (
|
||
<article className="suggestion-card" key={suggestion.id}>
|
||
<div className="suggestion-card-heading">
|
||
<div>
|
||
<span className="eyebrow">
|
||
{suggestion.suggestion_type}
|
||
</span>
|
||
<strong>
|
||
{source?.name ??
|
||
suggestion.proposed_name ??
|
||
suggestion.source_ref ??
|
||
"Discovered item"}
|
||
{suggestion.suggestion_type === "relation" && (
|
||
<>
|
||
{" "}
|
||
<span aria-hidden="true">→</span>{" "}
|
||
{target?.name ?? suggestion.target_ref ?? "target"}
|
||
</>
|
||
)}
|
||
</strong>
|
||
</div>
|
||
<span
|
||
className={`health-pill status-${suggestion.confidence === "low" ? "unknown" : suggestion.confidence === "medium" ? "degraded" : "healthy"}`}
|
||
>
|
||
{suggestion.confidence}
|
||
</span>
|
||
</div>
|
||
{suggestion.suggestion_type === "service" && (
|
||
<small>
|
||
{suggestion.proposed_category.replace("_", " ")} ·{" "}
|
||
{suggestion.proposed_url ??
|
||
`${suggestion.proposed_host ?? "no endpoint"}${suggestion.proposed_port ? `:${suggestion.proposed_port}` : ""}`}
|
||
</small>
|
||
)}
|
||
{suggestion.suggestion_type === "relation" && (
|
||
<small>
|
||
{suggestion.relation_type ?? "relationship"} · canonical
|
||
health edge only after acceptance
|
||
</small>
|
||
)}
|
||
<div className="evidence-list">
|
||
<strong>Evidence</strong>
|
||
{suggestion.evidence.map((item) => (
|
||
<small key={item}>{item}</small>
|
||
))}
|
||
</div>
|
||
<div className="button-row wrap">
|
||
<button
|
||
onClick={() =>
|
||
void reviewSuggestion(suggestion.id, "accept")
|
||
}
|
||
disabled={discoveryBusy}
|
||
>
|
||
Accept
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() =>
|
||
void reviewSuggestion(suggestion.id, "reject")
|
||
}
|
||
disabled={discoveryBusy}
|
||
>
|
||
Reject
|
||
</button>
|
||
{suggestion.suggestion_type === "service" && (
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
const candidate = dockerCandidates.find(
|
||
(item) =>
|
||
item.stable_source_ref === suggestion.source_ref,
|
||
);
|
||
if (candidate) prefillDockerCandidate(candidate);
|
||
}}
|
||
>
|
||
Inspect probe
|
||
</button>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
{!visibleSuggestions.length && (
|
||
<div className="empty-inline">
|
||
No findings match these filters.
|
||
</div>
|
||
)}
|
||
</div>
|
||
{visibleSuggestions.length > suggestionLimit && <button className="ghost show-more" onClick={() => setSuggestionLimit((current) => current + 30)}>Show 30 more findings</button>}
|
||
</section>
|
||
<section className="panel-section discovery-observations">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="eyebrow">Observed infrastructure</span>
|
||
<h3>Latest source observations</h3>
|
||
</div>
|
||
<span className="health-pill">
|
||
{dockerCandidates.length} Docker candidates
|
||
</span>
|
||
</div>
|
||
<div className="observation-list">
|
||
{discoveryItems.slice(0, 24).map((item) => (
|
||
<div className="observation-row" key={item.id}>
|
||
<strong>{item.source_name}</strong>
|
||
<span>{item.change_type}</span>
|
||
<small>
|
||
{item.lifecycle} · {formatDate(item.observed_at)}
|
||
</small>
|
||
</div>
|
||
))}
|
||
{!discoveryItems.length && (
|
||
<div className="empty-inline">
|
||
No completed discovery run yet.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<details className="candidate-details">
|
||
<summary>
|
||
Show candidate inventory ({dockerCandidates.length})
|
||
</summary>
|
||
<div className="docker-list compact-list">
|
||
{dockerCandidates.slice(0, 80).map((candidate) => (
|
||
<article
|
||
className="docker-card"
|
||
key={`${candidate.container_id}-${candidate.stable_source_ref}`}
|
||
>
|
||
<div>
|
||
<strong>{candidate.name}</strong>
|
||
<span>
|
||
{candidate.category.replace("_", " ")} · {candidate.state}
|
||
{candidate.health ? ` · ${candidate.health}` : ""}
|
||
</span>
|
||
</div>
|
||
<small>
|
||
{candidate.image} · {candidate.import_state}
|
||
</small>
|
||
<small>
|
||
{candidate.stable_source_ref ?? "no stable identity"}
|
||
{candidate.compose_project
|
||
? ` · compose ${candidate.compose_project}`
|
||
: ""}
|
||
</small>
|
||
{candidate.proxy_route_evidence.map((item) => (
|
||
<small key={item}>{item}</small>
|
||
))}
|
||
{candidate.probe_candidates.map((probe) => (
|
||
<small key={`${probe.source}-${probe.url ?? probe.host ?? "none"}-${probe.port ?? ""}`}>
|
||
{probe.source.replace("_", " ")} · {probe.reachability}
|
||
{probe.url ??
|
||
(probe.host
|
||
? ` · ${probe.host}${probe.port ? `:${probe.port}` : ""}`
|
||
: "")}
|
||
{probe.preflight_detail ? ` · ${probe.preflight_detail}` : ""}
|
||
</small>
|
||
))}
|
||
</article>
|
||
))}
|
||
</div>
|
||
</details>
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderChanges = () => {
|
||
const snapshotIndex = topologySnapshots.findIndex((item) => item.id === selectedSnapshotId);
|
||
const selectedSnapshot = topologySnapshots[snapshotIndex] ?? topologySnapshots[0];
|
||
const previousSnapshot = topologySnapshots[snapshotIndex + 1];
|
||
const changes = topologyDiff
|
||
? [...topologyDiff.added, ...topologyDiff.changed, ...topologyDiff.removed]
|
||
: [];
|
||
const inspectChange = (entityId: string, after: Record<string, unknown> | null) => {
|
||
const serviceId =
|
||
services.some((item) => item.service.id === entityId)
|
||
? entityId
|
||
: typeof after?.source_service_id === "string"
|
||
? after.source_service_id
|
||
: null;
|
||
if (!serviceId) return;
|
||
setSelectedId(serviceId);
|
||
setPanel("topology");
|
||
setInspectorOpen(true);
|
||
};
|
||
return (
|
||
<section className="panel-section change-intelligence">
|
||
<span className="eyebrow">Change intelligence</span>
|
||
<h3>Topology evidence over time</h3>
|
||
<p className="muted compact">
|
||
Snapshots are compact canonical topology captures. They compare service identity, lifecycle, semantic metadata and dependencies without changing your topology.
|
||
</p>
|
||
{selectedSnapshot && (
|
||
<label className="snapshot-picker">Compare snapshot
|
||
<select value={selectedSnapshot.id} onChange={(event) => { setSelectedSnapshotId(event.target.value); setTopologyDiff(null); setChangeLimit(30); }}>
|
||
{topologySnapshots.map((item) => <option key={item.id} value={item.id}>{formatDate(item.created_at)} · {item.service_count} services</option>)}
|
||
</select>
|
||
</label>
|
||
)}
|
||
{selectedSnapshot && snapshotCheckedAt - new Date(selectedSnapshot.created_at).getTime() > 24 * 60 * 60 * 1000 && (
|
||
<p className="notice-inline">This topology snapshot is more than 24 hours old. Snapshots update after discovery reconciliation; current health evidence is shown in Overview and Services. <button className="text-button" onClick={() => setPanel("discovery")}>Open discovery</button></p>
|
||
)}
|
||
{selectedSnapshot ? (
|
||
<div className="detail-grid">
|
||
<div><dt>Selected topology snapshot</dt><dd>{formatDate(selectedSnapshot.created_at)}</dd></div>
|
||
<div><dt>Compared with</dt><dd>{formatDate(previousSnapshot?.created_at)}</dd></div>
|
||
<div><dt>Services / relationships</dt><dd>{selectedSnapshot.service_count} / {selectedSnapshot.relationship_count}</dd></div>
|
||
<div><dt>Added</dt><dd>{topologyDiff?.summary.added ?? 0}</dd></div>
|
||
<div><dt>Changed</dt><dd>{topologyDiff?.summary.changed ?? 0}</dd></div>
|
||
<div><dt>Removed</dt><dd>{topologyDiff?.summary.removed ?? 0}</dd></div>
|
||
</div>
|
||
) : <p className="muted compact">A topology snapshot will be captured after the next reconciliation.</p>}
|
||
{changes.length ? (
|
||
<>
|
||
<p className="muted compact">Showing {Math.min(changeLimit, changes.length)} of {changes.length} changes. Expand an item to inspect the changed fields.</p>
|
||
<div className="change-list">
|
||
{changes.slice(0, changeLimit).map((change) => {
|
||
const fields = changedFields(change.before, change.after);
|
||
return <details key={`${change.change_type}-${change.entity_type}-${change.entity_id}`} className="change-card">
|
||
<summary><strong>{change.name}</strong><small>{change.change_type} {change.entity_type} · {fields.length ? `${fields.length} fields changed` : "record added or removed"}</small></summary>
|
||
{fields.length > 0 && <div className="change-fields">{fields.map((item) => <div key={item.field}><strong>{item.field.replaceAll("_", " ")}</strong><small>{item.before} → {item.after}</small></div>)}</div>}
|
||
{change.after && <button className="ghost" onClick={() => inspectChange(change.entity_id, change.after)}>Inspect in topology</button>}
|
||
</details>;
|
||
})}
|
||
</div>
|
||
{changes.length > changeLimit && <button className="ghost show-more" onClick={() => setChangeLimit((current) => current + 30)}>Show 30 more changes</button>}
|
||
</>
|
||
) : (
|
||
<p className="muted compact">No topology difference is recorded between the two latest snapshots.</p>
|
||
)}
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const renderConfig = () => (
|
||
<section className="panel-section config-panel">
|
||
<span className="eyebrow">Configuration v2</span>
|
||
<h3>Import / export</h3>
|
||
<p className="muted compact">
|
||
Imports validate the complete graph atomically before committing.
|
||
Existing v1 documents remain importable.
|
||
</p>
|
||
<div className="field-row">
|
||
<label>
|
||
Format
|
||
<select
|
||
value={configFormat}
|
||
onChange={(event) =>
|
||
setConfigFormat(event.target.value as "yaml" | "json")
|
||
}
|
||
>
|
||
<option value="yaml">YAML</option>
|
||
<option value="json">JSON</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Mode
|
||
<select
|
||
value={configMode}
|
||
onChange={(event) =>
|
||
setConfigMode(event.target.value as "merge" | "replace")
|
||
}
|
||
>
|
||
<option value="merge">Merge</option>
|
||
<option value="replace">Replace</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label className="config-label">
|
||
Configuration
|
||
<textarea
|
||
value={configText}
|
||
onChange={(event) => setConfigText(event.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<div className="button-row wrap">
|
||
<button onClick={() => void importConfig()}>Apply configuration</button>
|
||
<button className="ghost" onClick={() => void exportConfig("yaml")}>
|
||
YAML
|
||
</button>
|
||
<button className="ghost" onClick={() => void exportConfig("json")}>
|
||
JSON
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
|
||
return (
|
||
<main className={`shell ${demo?.active ? "demo-mode" : ""}`}>
|
||
<header className="topbar">
|
||
<div className="brand-lockup">
|
||
<img src="/brand/servicemap-mark.svg" alt="" />
|
||
<div>
|
||
<span className="eyebrow">
|
||
Production infrastructure intelligence
|
||
</span>
|
||
<h1>
|
||
ServiceMap <small>v1.6.2</small>
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
<div className="toolbar">
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void loadDemo()}
|
||
disabled={demoBusy}
|
||
>
|
||
{demo?.active ? "Reset demo" : "Explore demo"}
|
||
</button>
|
||
<button className="ghost" onClick={() => void exportConfig("yaml")}>
|
||
Export YAML
|
||
</button>
|
||
<button
|
||
onClick={() => void probeAll()}
|
||
disabled={busyAll || Boolean(demo?.active)}
|
||
>
|
||
{demo?.active
|
||
? "Synthetic probes"
|
||
: busyAll
|
||
? "Probing all…"
|
||
: "Probe all"}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<section
|
||
className="operations-strip"
|
||
aria-label="Current operational picture"
|
||
>
|
||
<div>
|
||
<span className="eyebrow">Operational picture</span>
|
||
<strong>
|
||
{incident?.active
|
||
? "Active dependency incident"
|
||
: "Topology and monitoring are live"}
|
||
</strong>
|
||
<small>
|
||
{services.length} services · {resolvedComponentCount} with current component evidence ·{" "}
|
||
{unresolvedComponentCount} unresolved
|
||
</small>
|
||
</div>
|
||
<div className="health-summary">
|
||
{(["healthy", "degraded", "down", "unknown"] as HealthStatus[]).map(
|
||
(item) => (
|
||
<div key={item} className={`status-${item}`}>
|
||
<strong>{counts[item] ?? 0}</strong>
|
||
<span>{item}</span>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
</section>
|
||
{error && (
|
||
<div className="error-banner" role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{notice && (
|
||
<div className="notice-banner" role="status">
|
||
<span>{notice}</span><button className="text-button" aria-label="Dismiss notice" onClick={() => setNotice(null)}>Dismiss</button>
|
||
</div>
|
||
)}
|
||
{demo?.active && (
|
||
<section
|
||
className="demo-banner"
|
||
role="status"
|
||
aria-label="Explore demo scenario"
|
||
aria-live="polite"
|
||
aria-busy={demoBusy}
|
||
>
|
||
<div>
|
||
<span className="eyebrow">
|
||
Demo scenario · synthetic monitoring data
|
||
</span>
|
||
<strong>PostgreSQL is unavailable.</strong>
|
||
<p>
|
||
ServiceMap identified it as the likely root cause affecting{" "}
|
||
{demo.affected_count} downstream services. Select a node to
|
||
inspect health and history.
|
||
</p>
|
||
</div>
|
||
<div className="demo-actions">
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
setPanel("topology");
|
||
setTopologyMode("impact");
|
||
setBlastRadius(true);
|
||
}}
|
||
>
|
||
Trace impact
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => {
|
||
const dataDomain = semanticTopology?.domains.find(
|
||
(item) => item.name === "Data",
|
||
);
|
||
if (dataDomain) setTopologyLocation(domainLocation(dataDomain.id));
|
||
setPanel("topology");
|
||
}}
|
||
>
|
||
Explore data
|
||
</button>
|
||
<button className="ghost" onClick={() => setPanel("incidents")}>
|
||
View incident
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void resetDemo()}
|
||
disabled={demoBusy}
|
||
>
|
||
Reset demo
|
||
</button>
|
||
</div>
|
||
<DemoWalkthrough
|
||
step={demoStep}
|
||
affectedCount={demo.affected_count}
|
||
busy={demoBusy}
|
||
onStepChange={setDemoWalkthroughStep}
|
||
onExit={() => void exitDemo()}
|
||
/>
|
||
</section>
|
||
)}
|
||
{demoConfirm && (
|
||
<section className="demo-confirm" role="alert">
|
||
<div>
|
||
<strong>
|
||
Explore demo will temporarily replace this topology.
|
||
</strong>
|
||
<p>
|
||
Your current services, probes, incidents and event history will be
|
||
snapshotted and restored when you choose Exit demo.
|
||
</p>
|
||
</div>
|
||
<div className="demo-actions">
|
||
<button className="ghost" onClick={() => setDemoConfirm(false)}>
|
||
Cancel
|
||
</button>
|
||
<button onClick={() => void startDemo()} disabled={demoBusy}>
|
||
Snapshot and explore
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
<nav className="panel-tabs" aria-label="ServiceMap workspaces">
|
||
{(
|
||
[
|
||
"overview",
|
||
"topology",
|
||
"incidents",
|
||
"services",
|
||
"discovery",
|
||
"changes",
|
||
"config",
|
||
] as Panel[]
|
||
).map((item) => (
|
||
<button
|
||
key={item}
|
||
className={panel === item ? "active" : ""}
|
||
onClick={() => { setPanel(item); setNotice(null); setReconcileConfirm(false); }}
|
||
>
|
||
{item === "incidents"
|
||
? `Incidents${incident?.active ? " •" : ""}`
|
||
: item[0].toUpperCase() + item.slice(1)}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
{panel === "overview" && renderOverview()}
|
||
{panel === "incidents" && renderIncidents()}
|
||
{panel === "services" && renderServicesTable()}
|
||
{panel === "discovery" && renderDiscovery()}
|
||
{panel === "changes" && renderChanges()}
|
||
{panel === "config" && renderConfig()}
|
||
{panel === "topology" && (
|
||
<>
|
||
<TopologyCommandBar
|
||
location={topologyLocation}
|
||
setLocation={setTopologyLocation}
|
||
selectedDomain={selectedDomain ?? null}
|
||
selectedApplication={selectedApplication ?? null}
|
||
mode={topologyMode}
|
||
setMode={setTopologyMode}
|
||
setImpact={setBlastRadius}
|
||
showImpact={Boolean(demo?.active || selectedIncident)}
|
||
search={search}
|
||
setSearch={setSearch}
|
||
statusFilter={statusFilter}
|
||
setStatusFilter={setStatusFilter}
|
||
monitoringFilter={monitoringFilter}
|
||
setMonitoringFilter={setMonitoringFilter}
|
||
groupFilter={groupFilter}
|
||
environmentFilter={environmentFilter}
|
||
setEnvironmentFilter={setEnvironmentFilter}
|
||
criticalityFilter={criticalityFilter}
|
||
setCriticalityFilter={setCriticalityFilter}
|
||
domainFilter={domainFilter}
|
||
setDomainFilter={setDomainFilter}
|
||
applicationFilter={serviceApplicationFilter}
|
||
setApplicationFilter={setServiceApplicationFilter}
|
||
lifecycleFilter={lifecycleFilter}
|
||
setLifecycleFilter={setLifecycleFilter}
|
||
filtersOpen={filtersOpen}
|
||
setFiltersOpen={setFiltersOpen}
|
||
domains={domains}
|
||
applications={applications}
|
||
environments={environments}
|
||
activeFilters={activeTopologyFilters}
|
||
onFit={() => flowInstance.current?.fitView({ padding: 0.16 })}
|
||
onArrange={() => void autoArrangeGraph()}
|
||
inspectorOpen={inspectorOpen}
|
||
onToggleInspector={() => setInspectorOpen(!inspectorOpen)}
|
||
/> <section
|
||
className={`workspace topology-workspace ${inspectorOpen ? "" : "inspector-collapsed"}`}
|
||
>
|
||
<div className="canvas-card">
|
||
{loading && !services.length ? (
|
||
<div className="empty-state">
|
||
<strong>Loading topology…</strong>
|
||
<span>Reading services, incidents and live state.</span>
|
||
</div>
|
||
) : services.length ? (
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
nodeTypes={nodeTypes}
|
||
edgeTypes={edgeTypes}
|
||
fitView
|
||
minZoom={0.2}
|
||
nodesDraggable={editMode && topologyLocation.level === "components"}
|
||
nodesConnectable={editMode && topologyLocation.level === "components"}
|
||
onInit={(instance) => {
|
||
flowInstance.current = instance;
|
||
instance.fitView({ padding: 0.16 });
|
||
}}
|
||
onNodeClick={(_, node) => {
|
||
setSelectedEdgeId(null);
|
||
const data = node.data as ServiceNodeData;
|
||
if (data.nodeKind === "domain") {
|
||
setTopologyLocation(domainLocation(node.id));
|
||
setComponentScope(null);
|
||
setTopologyMode("landscape");
|
||
} else if (
|
||
data.nodeKind === "application" ||
|
||
node.id.startsWith("app:")
|
||
) {
|
||
const members = data.memberIds ?? [];
|
||
setComponentScope({ label: data.name, memberIds: members });
|
||
setTopologyLocation(applicationLocation(data.domainId ?? null, node.id));
|
||
setApplicationFilter("all");
|
||
setTopologyMode("landscape");
|
||
const member =
|
||
members.find((id) => id === rootCauseId) ?? members[0];
|
||
if (member) setSelectedId(member);
|
||
} else setSelectedId(node.id);
|
||
setInspectorOpen(true);
|
||
setPanel("topology");
|
||
}}
|
||
onNodeMouseEnter={(_, node) => setHoveredNodeId(node.id)}
|
||
onNodeMouseLeave={() => setHoveredNodeId(null)}
|
||
onPaneClick={() => setSelectedEdgeId(null)}
|
||
onEdgeClick={(_, edge) => {
|
||
const evidence = edgeEvidence.find(
|
||
(item) =>
|
||
item.source_service_id === edge.target &&
|
||
item.target_service_id === edge.source,
|
||
);
|
||
setSelectedEdgeId(evidence?.id ?? null);
|
||
setInspectorOpen(true);
|
||
}}
|
||
onConnect={(connection) => void createManualEdge(connection)}
|
||
onNodeDragStop={(_, node) =>
|
||
void persistNodePosition(node as ServiceNode)
|
||
}
|
||
>
|
||
<Background />
|
||
<Controls />
|
||
{showMiniMap && <MiniMap pannable zoomable />}
|
||
</ReactFlow>
|
||
) : (
|
||
<div className="empty-state">
|
||
<strong>No services yet</strong>
|
||
<span>
|
||
Add an endpoint, import configuration or explore the synthetic
|
||
demo.
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(inspectorOpen || formOpen) && (
|
||
<aside ref={sidePanelRef} className="side-panel topology-drawer">
|
||
{inspectorOpen && (
|
||
<>{selectedEdge ? renderEdgeInspector() : renderInspector()}</>
|
||
)}
|
||
<div className="graph-tools panel-section">
|
||
<div className="panel-heading">
|
||
<span className="eyebrow">Topology controls</span>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => setInspectorOpen(!inspectorOpen)}
|
||
>
|
||
{inspectorOpen ? "Hide inspector" : "Show inspector"}
|
||
</button>
|
||
</div>
|
||
<div className="mode-tabs" aria-label="Topology mode">
|
||
{(
|
||
[
|
||
"landscape",
|
||
"dependencies",
|
||
"impact",
|
||
] as TopologyMode[]
|
||
).map((mode) => (
|
||
<button
|
||
key={mode}
|
||
className={topologyMode === mode ? "active" : "ghost"}
|
||
onClick={() => {
|
||
setTopologyMode(mode);
|
||
if (mode === "impact") setBlastRadius(true);
|
||
else {
|
||
setBlastRadius(false);
|
||
setComponentScope(null);
|
||
if (mode === "landscape") {
|
||
setSemanticScope({
|
||
domainId: null,
|
||
applicationId: null,
|
||
});
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
{mode === "impact"
|
||
? "Incident impact"
|
||
: mode[0].toUpperCase() + mode.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{semanticTopology &&
|
||
(semanticScope.domainId || semanticScope.applicationId) && (
|
||
<div
|
||
className="topology-breadcrumbs"
|
||
aria-label="Topology hierarchy"
|
||
>
|
||
<button
|
||
className="text-button"
|
||
onClick={() => {
|
||
setSemanticScope({
|
||
domainId: null,
|
||
applicationId: null,
|
||
});
|
||
setTopologyMode("landscape");
|
||
}}
|
||
>
|
||
Domains
|
||
</button>
|
||
{semanticScope.domainId && (
|
||
<>
|
||
<span>›</span>
|
||
<button
|
||
className="text-button"
|
||
onClick={() =>
|
||
setSemanticScope((scope) => ({
|
||
...scope,
|
||
applicationId: null,
|
||
}))
|
||
}
|
||
>
|
||
{semanticTopology.domains.find(
|
||
(item) => item.id === semanticScope.domainId,
|
||
)?.name ?? "Domain"}
|
||
</button>
|
||
</>
|
||
)}
|
||
{semanticScope.applicationId && (
|
||
<>
|
||
<span>›</span>
|
||
<span>
|
||
{semanticTopology.applications.find(
|
||
(item) => item.id === semanticScope.applicationId,
|
||
)?.name ?? "Application"}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{topologyLocation.level === "components" && !semanticTopology && (
|
||
<>
|
||
<label className="scope-label">
|
||
{componentScope
|
||
? `Landscape group: ${componentScope.label}`
|
||
: "Application components"}
|
||
</label>
|
||
<select
|
||
aria-label="Application components"
|
||
value={applicationFilter}
|
||
onChange={(event) => {
|
||
setComponentScope(null);
|
||
setApplicationFilter(event.target.value);
|
||
}}
|
||
>
|
||
<option value="all">All applications</option>
|
||
{applications.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</>
|
||
)}
|
||
<div className="button-row wrap">
|
||
<button
|
||
className={focusMode ? "" : "ghost"}
|
||
onClick={() => setFocusMode(!focusMode)}
|
||
disabled={
|
||
!selectedId ||
|
||
topologyLocation.level !== "components" ||
|
||
Boolean(semanticTopology)
|
||
}
|
||
>
|
||
Focus selected
|
||
</button>
|
||
<button
|
||
className={blastRadius ? "" : "ghost"}
|
||
onClick={() => {
|
||
setBlastRadius(!blastRadius);
|
||
setTopologyMode("impact");
|
||
}}
|
||
disabled={!demo?.active && !selectedIncident}
|
||
>
|
||
Show impact
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() => void autoArrangeGraph()}
|
||
>
|
||
Auto arrange
|
||
</button>
|
||
<button
|
||
className="ghost"
|
||
onClick={() =>
|
||
flowInstance.current?.fitView({ padding: 0.16 })
|
||
}
|
||
>
|
||
Fit view
|
||
</button>
|
||
<button
|
||
className={showMiniMap ? "" : "ghost"}
|
||
onClick={() => setShowMiniMap(!showMiniMap)}
|
||
>
|
||
Mini-map
|
||
</button>
|
||
</div>
|
||
<div className="filter-row">
|
||
<input
|
||
aria-label="Search topology"
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder="Search all services"
|
||
/>
|
||
<select
|
||
aria-label="Filter status"
|
||
value={statusFilter}
|
||
onChange={(event) =>
|
||
setStatusFilter(event.target.value as FilterStatus)
|
||
}
|
||
>
|
||
<option value="all">All status</option>
|
||
{(
|
||
[
|
||
"healthy",
|
||
"degraded",
|
||
"down",
|
||
"recovering",
|
||
"unmonitored",
|
||
"unknown",
|
||
"maintenance",
|
||
] as HealthStatus[]
|
||
).map((item) => (
|
||
<option key={item} value={item}>
|
||
{statusLabel(item)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
aria-label="Filter group"
|
||
value={groupFilter}
|
||
onChange={(event) => setGroupFilter(event.target.value)}
|
||
>
|
||
<option value="all">All groups</option>
|
||
{groups.map((item) => (
|
||
<option key={item}>{item}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<p className="muted compact">
|
||
{topologyMode === "landscape"
|
||
? semanticScope.domainId
|
||
? "Application view for the selected operational domain. Select an application to inspect its components."
|
||
: "Landscape is organized by operational domain. Select a domain to explore its applications."
|
||
: editMode
|
||
? "Drag component nodes to pin their layout. New semantic edges are confirmed as manual dependencies."
|
||
: focusMode
|
||
? "Showing two dependency levels around the selected service."
|
||
: blastRadius
|
||
? "Showing the root cause and downstream impact."
|
||
: "Arrowheads point from a dependency to the service that depends on it. Select an edge for evidence."}
|
||
</p>
|
||
<button
|
||
className={editMode ? "" : "ghost"}
|
||
onClick={() => setEditMode(!editMode)}
|
||
disabled={topologyLocation.level !== "components"}
|
||
>
|
||
{editMode ? "Done editing" : "Edit components"}
|
||
</button>
|
||
</div>
|
||
{formOpen && renderServiceForm()}
|
||
</aside>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|