commit 105251b9c8e99fb23d52cc793d015d23e251634f Author: Public Source Publisher Date: Thu Oct 1 01:34:38 2026 +0200 Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..44e660c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitea +.env +.venv +__pycache__ +.pytest_cache +node_modules +frontend/node_modules +frontend/dist +data +*.db +*.db-shm +*.db-wal +*.sqlite* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..130be59 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Unraid deployment defaults: expose the ServiceMap UI on host port 1253. +SERVICEMAP_PORT=1253 +SERVICEMAP_WEBUI_HOST=127.0.0.1 +SERVICEMAP_PROBE_INTERVAL=60 +SERVICEMAP_PROBE_TIMEOUT=5 +SERVICEMAP_PROBE_RETRIES=1 +SERVICEMAP_PROBE_CONCURRENCY=10 +SERVICEMAP_RETENTION_DAYS=30 +SERVICEMAP_EVENT_RETENTION_DAYS=90 +SERVICEMAP_TLS_WARNING_DAYS=30 +SERVICEMAP_FLAP_THRESHOLD=6 +SERVICEMAP_DISCOVERY_INTERVAL_SECONDS=0 +# Health-only Docker/AppOps observation; it never runs discovery or reconciliation. +SERVICEMAP_OBSERVATION_INTERVAL_SECONDS=60 +SERVICEMAP_DOCKER_HOST=unix:///var/run/docker.sock +# Default is the legacy, read-only socket mount. Broker mode has no automatic +# fallback and requires a dedicated registered ServiceMap token mount. +SERVICEMAP_DOCKER_PROVIDER=socket +SERVICEMAP_DOCKER_BROKER_URL= +SERVICEMAP_DOCKER_BROKER_TOKEN_FILE= +SERVICEMAP_APPOPS_URL= +# Leave empty unless this host address is reachable from the ServiceMap container. +SERVICEMAP_PROBE_HOST= +# Optional OIDC API authorization. Keep disabled until a dedicated ServiceMap +# client, issuer, audience and role groups have been provisioned. +SERVICEMAP_AUTH_MODE=disabled +SERVICEMAP_OIDC_ISSUER= +SERVICEMAP_OIDC_AUDIENCE= +SERVICEMAP_OIDC_ROLE_CLAIMS=groups,roles +SERVICEMAP_OIDC_VIEWER_GROUPS= +SERVICEMAP_OIDC_OPERATOR_GROUPS= +SERVICEMAP_OIDC_ADMIN_GROUPS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b75c564 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +.pytest_cache/ +.ruff_cache/ + +# Node +node_modules/ +dist/ +.vite/ +frontend/test-results/ + +# Local state +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +.env +.env.* +!.env.example + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/Dockerfile.unraid b/Dockerfile.unraid new file mode 100644 index 0000000..027040e --- /dev/null +++ b/Dockerfile.unraid @@ -0,0 +1,30 @@ +FROM node:22-alpine AS frontend-build + +WORKDIR /frontend +COPY frontend/package.json ./package.json +COPY frontend/package-lock.json ./package-lock.json +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +FROM python:3.12-slim + +LABEL org.opencontainers.image.title="ServiceMap" \ + org.opencontainers.image.description="Production infrastructure intelligence" \ + org.opencontainers.image.version="1.6.2" \ + org.opencontainers.image.licenses="MIT" + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + SERVICEMAP_DB=/data/servicemap.db \ + SERVICEMAP_STATIC_DIR=/app/static + +COPY backend/requirements.lock ./requirements.lock +COPY backend/app ./app +RUN pip install --no-cache-dir -r requirements.lock +COPY --from=frontend-build /frontend/dist ./static + +VOLUME ["/data"] +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..b7d224e --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `7a0a61232d05ba9097432abef6f7f4102a64d0b8`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..59cf10f --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,476 @@ +{ + "schemaVersion": 1, + "sourceRevision": "7a0a61232d05ba9097432abef6f7f4102a64d0b8", + "files": [ + { + "path": ".dockerignore", + "sha256": "bf97a1ebf1e9b5d01538a0e92a7cd4763139350c46ffbd344c87e13f6da0b8af", + "bytes": 150 + }, + { + "path": ".env.example", + "sha256": "b1e3e17c79d606b293f5cc027943c83a4cd4fdb4c9b518c50e84e10bb3ccb262", + "bytes": 1353 + }, + { + "path": ".gitignore", + "sha256": "238a6b3a02cf4b1de38f1c506beab1c3722d604f42100fc1ef3d92b4ed0ab62d", + "bytes": 297 + }, + { + "path": "Dockerfile.unraid", + "sha256": "644039841da59594f369f80ad0766eb1c934dcddaf4a594cf2bdad1fc685d99a", + "bytes": 934 + }, + { + "path": "README.md", + "sha256": "8887dc7df5db24b00873789961e855fe08c9a9ce992302cbf48745c50de34285", + "bytes": 4738 + }, + { + "path": "backend/Dockerfile", + "sha256": "7f8627da7d4d15a9df9a8bb650b18d74dbfa3c226d25ab25070f65709ef6870f", + "bytes": 373 + }, + { + "path": "backend/app/__init__.py", + "sha256": "a7589dccbf098c71a4bb02f3547e170d0d538db54b17dd355e62f582fe2f0e9e", + "bytes": 35 + }, + { + "path": "backend/app/auth.py", + "sha256": "4202bb02fa0133a6816e599f3e35426a0604ef407313cee7076e04e96ecc495d", + "bytes": 6878 + }, + { + "path": "backend/app/config_io.py", + "sha256": "f289ecac3ec35848e0097f2d3c81b4401536586564dc36a8e6a6301952247fe6", + "bytes": 7255 + }, + { + "path": "backend/app/demo.py", + "sha256": "0c5845029d5cfffae3e83665b7aa89bf715f8646cdfcc525f70abada860ea148", + "bytes": 6219 + }, + { + "path": "backend/app/discovery.py", + "sha256": "646db051a01658d6f5ef6b75070dceb05a59c40264d56e2761e9fc165e7527ef", + "bytes": 53184 + }, + { + "path": "backend/app/discovery_store.py", + "sha256": "f83a21b6f71e73314d6602a262d2d2eada0fa83b9db33e59b6d3070c6a2a14bd", + "bytes": 48117 + }, + { + "path": "backend/app/docker_discovery.py", + "sha256": "170b950925f26ad12e1c285edc039342d2eedbc2af04a7b0683501c65393c957", + "bytes": 32124 + }, + { + "path": "backend/app/docker_provider.py", + "sha256": "b409d13f149cb15e88f258510787b73ed3b67c92ba3bd9ab30be76473bcaf492", + "bytes": 13288 + }, + { + "path": "backend/app/events.py", + "sha256": "6f71a41d0154306f5d5f7a215b4ca69cf139448ef78d50f3d6c5aaea48bbe1b6", + "bytes": 2154 + }, + { + "path": "backend/app/evidence.py", + "sha256": "bf3545eb916326acfc5742c56c99089781babdb1221d62394710b36417bbd5ec", + "bytes": 4843 + }, + { + "path": "backend/app/health.py", + "sha256": "6e0eb886abc5e3a30f1d256bb3094cc09566cfde8510d433318f18e403ec9549", + "bytes": 6131 + }, + { + "path": "backend/app/incidents.py", + "sha256": "d87bebdfe902c2875e49067245a571bc30debe9f375d0faab1ea91bb5c2d39df", + "bytes": 7327 + }, + { + "path": "backend/app/main.py", + "sha256": "16807aa5f8e3301f76d9b5116e0ce1da824814e62bce2efa3f63829ed5d3bd5c", + "bytes": 31255 + }, + { + "path": "backend/app/models.py", + "sha256": "2e5d6faeb1606f6f66e06aebc236daed996ab1d56d72f84123fe6261ace3f065", + "bytes": 28385 + }, + { + "path": "backend/app/observation.py", + "sha256": "5714125fe38065d7d4cb9a9cdbaa95e969ae9710fccbc45838f3beaa7588f36c", + "bytes": 9078 + }, + { + "path": "backend/app/probes.py", + "sha256": "61e5a9bf6dc83973bc56e9d48fa94def7222663de13a4338f5e829ad8b41d354", + "bytes": 8436 + }, + { + "path": "backend/app/scheduler.py", + "sha256": "4ea3e15d3f107cf5d29c2ce5ec71b2c48826602543fb4c08ebf2edd5a53fd7fa", + "bytes": 2544 + }, + { + "path": "backend/app/semantic.py", + "sha256": "2c87be83a4c4bef48ab17e7140e7d989058750c5acf42a96a1380bb34272a07c", + "bytes": 20105 + }, + { + "path": "backend/app/store.py", + "sha256": "5e3713f222521f22cc53eeda4005f46a06d50b13a1f43090b58783f7f4a84ebe", + "bytes": 105338 + }, + { + "path": "backend/pyproject.toml", + "sha256": "f739af56b1a5dbe009481de79aa5c578cb7b6865cf80cc32b641d4ade5c35534", + "bytes": 524 + }, + { + "path": "backend/requirements-dev.lock", + "sha256": "466630ac7ad0bccc6ab7e0678a24cbd3132ceb6937b20c0dcf54ba7f373be814", + "bytes": 2297 + }, + { + "path": "backend/requirements.lock", + "sha256": "f21c3b78c3c852126dfe4e42cd7fca074e2ca3587f5d4cd47539727474dd78dd", + "bytes": 1967 + }, + { + "path": "backend/tests/test_api.py", + "sha256": "3b8644b53200cdac233dc629be3a1282e332e1f782e215d237f77770fcf7fd2a", + "bytes": 7330 + }, + { + "path": "backend/tests/test_auth.py", + "sha256": "3023839a1dc787b26d98f738adc8ef212187370878edb53e6a6dbc42fd8fb8ad", + "bytes": 2531 + }, + { + "path": "backend/tests/test_config_io.py", + "sha256": "382869227e1d0fe6ad10e233ce70978b1adfa42162e2439d0e0747b7ebfc8cc7", + "bytes": 3686 + }, + { + "path": "backend/tests/test_demo.py", + "sha256": "1290b10727ad668af6639cf7a65a3fbcc89e25c01d17e97ee3f0f60565a3f256", + "bytes": 7610 + }, + { + "path": "backend/tests/test_discovery_v120.py", + "sha256": "7b3c1242815f65da45ec2d3a57cc1ba89a4a599f8852be781923242104c69472", + "bytes": 16143 + }, + { + "path": "backend/tests/test_docker_discovery.py", + "sha256": "0fc5b719bf1d0556a3fd6c7befa7e1be2860c5c9e562a357c58b232848b177a6", + "bytes": 4966 + }, + { + "path": "backend/tests/test_docker_provider.py", + "sha256": "dc949328ba6e3762b40a17c9a61029f538609507d713bee839fce6c639e3873f", + "bytes": 8637 + }, + { + "path": "backend/tests/test_health.py", + "sha256": "26056d2de98b6546e00e195dfe5fa81fa3a43c697261190abdfd39e40acc490d", + "bytes": 3983 + }, + { + "path": "backend/tests/test_probes.py", + "sha256": "263f4cf12444f998dd59d72bc75e5976cd9b2b8d52b576bae79e545db3cc631d", + "bytes": 4816 + }, + { + "path": "backend/tests/test_scheduler.py", + "sha256": "ede0a460f3ed60047db0ecd5f76b09a13be1e93b97b210fc2735116cd1ddc74d", + "bytes": 1934 + }, + { + "path": "backend/tests/test_store.py", + "sha256": "b43d83b3030d80b416147903666add662ebbd4fc6f8ae3b98e9a50b432dee6ac", + "bytes": 3755 + }, + { + "path": "backend/tests/test_v110_features.py", + "sha256": "8ad4e82ad5ee65c700aeaf796df46337690c84c494463e5da57968b71819e336", + "bytes": 6876 + }, + { + "path": "backend/tests/test_v130_probe_reachability.py", + "sha256": "8e021432c4f850958e3d4443e3095502437401890ae02661c43869ca46476724", + "bytes": 11754 + }, + { + "path": "backend/tests/test_v140_semantic.py", + "sha256": "eb78d80bb4c8a92f6526aa3e4d22d5f44f6ef946c97e2dd12767c404b5531cbe", + "bytes": 5382 + }, + { + "path": "backend/tests/test_v160_evidence_changes.py", + "sha256": "4fe8d2de180d6b4d265befe6aec8dcb9e1c01be22b42539a9703ed8b9cd9a441", + "bytes": 4280 + }, + { + "path": "backend/tests/test_v161_health_resolution.py", + "sha256": "1f30ed8929188c6fb0c3fc636ee36ff6e9d016f40a01edc2887585d96353edab", + "bytes": 15856 + }, + { + "path": "compose.yaml", + "sha256": "ef5c82e6330a2ce8bea27268a0f2a19960f08a26bc6c53b7d84218b342494258", + "bytes": 2195 + }, + { + "path": "docker-compose.yml", + "sha256": "c051b66f67511a02fb11fb8da18393380662394542830c96927d32d26a7079c9", + "bytes": 3239 + }, + { + "path": "docs/ARCHITECTURE.md", + "sha256": "ac4d87a866c3d9fc5b19507abbb1f6afb2d68ce5e4729b9e332943755075d2dc", + "bytes": 14240 + }, + { + "path": "docs/screenshots/demo-health.png", + "sha256": "7814f69214c8b6b65cd735f9a75d9f7b974b88470a37641e3c76bc314e164085", + "bytes": 312563 + }, + { + "path": "docs/screenshots/demo-landscape.png", + "sha256": "185649a264c5e1fb58d83e1d25da94d5a61b60c4adddb398a55b2298d26cebf8", + "bytes": 406522 + }, + { + "path": "frontend/Dockerfile", + "sha256": "0d8bf5dd1725879b8ea2859d78a67dd0d499fb1fb87e44aa4efad8c2e89b1899", + "bytes": 358 + }, + { + "path": "frontend/e2e/demo.spec.ts", + "sha256": "5fe2de577a41f31e3574c75bb0b46a51e695acb1eb8aadbb094d7b4ad0a1e887", + "bytes": 7851 + }, + { + "path": "frontend/eslint.config.js", + "sha256": "e779a8cbd7200eaf5176c3c031467345dde6af2dac138cc4a32407f14432062f", + "bytes": 940 + }, + { + "path": "frontend/index.html", + "sha256": "21a1858bfa8b92521d179c05d16991e6c7b0fbef61fe16f7f0f12a72db3e9ab2", + "bytes": 865 + }, + { + "path": "frontend/nginx.conf", + "sha256": "c07a3ce7936beef0d3f124f18878b46c87693150e83cf571cebbf3f6f6635885", + "bytes": 520 + }, + { + "path": "frontend/package-lock.json", + "sha256": "4b35333ebae3a5ac1830820cba08561dc12cf244566bb21b5183bf513902aed7", + "bytes": 138931 + }, + { + "path": "frontend/package.json", + "sha256": "480fd200b705f407e1d1fda6d9a28b09189aaec28b3253d10e463fb0b17a7119", + "bytes": 881 + }, + { + "path": "frontend/playwright.config.ts", + "sha256": "d959bee365c474dc6380de9056bfb5d27a905b9e4e2c79ee43071e0006d659f3", + "bytes": 855 + }, + { + "path": "frontend/public/brand/servicemap-dark.svg", + "sha256": "79ed84eba76e6362d6bbb69ef79ea3d3686bb49f89e44d6ae477bd94e87de988", + "bytes": 576 + }, + { + "path": "frontend/public/brand/servicemap-mark.svg", + "sha256": "12b8f893d5a85c9dc9c96e18ea57735348ceaff420e891483c3c2ebc8e3e7523", + "bytes": 671 + }, + { + "path": "frontend/public/brand/servicemap-mono.svg", + "sha256": "79e35d1c8ac11d14ae6e83f1d49e37dcbfd79ff207e99610b1f5407b873eed5c", + "bytes": 568 + }, + { + "path": "frontend/public/brand/servicemap-wordmark.svg", + "sha256": "75f8b5b7698d4959234c96a418e2857d64806ff85d6d9693ac5d06a79bec2a42", + "bytes": 855 + }, + { + "path": "frontend/public/icons/apple-touch-icon.png", + "sha256": "00206a46b358446d25494be66bd812aff9af40fb972640d2635891e614911f3d", + "bytes": 4051 + }, + { + "path": "frontend/public/icons/favicon-16.png", + "sha256": "d9f1eaef74f8f4159118fe9a50d05409bfbf92995b6f52ce100184b172a8c205", + "bytes": 447 + }, + { + "path": "frontend/public/icons/favicon-32.png", + "sha256": "62709f3bcfee2c00be5cfb628e96197d7983e3604a8cbd6669ed59fcd61abf4d", + "bytes": 803 + }, + { + "path": "frontend/public/icons/icon-192.png", + "sha256": "73ac592d98b2f5d299872b9509bee18533ccb82704e2dbada4b39cb417aa9262", + "bytes": 4313 + }, + { + "path": "frontend/public/icons/icon-512.png", + "sha256": "26a7927f877ccb98e9e8109fd71518136766edef81dcf56799420c839cfcf826", + "bytes": 12350 + }, + { + "path": "frontend/public/icons/servicemap-dockerman-256.png", + "sha256": "8c8807d5ef5bae83b9071b2962e90a65fded243f1474e2e27d9ad7ef06441d2d", + "bytes": 6066 + }, + { + "path": "frontend/public/icons/servicemap-dockerman-512.png", + "sha256": "26a7927f877ccb98e9e8109fd71518136766edef81dcf56799420c839cfcf826", + "bytes": 12350 + }, + { + "path": "frontend/public/site.webmanifest", + "sha256": "6ccd6440da6471ad2326010f1e1ae5b47760ea058d6b5cd674f0d1567d6a6eba", + "bytes": 340 + }, + { + "path": "frontend/src/App.tsx", + "sha256": "b78e5998577869103f9260e93fda6b6ab7d81d7f2ddb6f990294b4e8929e2257", + "bytes": 141427 + }, + { + "path": "frontend/src/LivingEdge.tsx", + "sha256": "0699d5ac00159befe6f37caaceb742c34a8a909079269cfeb0177cca8fb6a954", + "bytes": 1371 + }, + { + "path": "frontend/src/api.test.ts", + "sha256": "f62fc355a399642b0d809c19c3eb1911bfb4e2eb2e1a8d0effee0cd07c35b105", + "bytes": 5837 + }, + { + "path": "frontend/src/api.ts", + "sha256": "ac71a7b3adf4188eaa26058afa8ef6c03e023cbbb67b445ca4f7f3227c74d4ee", + "bytes": 27507 + }, + { + "path": "frontend/src/auditViewModel.test.ts", + "sha256": "e9bd72409f745321af37e54565eb3a2a1777c5696cfa5235aa674c279a6537ec", + "bytes": 2039 + }, + { + "path": "frontend/src/auditViewModel.ts", + "sha256": "228a7c02b27f4c064d05abe95e544da9a2389bcae8e8b79f97a670319a9ad986", + "bytes": 1428 + }, + { + "path": "frontend/src/demo/DemoWalkthrough.test.ts", + "sha256": "2d95993bef2286d60cb8ab6cd71b1f081a3f6cabd3f4e8b32791721578933bda", + "bytes": 487 + }, + { + "path": "frontend/src/demo/DemoWalkthrough.tsx", + "sha256": "1201c5d832475dd7e09b72287343e91e4a363fc79624ec7eb055b68ad2d390db", + "bytes": 2214 + }, + { + "path": "frontend/src/main.tsx", + "sha256": "b3f023da133154adff448b24c256c554072536939e52291490a6128a9cfc82f7", + "bytes": 237 + }, + { + "path": "frontend/src/realtime/useRealtimeEvents.ts", + "sha256": "e800faa868b0d2e811848647101f4a21897e22b284b5a24572d0a687a03b47be", + "bytes": 3421 + }, + { + "path": "frontend/src/styles.css", + "sha256": "6deaffb725b10cee74496187c1364a848ccfb2843b8d278a232e99e6cea5fd84", + "bytes": 31996 + }, + { + "path": "frontend/src/topology.test.ts", + "sha256": "3a1267d0af23a45889d6a39184ff6f32d89f9ccafc9d53872cbb38931b7a3434", + "bytes": 7110 + }, + { + "path": "frontend/src/topology/ServiceNode.tsx", + "sha256": "0e3e673d1e148483bdd331b10d7b515348b308f943fd370e8bb2435ac8d4d9f9", + "bytes": 3872 + }, + { + "path": "frontend/src/topology/TopologyCommandBar.tsx", + "sha256": "3349725592ca64c9a845d1fe677ec7ed919069e0ea9af52373e955698c20e039", + "bytes": 8375 + }, + { + "path": "frontend/src/topology/legacyLayout.ts", + "sha256": "e843fbb70a14346269987e4a6846fab2b88ff955cf35cd69c691851f7f94ea09", + "bytes": 9835 + }, + { + "path": "frontend/src/topology/semanticGraph.ts", + "sha256": "53da1a124bb798674b6da14f45ddf7c35a20cacce62d38223b6fc27909854d2c", + "bytes": 9526 + }, + { + "path": "frontend/src/topologyLayout.ts", + "sha256": "d51e7f827df124f1932b544ce253c09fd3731f36ec66b051cdcc0f2933c38b39", + "bytes": 2842 + }, + { + "path": "frontend/src/topologyNavigation.test.ts", + "sha256": "7381dc580b132ba55775b50cb623db5497000c5694c7888fb740c2e3fbf87858", + "bytes": 600 + }, + { + "path": "frontend/src/topologyNavigation.ts", + "sha256": "1fd2d65317ed1445fba823b7351cdcd25b0ab87405f7b1b61505021c5ba23748", + "bytes": 976 + }, + { + "path": "frontend/tsconfig.json", + "sha256": "9616ea859510ecae0cb8464fbe3bbd1448d66bb5a4ebf40d163e8f14fa8fff2b", + "bytes": 545 + }, + { + "path": "frontend/vite.config.ts", + "sha256": "72b18ff3a6e5ef68c37ebd6de037943ecbd51cbe27b86342e6222580eecd1e16", + "bytes": 359 + }, + { + "path": "package-lock.json", + "sha256": "43f354f7539a2315a36d1fa499707e8f198e447b4ee50ec736add96c89d09eb2", + "bytes": 215 + }, + { + "path": "package.json", + "sha256": "96398a3dbf6e8545aead833277fd60c8405ced51790ad7fe20c99068c3524b6e", + "bytes": 490 + }, + { + "path": "requirements.txt", + "sha256": "b61a55489732c882dd350b471b9050e019f29ba1965f7eb4cc65f128271dbcf6", + "bytes": 53 + }, + { + "path": "unraid/my-servicemap.xml", + "sha256": "24bd225838c710187fb93950b29b79f782792c96cffa05062172020a91f69e0f", + "bytes": 3110 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa513d7 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# ServiceMap + +**Understand what runs, what depends on it, and why a service is unhealthy.** + +ServiceMap is a self-hosted service intelligence workspace for homelabs and small environments. It combines explicit dependencies, bounded probes, reviewable discovery and durable incident evidence in a navigable topology. + +```mermaid +flowchart LR + A[Define / discover] --> B[Review topology] + B --> C[Probe and observe] + C --> D[Resolve health evidence] + D --> E[Trace impact and incidents] +``` + +## What you can do + +- Probe HTTP(S), TCP, DNS and TLS certificate expiry with configurable thresholds and retries. +- Navigate Domain → Application → Component with search, filters, focus and blast-radius views. +- Inspect provider, confidence, scope, freshness and conflicts behind each health decision. +- Review Docker discovery and optional AppOps enrichment before importing canonical topology. +- Track incident acknowledgement, resolution, availability, maintenance and retained event history. +- Compare canonical topology snapshots and retain relationship provenance. +- Use SSE updates and deterministic ELK topology layout with reduced-motion support. +- Import/export validated YAML or JSON configuration atomically. + +## Explore safely + +Choose **Explore demo** to open the fictional Northstar Platform: nine services, synthetic observations and a seeded PostgreSQL failure. The same incident and dependency logic identifies downstream impact. The demo contacts no fictional endpoints; **Exit demo** restores the saved topology, and **Reset demo** restores its synthetic starting state. + +## Demo preview + +Captured from the real application with its synthetic Northstar demo. No private infrastructure or live monitoring inventory is shown. + +![Dependency landscape with synthetic root-cause evidence](docs/screenshots/demo-landscape.png) + +![Synthetic incident and monitoring-coverage overview](docs/screenshots/demo-health.png) + +## Deploy with Docker Compose + +```bash +git clone https://gitea.itworx.tech/Jens/ServiceMap-Public.git +cd ServiceMap-Public +cp .env.example .env +docker compose up --build +``` + +Open `http://localhost:1253`. Standard Compose runs the Python API and React frontend separately and persists SQLite under `./data`. Preserve this directory during upgrades. Set `SERVICEMAP_PROBE_INTERVAL=0` to disable scheduled probes. Define targets and dependencies in the UI or import a configuration before expecting live coverage. + +The single-container Unraid build is provided by `Dockerfile.unraid` and `docker-compose.yml`. Review the socket/network mounts before enabling discovery. A read-only Docker socket mount still exposes a powerful Docker API boundary. Broker mode requires a dedicated registered identity and token file and deliberately has no socket fallback. AppOps enrichment is optional; configure your own endpoint. + +## Security and health semantics + +Protect the UI/API with network restrictions or tested OIDC configuration. Authentication is disabled by default; do not expose an unprotected deployment to the Internet. OIDC supports viewer/operator/admin roles when its issuer, audience and groups are provisioned. Discovery uses deployment-configured targets, not arbitrary browser-supplied targets. + +Health distinguishes **unmonitored, unknown, healthy, degraded, down, recovering and maintenance**. A runtime observation is not automatically a successful endpoint probe. Application-scoped AppOps evidence does not silently establish component health. Expired evidence and ambiguous relationships remain visible instead of inventing certainty. + +## Development + +```bash +python -m venv .venv +# Activate .venv for your shell. +pip install -r requirements.txt +uvicorn app.main:app --app-dir backend --reload --port 8000 +``` + +In another terminal: + +```bash +npm --prefix frontend ci +npm --prefix frontend run dev +``` + +Vite proxies the API to port 8000. Validate changes with `python -m pytest`, `npm --prefix frontend test`, `npm --prefix frontend run typecheck`, `npm --prefix frontend run lint` and `npm --prefix frontend run build`. Browser tests exercise the synthetic demo. See [architecture](docs/ARCHITECTURE.md) for the data and observation model. + +## Source distribution and licensing + +This is the curated public source edition of ServiceMap 1.6.2. Reviewed updates flow from the private canonical repository after merges to `main`; private history, production evidence and internal CI/deployment workflows are excluded. Deployment examples use generic addresses and must be configured for your environment. + +No project-wide license is included. Public source visibility does not grant reuse or redistribution rights; obtain the owner's terms and review third-party dependencies before redistribution. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b2c9fbc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + SERVICEMAP_DB=/data/servicemap.db + +COPY backend/requirements.lock ./requirements.lock +COPY backend/app ./app +RUN pip install --no-cache-dir -r requirements.lock + +VOLUME ["/data"] +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..5941914 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""ServiceMap backend package.""" diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..76cbae9 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,178 @@ +"""Optional, fail-closed OIDC bearer authorization for ServiceMap APIs.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from functools import lru_cache +from urllib.parse import urlparse + +import httpx +import jwt +from fastapi import HTTPException, Request, status + + +class AuthorizationError(RuntimeError): + pass + + +_ROLE_ORDER = {"viewer": 1, "operator": 2, "admin": 3} + + +def _csv(name: str) -> set[str]: + return {item.strip().casefold() for item in os.getenv(name, "").split(",") if item.strip()} + + +@dataclass(frozen=True) +class AuthConfig: + mode: str + issuer: str | None = None + audience: str | None = None + role_claims: tuple[str, ...] = ("groups", "roles") + viewer_groups: frozenset[str] = frozenset() + operator_groups: frozenset[str] = frozenset() + admin_groups: frozenset[str] = frozenset() + + @classmethod + def from_environment(cls) -> AuthConfig: + mode = os.getenv("SERVICEMAP_AUTH_MODE", "disabled").strip().casefold() or "disabled" + if mode == "disabled": + return cls(mode="disabled") + if mode != "oidc": + raise AuthorizationError("SERVICEMAP_AUTH_MODE must be 'disabled' or 'oidc'") + issuer = os.getenv("SERVICEMAP_OIDC_ISSUER", "").strip().rstrip("/") + audience = os.getenv("SERVICEMAP_OIDC_AUDIENCE", "").strip() + parsed = urlparse(issuer) + local_http = os.getenv("SERVICEMAP_AUTH_ALLOW_HTTP_FOR_LOCAL", "false").casefold() == "true" + if ( + not issuer + or not audience + or not parsed.netloc + or (parsed.scheme != "https" and not local_http) + ): + raise AuthorizationError("OIDC mode requires a valid HTTPS issuer and audience") + claims = tuple( + item.strip() + for item in os.getenv("SERVICEMAP_OIDC_ROLE_CLAIMS", "groups,roles").split(",") + if item.strip() + ) + return cls( + mode=mode, + issuer=issuer, + audience=audience, + role_claims=claims or ("groups", "roles"), + viewer_groups=frozenset(_csv("SERVICEMAP_OIDC_VIEWER_GROUPS")), + operator_groups=frozenset(_csv("SERVICEMAP_OIDC_OPERATOR_GROUPS")), + admin_groups=frozenset(_csv("SERVICEMAP_OIDC_ADMIN_GROUPS")), + ) + + +@lru_cache(maxsize=8) +def _jwks_client(issuer: str) -> jwt.PyJWKClient: + try: + document = httpx.get(f"{issuer}/.well-known/openid-configuration", timeout=5.0).json() + jwks_uri = str(document.get("jwks_uri", "")) if isinstance(document, dict) else "" + except (httpx.HTTPError, ValueError) as exc: + raise AuthorizationError("OIDC discovery is unavailable") from exc + parsed = urlparse(jwks_uri) + issuer_host = urlparse(issuer).hostname + if not parsed.netloc or parsed.scheme != "https" or parsed.hostname != issuer_host: + raise AuthorizationError("OIDC discovery returned an invalid JWKS URI") + return jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=300) + + +def _claim_values(claims: dict[str, object], names: tuple[str, ...]) -> set[str]: + values: set[str] = set() + for name in names: + raw = claims.get(name) + if isinstance(raw, str): + values.add(raw.casefold()) + elif isinstance(raw, list): + values.update(str(item).casefold() for item in raw if isinstance(item, (str, int))) + return values + + +def _roles_for(claims: dict[str, object], config: AuthConfig) -> set[str]: + values = _claim_values(claims, config.role_claims) + roles: set[str] = set() + # Explicit role claims remain useful with a constrained issuer; group + # mapping gives operators a stable least-privilege model in Authentik. + roles.update(value for value in values if value in _ROLE_ORDER) + if values & config.viewer_groups: + roles.add("viewer") + if values & config.operator_groups: + roles.add("operator") + if values & config.admin_groups: + roles.add("admin") + if "admin" in roles: + roles.update({"operator", "viewer"}) + elif "operator" in roles: + roles.add("viewer") + return roles + + +def verify_bearer(token: str, config: AuthConfig) -> set[str]: + if not config.issuer or not config.audience: + raise AuthorizationError("OIDC configuration is incomplete") + try: + signing_key = _jwks_client(config.issuer).get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=[signing_key.algorithm_name], + audience=config.audience, + issuer=config.issuer, + options={"require": ["exp", "sub", "iss", "aud"]}, + ) + except jwt.PyJWTError as exc: + raise AuthorizationError("Bearer token verification failed") from exc + if not isinstance(claims, dict): + raise AuthorizationError("Bearer token claims are invalid") + return _roles_for(claims, config) + + +def required_role(request: Request) -> str: + if request.method in {"GET", "HEAD", "OPTIONS"}: + return "viewer" + path = request.url.path + if path.startswith( + ("/api/v1/config", "/api/v1/discovery", "/api/v1/docker", "/api/v1/maintenance") + ): + return "admin" + return "operator" + + +def authorize_request(request: Request) -> None: + """Authorize API requests; health and static content remain reachable. + + Disabled mode preserves v1.5 behavior intentionally. Enabling OIDC with + incomplete configuration fails closed on the API while `/health` remains + available for recovery and deployment diagnostics. + """ + + if request.url.path == "/health" or not request.url.path.startswith("/api/"): + return + try: + config = AuthConfig.from_environment() + except AuthorizationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) + ) from exc + if config.mode == "disabled": + return + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Bearer authentication is required" + ) + try: + roles = verify_bearer(header.removeprefix("Bearer ").strip(), config) + except AuthorizationError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + if ( + _ROLE_ORDER.get(max(roles, key=lambda item: _ROLE_ORDER[item]) if roles else "", 0) + < _ROLE_ORDER[required_role(request)] + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient ServiceMap role" + ) diff --git a/backend/app/config_io.py b/backend/app/config_io.py new file mode 100644 index 0000000..7ea0ab5 --- /dev/null +++ b/backend/app/config_io.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json + +import yaml +from pydantic import ValidationError + +from .models import ConfigDocument, ConfigImportResult, ConfigService +from .store import Store + + +def parse_document(content: str, format_name: str) -> ConfigDocument: + try: + raw = json.loads(content) if format_name == "json" else yaml.safe_load(content) + except (json.JSONDecodeError, yaml.YAMLError) as exc: + raise ValueError(f"Invalid {format_name.upper()} configuration: {exc}") from exc + if raw is None: + raw = {"version": 1, "services": []} + try: + document = ConfigDocument.model_validate(raw) + except ValidationError as exc: + raise ValueError(f"Configuration schema validation failed: {exc}") from exc + if document.version == 2 and not document.services: + # Preserve the v1 contract's explicit unsupported-empty-v2 guard while + # accepting real v2 documents that carry the richer service schema. + raise ValueError("Configuration version must be less than or equal to 1 for an empty document") + + names = [service.name.casefold() for service in document.services] + if len(names) != len(set(names)): + raise ValueError("Service names must be unique in the configuration") + + for service in document.services: + dependencies = [dependency.casefold() for dependency in service.depends_on] + if len(dependencies) != len(set(dependencies)): + raise ValueError(f"Service {service.name} has duplicate dependencies") + if service.name.casefold() in dependencies: + raise ValueError(f"Service {service.name} cannot depend on itself") + + # A merge document may reference services that already exist in the store, + # so missing-reference validation belongs to import_document(). Cycles that + # are fully contained in this document can still be rejected immediately. + _validate_acyclic(document.services) + return document + + +def _validate_acyclic(services: list[ConfigService]) -> None: + graph = { + service.name.casefold(): [item.casefold() for item in service.depends_on] + for service in services + } + visiting: set[str] = set() + visited: set[str] = set() + + def visit(node: str) -> None: + if node in visiting: + raise ValueError("Dependency graph contains a cycle") + if node in visited: + return + visiting.add(node) + for dependency in graph.get(node, []): + visit(dependency) + visiting.remove(node) + visited.add(node) + + for node in graph: + visit(node) + + +def _validate_import_graph(store: Store, document: ConfigDocument, mode: str) -> None: + if mode not in {"merge", "replace"}: + raise ValueError("Import mode must be merge or replace") + + projected: dict[str, ConfigService] = {} + if mode == "merge": + existing = store.list_services() + names_by_id = {service.id: service.name for service in existing} + for service in existing: + projected[service.name.casefold()] = ConfigService( + name=service.name, + kind=service.kind, + url=service.url, + host=service.host, + port=service.port, + depends_on=[names_by_id[item] for item in service.depends_on if item in names_by_id], + description=service.description, + tags=service.tags, + group=service.group, + environment=service.environment, + owner=service.owner, + enabled=service.enabled, + monitoring_enabled=service.monitoring_enabled, + criticality=service.criticality, + source_type=service.source_type, + source_ref=service.source_ref, + interval_seconds=service.interval_seconds, + timeout_seconds=service.timeout_seconds, + retries=service.retries, + failure_threshold=service.failure_threshold, + recovery_threshold=service.recovery_threshold, + expected_status_min=service.expected_status_min, + expected_status_max=service.expected_status_max, + availability_target=service.availability_target, + maintenance_enabled=service.maintenance_enabled, + maintenance_reason=service.maintenance_reason, + ) + + for service in document.services: + projected[service.name.casefold()] = service + + available = set(projected) + for service in projected.values(): + missing = [ + dependency + for dependency in service.depends_on + if dependency.casefold() not in available + ] + if missing: + raise ValueError( + f"Service {service.name} references unknown dependencies: {', '.join(missing)}" + ) + + _validate_acyclic(list(projected.values())) + + +def export_document(store: Store) -> ConfigDocument: + services = store.list_services() + names = {service.id: service.name for service in services} + return ConfigDocument( + version=2, + services=[ + ConfigService( + name=service.name, + kind=service.kind, + url=service.url, + host=service.host, + port=service.port, + depends_on=[names[item] for item in service.depends_on if item in names], + description=service.description, + tags=service.tags, + group=service.group, + environment=service.environment, + owner=service.owner, + enabled=service.enabled, + monitoring_enabled=service.monitoring_enabled, + criticality=service.criticality, + source_type=service.source_type, + source_ref=service.source_ref, + interval_seconds=service.interval_seconds, + timeout_seconds=service.timeout_seconds, + retries=service.retries, + failure_threshold=service.failure_threshold, + recovery_threshold=service.recovery_threshold, + expected_status_min=service.expected_status_min, + expected_status_max=service.expected_status_max, + availability_target=service.availability_target, + maintenance_enabled=service.maintenance_enabled, + maintenance_reason=service.maintenance_reason, + ) + for service in services + ], + ) + + +def serialize_document(document: ConfigDocument, format_name: str) -> str: + payload = document.model_dump(mode="json") + if format_name == "json": + return json.dumps(payload, indent=2, sort_keys=False) + return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True) + + +def import_document(store: Store, document: ConfigDocument, mode: str) -> ConfigImportResult: + _validate_import_graph(store, document, mode) + return store.import_config_services(document.services, mode) diff --git a/backend/app/demo.py b/backend/app/demo.py new file mode 100644 index 0000000..efc40ee --- /dev/null +++ b/backend/app/demo.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid5 + +from .models import ( + ComponentRole, + Criticality, + DiscoveryCategory, + HealthStatus, + ProbeResult, + Service, + ServiceKind, +) + +DEMO_SCENARIO_NAME = "Northstar Platform" +DEMO_ROOT_CAUSE_NAME = "PostgreSQL" +_DEMO_NAMESPACE = UUID("4b3f31f4-8ee8-4e34-9a30-4bd4e5d4d4c1") + + +def _stable_id(label: str) -> str: + return str(uuid5(_DEMO_NAMESPACE, label)) + + +def build_demo_records(now: datetime | None = None) -> tuple[list[Service], list[ProbeResult]]: + """Build the complete deterministic Northstar topology and its synthetic history.""" + + current = now or datetime.now(UTC) + definitions = [ + ("Public Endpoint", ServiceKind.HTTP, "https://northstar.synthetic/public", None, None, ["Reverse Proxy"]), + ("Reverse Proxy", ServiceKind.HTTP, "https://northstar.synthetic/proxy", None, None, ["Web Frontend", "Auth Service"]), + ("Web Frontend", ServiceKind.HTTP, "https://northstar.synthetic/web", None, None, ["API Gateway"]), + ("Auth Service", ServiceKind.HTTP, "https://northstar.synthetic/auth", None, None, []), + ("API Gateway", ServiceKind.HTTP, "https://northstar.synthetic/api", None, None, ["PostgreSQL", "Redis"]), + ("PostgreSQL", ServiceKind.TCP, None, "postgresql.northstar.synthetic", 5432, []), + ("Redis", ServiceKind.TCP, None, "redis.northstar.synthetic", 6379, []), + ("Background Worker", ServiceKind.TCP, None, "worker.northstar.synthetic", 9000, ["PostgreSQL", "Redis", "Object Storage"]), + ("Object Storage", ServiceKind.HTTP, "https://northstar.synthetic/object-storage", None, None, []), + ] + ids_by_name = {name: _stable_id(f"service:{name}") for name, *_ in definitions} + metadata = { + "Public Endpoint": ("Edge", "Public Gateway", "Edge", ComponentRole.FRONTEND, DiscoveryCategory.PROXY, Criticality.CRITICAL, "Customer-facing availability check", 100.0), + "Reverse Proxy": ("Edge", "Public Gateway", "Edge", ComponentRole.PROXY, DiscoveryCategory.PROXY, Criticality.HIGH, "Northstar ingress gateway", 100.0), + "Web Frontend": ("Application", "Northstar App", "Application", ComponentRole.FRONTEND, DiscoveryCategory.APPLICATION, Criticality.HIGH, "Browser application", 99.0), + "Auth Service": ("Application", "Northstar App", "Application", ComponentRole.API, DiscoveryCategory.APPLICATION, Criticality.HIGH, "Identity and session service", 100.0), + "API Gateway": ("Application", "Northstar App", "Application", ComponentRole.API, DiscoveryCategory.APPLICATION, Criticality.CRITICAL, "Application API entry point", 99.0), + "PostgreSQL": ("Data", "Data Platform", "Data", ComponentRole.DATABASE, DiscoveryCategory.DATABASE, Criticality.CRITICAL, "Primary relational datastore", 99.9), + "Redis": ("Data", "Data Platform", "Data", ComponentRole.CACHE, DiscoveryCategory.CACHE, Criticality.HIGH, "Cache and queue backend", 100.0), + "Background Worker": ("Application", "Worker Platform", "Application", ComponentRole.WORKER, DiscoveryCategory.BACKGROUND_WORKER, Criticality.HIGH, "Asynchronous job processor", 99.0), + "Object Storage": ("Data", "Worker Platform", "Application", ComponentRole.STORAGE, DiscoveryCategory.STORAGE, Criticality.MEDIUM, "Durable object store", 100.0), + } + services = [ + Service( + id=ids_by_name[name], + name=name, + kind=kind, + url=url, + host=host, + port=port, + depends_on=[ids_by_name[item] for item in dependencies], + group=metadata[name][0], + environment="production", + application=metadata[name][1], + domain=metadata[name][2], + component_role=metadata[name][3], + category=metadata[name][4], + criticality=metadata[name][5], + description=metadata[name][6], + availability_target=metadata[name][7], + interval_seconds=60, + created_at=current - timedelta(days=30), + updated_at=current, + ) + for name, kind, url, host, port, dependencies in definitions + ] + + probes: list[ProbeResult] = [] + for name, *_ in definitions: + for index in range(24): + status = _history_status(name, index) + checked_at = current - timedelta(hours=23 - index) + latency = None if status == HealthStatus.DOWN else round(42 + ((index * 17 + len(name) * 3) % 120), 2) + detail = _probe_detail(name, status, index) + probes.append( + ProbeResult( + id=_stable_id(f"probe:{name}:{index}"), + service_id=ids_by_name[name], + status=status, + latency_ms=latency, + detail=detail, + checked_at=checked_at, + trigger="synthetic", + metadata={"scenario": DEMO_SCENARIO_NAME}, + ) + ) + return services, probes + + +def _history_status(name: str, index: int) -> HealthStatus: + if name == "PostgreSQL": + if index in {21, 23}: + return HealthStatus.DOWN + if index == 22: + return HealthStatus.DEGRADED + if name in {"API Gateway", "Background Worker"} and index == 20: + return HealthStatus.DOWN + if name == "Web Frontend" and index == 21: + return HealthStatus.DOWN + if name == "Reverse Proxy" and index == 22: + return HealthStatus.DEGRADED + return HealthStatus.HEALTHY + + +def _probe_detail(name: str, status: HealthStatus, index: int) -> str: + if name == DEMO_ROOT_CAUSE_NAME and status == HealthStatus.DOWN: + return "Synthetic outage: PostgreSQL connection refused" + if status == HealthStatus.DOWN: + return f"Synthetic dependency impact observed at history point {index + 1}" + if status == HealthStatus.DEGRADED: + return f"Synthetic elevated latency observed at history point {index + 1}" + return "Synthetic monitoring observation" diff --git a/backend/app/discovery.py b/backend/app/discovery.py new file mode 100644 index 0000000..8286cd8 --- /dev/null +++ b/backend/app/discovery.py @@ -0,0 +1,996 @@ +from __future__ import annotations + +import hashlib +import os +import threading +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from .discovery_store import DiscoveryStore +from .docker_discovery import discover_containers +from .evidence import appops_health_status +from .health import validate_dependencies +from .models import ( + Criticality, + DiscoveryCategory, + DiscoveryConfidence, + DiscoveryDiff, + DiscoveryLifecycle, + DiscoveryReconcileRequest, + DiscoveryResult, + DiscoveryRun, + DiscoveryRunRequest, + DiscoveryRunStatus, + DockerCandidate, + EdgeMutationRequest, + EvidenceProvider, + EvidenceScope, + HealthEvidence, + HealthStatus, + MonitoringState, + ProbeEndpointSource, + ProbeReachability, + ProbeRepairChange, + ProbeRepairReport, + ProviderHealth, + ProviderStatus, + ReconcileResult, + RelationEvidence, + RelationType, + Service, + ServiceCreate, + ServiceKind, + ServiceUpdate, + SuggestionStatus, + TopologySuggestion, +) +from .observation import observation_ttl +from .store import Store + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _evidence_ttl(): + try: + interval = max(10, int(os.getenv("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", "60"))) + except ValueError: + interval = 60 + return observation_ttl(interval) + + +def _fingerprint(*parts: object) -> str: + value = "|".join(str(part or "") for part in parts) + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _confidence(value: str | DiscoveryConfidence) -> DiscoveryConfidence: + try: + return value if isinstance(value, DiscoveryConfidence) else DiscoveryConfidence(value) + except ValueError: + return DiscoveryConfidence.LOW + + +def _criticality(value: str | None) -> Criticality: + normalized = (value or "medium").casefold() + if normalized in {item.value for item in Criticality}: + return Criticality(normalized) + if normalized in {"urgent", "very_high"}: + return Criticality.CRITICAL + return Criticality.MEDIUM + + +def _application_name(candidate: DockerCandidate) -> str: + explicit = candidate.labels.get("servicemap.application", "").strip() + if explicit: + return explicit + if candidate.compose_project: + return candidate.compose_project.replace("-", " ").replace("_", " ").title() + if candidate.appops_app_id: + return f"AppOps · {candidate.appops_app_id}" + return candidate.category.value.replace("_", " ").title() + + +def _metadata(candidate: DockerCandidate) -> dict[str, object]: + return { + "candidate": candidate.model_dump(mode="json"), + "stable_source_ref": candidate.stable_source_ref, + "container_id": candidate.container_id, + "image": candidate.image, + "state": candidate.state, + "health": candidate.health, + "networks": candidate.networks, + "ports": [item.model_dump(mode="json") for item in candidate.ports], + "labels": candidate.labels, + "compose_project": candidate.compose_project, + "compose_service": candidate.compose_service, + "category": candidate.category.value, + "visibility": candidate.visibility.value, + "environment_evidence": candidate.environment_evidence, + "proxy_route_evidence": candidate.proxy_route_evidence, + "appops_app_id": candidate.appops_app_id, + } + + +def _same_observation(previous: dict[str, object], current: dict[str, object]) -> bool: + ignored = {"state", "health", "container_id"} + return {key: value for key, value in previous.items() if key not in ignored} == { + key: value for key, value in current.items() if key not in ignored + } + + +class DiscoveryManager: + def __init__(self, store: Store, discovery_store: DiscoveryStore) -> None: + self.store = store + self.discovery_store = discovery_store + self._lock = threading.Lock() + + def _docker_host(self, requested: str | None) -> str: + return requested or os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock") + + def _appops_url(self, requested: str | None) -> str | None: + return requested or os.getenv("SERVICEMAP_APPOPS_URL") + + def _existing_service_for_ref(self, services: list[Service], source_ref: str | None) -> Service | None: + if not source_ref: + return None + return next((item for item in services if item.source_ref == source_ref), None) + + def _candidate_suggestion(self, candidate: DockerCandidate): + return candidate.suggestions[0] if candidate.suggestions else None + + def _relation_suggestions( + self, + run: DiscoveryRun, + candidates: list[DockerCandidate], + service_by_ref: dict[str, Service], + ) -> list[TopologySuggestion]: + result: list[TopologySuggestion] = [] + + def aliases(candidate: DockerCandidate) -> set[str]: + return {item.casefold() for item in (candidate.name, candidate.compose_service or "") if item} + + def resolve(source: DockerCandidate, reference: str, *, compose_only: bool = False) -> list[DockerCandidate]: + """Resolve only inside a defensible compose/network scope. + + A duplicate service name must remain a review suggestion. Choosing + the last name seen in a host-wide index is not topology evidence. + """ + token = reference.casefold().strip() + matches = [item for item in candidates if token in aliases(item) or token == (item.stable_source_ref or "").casefold()] + same_compose = [item for item in matches if source.compose_project and item.compose_project and item.compose_project.casefold() == source.compose_project.casefold()] + if compose_only: + return same_compose + if same_compose: + return same_compose + shared_network = [item for item in matches if set(source.networks).intersection(item.networks)] + return shared_network or matches + + def add(source: DockerCandidate, target: DockerCandidate, confidence: DiscoveryConfidence, evidence: str, relation_type: RelationType = RelationType.DEPENDS_ON) -> None: + if source.stable_source_ref is None or target.stable_source_ref is None or source is target: + return + # Evidence changes as discovery learns more. It must enrich the + # same relationship rather than create a second suggestion/edge. + fingerprint = _fingerprint("relation", source.stable_source_ref, target.stable_source_ref, relation_type.value) + now = _now() + result.append( + self.discovery_store.save_suggestion( + TopologySuggestion( + id=f"suggest-{fingerprint[:14]}", + run_id=run.id, + suggestion_type="relation", + source_ref=source.stable_source_ref, + source_service_id=service_by_ref.get(source.stable_source_ref).id if source.stable_source_ref in service_by_ref else None, + target_ref=target.stable_source_ref, + target_service_id=service_by_ref.get(target.stable_source_ref).id if target.stable_source_ref in service_by_ref else None, + proposed_name=f"{source.name} depends on {target.name}", + relation_type=relation_type, + confidence=confidence, + evidence=[evidence], + created_at=now, + updated_at=now, + ), + fingerprint, + ) + ) + + def resolve_and_add( + source: DockerCandidate, + reference: str, + confidence: DiscoveryConfidence, + evidence: str, + *, + compose_only: bool = False, + relation_type: RelationType = RelationType.DEPENDS_ON, + ) -> None: + matches = resolve(source, reference, compose_only=compose_only) + if len(matches) == 1: + add(source, matches[0], confidence, evidence, relation_type) + return + if not matches or not source.stable_source_ref: + return + candidates_label = ", ".join(sorted(item.stable_source_ref or item.name for item in matches)[:6]) + fingerprint = _fingerprint("ambiguous-relation", source.stable_source_ref, reference.casefold(), RelationType.DEPENDS_ON.value) + now = _now() + result.append( + self.discovery_store.save_suggestion( + TopologySuggestion( + id=f"suggest-{fingerprint[:14]}", + run_id=run.id, + suggestion_type="relation", + source_ref=source.stable_source_ref, + source_service_id=service_by_ref.get(source.stable_source_ref).id if source.stable_source_ref in service_by_ref else None, + proposed_name=f"{source.name} has an ambiguous reference to {reference}", + relation_type=RelationType.DEPENDS_ON, + confidence=DiscoveryConfidence.LOW, + evidence=[f"{evidence}; candidates: {candidates_label}", "Manual review required; no canonical dependency was created"], + created_at=now, + updated_at=now, + ), + fingerprint, + ) + ) + + for source in candidates: + if not source.stable_source_ref: + continue + labels = source.labels + explicit = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()] + compose = [item.split(":", 1)[0].strip() for item in labels.get("com.docker.compose.depends_on", "").split(",") if item.strip()] + for name in sorted(set(explicit)): + resolve_and_add(source, name, DiscoveryConfidence.CONFIRMED, "Explicit servicemap.depends_on label") + for name in sorted(set(compose)): + resolve_and_add(source, name, DiscoveryConfidence.HIGH, "Docker Compose com.docker.compose.depends_on label", compose_only=True) + for item in source.environment_evidence: + if "hostname=" not in item: + continue + hostname = item.split("hostname=", 1)[1].split(":", 1)[0].casefold() + resolve_and_add(source, hostname, DiscoveryConfidence.MEDIUM, f"Environment endpoint reference: {item}") + for item in source.proxy_route_evidence: + proxies = [proxy for proxy in candidates if proxy.category == DiscoveryCategory.PROXY and set(proxy.networks).intersection(source.networks)] + if len(proxies) == 1: + add(proxies[0], source, DiscoveryConfidence.MEDIUM, item, RelationType.ROUTES_TO) + # Shared networks are review-only hints. Cap them to avoid flooding + # a large host with O(n²) low-confidence suggestions. + for name in source.dependency_suggestions[:8]: + resolve_and_add(source, name, DiscoveryConfidence.LOW, f"Shared Docker network hint: {', '.join(source.networks)}") + return result + + def run(self, request: DiscoveryRunRequest) -> DiscoveryResult: + if not self._lock.acquire(blocking=False): + raise ValueError("A discovery run is already in progress") + run = self.discovery_store.start_run() + try: + docker_host = self._docker_host(request.docker_host) + result = discover_containers( + docker_host, + self.store.list_services(), + include_stopped=request.include_stopped, + appops_url=self._appops_url(request.appops_url), + ) + observed_at = _now() + evidence_ttl = _evidence_ttl() + self.store.set_provider_status( + ProviderStatus( + provider=EvidenceProvider.DOCKER, + status=ProviderHealth.HEALTHY, + observed_at=observed_at, + valid_until=observed_at + evidence_ttl, + detail=f"Docker inventory returned {len(result.candidates)} candidates", + metadata={"docker_host": docker_host}, + ) + ) + if self._appops_url(request.appops_url): + self.store.set_provider_status( + ProviderStatus( + provider=EvidenceProvider.APPOPS, + status=ProviderHealth.HEALTHY if result.appops_available else ProviderHealth.UNAVAILABLE, + observed_at=observed_at, + valid_until=observed_at + evidence_ttl, + detail=result.appops_detail, + metadata={"mappings": sum(1 for item in result.candidates if item.appops_app_id)}, + ) + ) + existing = self.store.list_services() + by_ref = {service.source_ref: service for service in existing if service.source_ref} + diff = DiscoveryDiff() + observations = [] + source_counts: dict[str, int] = {"docker": len(result.candidates), "running": 0, "stopped": 0, "appops": 0, "proxy_routes": result.proxy_routes} + for candidate in result.candidates: + if candidate.state == "running": + source_counts["running"] += 1 + else: + source_counts["stopped"] += 1 + if candidate.appops_app_id: + source_counts["appops"] += 1 + stable_ref = candidate.stable_source_ref or f"docker:name:{candidate.name.casefold()}" + previous = self.discovery_store.get_source("docker", stable_ref) + previous_meta = previous.metadata if previous else {} + missing_cycles = int(previous_meta.get("missing_cycles", 0) or 0) if previous else 0 + if candidate.lifecycle == DiscoveryLifecycle.MISSING: + missing_cycles += 1 + if missing_cycles >= 3: + candidate.lifecycle = DiscoveryLifecycle.RETIRED + elif missing_cycles >= 2: + candidate.lifecycle = DiscoveryLifecycle.STALE + else: + missing_cycles = 0 + current_meta = _metadata(candidate) + current_meta["missing_cycles"] = missing_cycles + if candidate.lifecycle == DiscoveryLifecycle.MISSING: + change = "missing" + diff.missing += 1 + elif candidate.lifecycle == DiscoveryLifecycle.STALE: + change = "stale" + diff.stale += 1 + elif candidate.lifecycle == DiscoveryLifecycle.RETIRED: + change = "retired" + diff.stale += 1 + elif previous is None: + change = "new" + diff.new += 1 + elif _same_observation(previous_meta, current_meta): + change = "unchanged" + diff.unchanged += 1 + else: + change = "changed" + diff.changed += 1 + source = self.discovery_store.save_source( + source_type="docker", + source_id=stable_ref, + source_name=candidate.name, + lifecycle=candidate.lifecycle, + metadata=current_meta, + ) + observations.append( + self.discovery_store.save_observation( + run_id=run.id, + source=source, + source_name=candidate.name, + lifecycle=candidate.lifecycle, + change_type=change, + metadata=current_meta, + ) + ) + suggestion = self._candidate_suggestion(candidate) + if suggestion and candidate.lifecycle != DiscoveryLifecycle.MISSING and candidate.visibility.value != "ignored": + fingerprint = _fingerprint("service", stable_ref) + now = _now() + saved = self.discovery_store.save_suggestion( + TopologySuggestion( + id=f"suggest-{fingerprint[:14]}", + run_id=run.id, + suggestion_type="service", + source_ref=stable_ref, + source_service_id=by_ref.get(stable_ref).id if stable_ref in by_ref else None, + proposed_name=suggestion.name, + proposed_kind=suggestion.kind, + proposed_url=suggestion.url, + proposed_host=suggestion.host, + proposed_port=suggestion.port, + proposed_category=candidate.category, + proposed_group=suggestion.group, + confidence=_confidence(suggestion.confidence), + evidence=suggestion.evidence + candidate.environment_evidence, + created_at=now, + updated_at=now, + ), + fingerprint, + ) + if saved.status == SuggestionStatus.OPEN and change in {"new", "changed"}: + diff.new_suggestions += 1 + self._relation_suggestions(run, result.candidates, by_ref) + completed = self.discovery_store.finish_run( + run.id, + status=DiscoveryRunStatus.COMPLETED, + source_counts=source_counts, + diff=diff, + ) + suggestions = self.discovery_store.list_suggestions(run_id=run.id) + return DiscoveryResult( + docker_host=docker_host, + candidates=result.candidates, + run=completed, + suggestions=suggestions, + observations=observations, + appops_available=result.appops_available, + appops_mappings=source_counts["appops"], + proxy_routes=result.proxy_routes, + ) + except Exception as exc: + self.discovery_store.finish_run( + run.id, + status=DiscoveryRunStatus.FAILED, + source_counts={}, + diff=DiscoveryDiff(), + error=str(exc)[:500], + ) + raise + finally: + self._lock.release() + + def status(self) -> dict[str, object]: + current, completed = self.discovery_store.status() + return {"current": current, "last_completed": completed, "active": current is not None} + + def _payload_for_candidate(self, candidate: DockerCandidate, name: str | None = None) -> ServiceCreate: + suggestion = self._candidate_suggestion(candidate) + primary = next((item for item in candidate.probe_candidates if item.primary and item.reachability in {ProbeReachability.VERIFIED, ProbeReachability.REACHABLE}), None) + kind = primary.kind if primary else (suggestion.kind if suggestion else ServiceKind.TCP) + # Suggestions are discovery evidence, not authorization to probe. A + # published endpoint is only promoted after a bounded preflight marks + # it as the primary candidate. + url = primary.url if primary else None + host = primary.host if primary else None + port = primary.port if primary else None + # HTTP probing requires a complete URL. A container can expose only + # an internal HTTP-like port, which is useful classification evidence + # but is not a safe externally reachable endpoint for ServiceMap. + if kind == ServiceKind.HTTP: + # Normalize stale/legacy candidate suggestions as well as fresh + # Docker observations to the canonical URL-only representation. + host = None + port = None + monitoring = bool(url) if kind == ServiceKind.HTTP else bool(url or (host and port)) + if not monitoring: + url = None + host = None + port = None + group = suggestion.group if suggestion else (f"Compose · {candidate.compose_project}" if candidate.compose_project else candidate.category.value.title()) + if monitoring: + monitoring_state = MonitoringState.ACTIVE + probe_source = primary.source if primary else ProbeEndpointSource.KNOWN_URL + probe_reachability = primary.reachability if primary else ProbeReachability.REACHABLE + probe_confidence = primary.confidence if primary else DiscoveryConfidence.HIGH + elif candidate.healthcheck_present: + monitoring_state = MonitoringState.DOCKER_HEALTH + probe_source = ProbeEndpointSource.DOCKER_HEALTHCHECK + probe_reachability = ProbeReachability.VERIFIED + probe_confidence = DiscoveryConfidence.HIGH + elif candidate.state == "running": + monitoring_state = MonitoringState.OBSERVED + probe_source = ( + ProbeEndpointSource.CONTAINER_INTERNAL + if candidate.probe_candidates + else ProbeEndpointSource.DISCOVERED_ENVIRONMENT + ) + probe_reachability = ( + ProbeReachability.INTERNAL_ONLY + if candidate.probe_candidates + else ProbeReachability.NOT_CONFIGURED + ) + probe_confidence = DiscoveryConfidence.LOW + else: + monitoring_state = MonitoringState.UNMONITORED + probe_source = ProbeEndpointSource.CONTAINER_INTERNAL if candidate.probe_candidates else ProbeEndpointSource.DISCOVERED_ENVIRONMENT + probe_reachability = ProbeReachability.INTERNAL_ONLY if candidate.probe_candidates else ProbeReachability.NOT_CONFIGURED + probe_confidence = DiscoveryConfidence.LOW + return ServiceCreate( + name=name or (suggestion.name if suggestion else candidate.name), + kind=kind, + url=url, + host=host, + port=port, + description=f"Discovered Docker service from {candidate.image}", + tags=["discovered", "docker"], + group=group, + environment="production", + criticality=_criticality(candidate.appops_criticality), + category=candidate.category, + visibility=candidate.visibility, + lifecycle=candidate.lifecycle, + source_type="docker", + source_ref=candidate.stable_source_ref, + first_seen_at=_now(), + last_seen_at=_now(), + last_discovery_status=candidate.state, + container_image=candidate.image, + compose_project=candidate.compose_project, + monitoring_enabled=monitoring, + monitoring_state=monitoring_state, + probe_source=probe_source, + probe_reachability=probe_reachability, + probe_confidence=probe_confidence, + runtime_state=candidate.state, + runtime_health=candidate.health, + application=_application_name(candidate), + enabled=True, + ) + + def _update_discovered_service(self, service: Service, candidate: DockerCandidate) -> Service: + payload = self._payload_for_candidate(candidate, service.name) + updates: dict[str, Any] = payload.model_dump() + overrides = { + "name": self.discovery_store.has_override("service", service.id, "name"), + "group": self.discovery_store.has_override("service", service.id, "group"), + "category": self.discovery_store.has_override("service", service.id, "category"), + "criticality": self.discovery_store.has_override("service", service.id, "criticality"), + "environment": self.discovery_store.has_override("service", service.id, "environment"), + "probe": self.discovery_store.has_override("service", service.id, "probe"), + "visibility": self.discovery_store.has_override("service", service.id, "visibility"), + } + current = service.model_dump() + for field, locked in overrides.items(): + if locked: + if field == "probe": + for endpoint_field in ("kind", "url", "host", "port", "monitoring_enabled", "monitoring_state", "probe_source", "probe_reachability", "probe_confidence"): + updates[endpoint_field] = current[endpoint_field] + else: + updates[field] = current[field] + updates["depends_on"] = service.depends_on + # Layout is operator-owned state; rediscovery must not snap a manually + # positioned node back to an inferred default. + for field in ("layout_x", "layout_y", "layout_pinned"): + updates[field] = current[field] + updates["first_seen_at"] = service.first_seen_at or payload.first_seen_at + updates["last_seen_at"] = payload.last_seen_at + updates["lifecycle"] = candidate.lifecycle + updates["last_discovery_status"] = candidate.state + return self.store.update_service(service.id, ServiceUpdate(**updates)) or service + + def _record_candidate_evidence(self, candidate: DockerCandidate, service: Service) -> None: + """Persist source-scoped discovery claims without flattening their scope.""" + + now = _now() + evidence_ttl = _evidence_ttl() + health = (candidate.health or "").casefold() + runtime = candidate.state.casefold() + component_status: HealthStatus | None = None + if health in {"unhealthy", "down"} or runtime in {"exited", "dead", "stopped"}: + component_status = HealthStatus.DOWN + elif health == "degraded" or runtime == "restarting": + component_status = HealthStatus.DEGRADED + elif health == "healthy": + component_status = HealthStatus.HEALTHY + if component_status is not None: + self.store.record_health_evidence( + HealthEvidence( + id=str(uuid4()), + subject_type="service", + subject_id=service.id, + service_id=service.id, + provider=EvidenceProvider.DOCKER, + provider_ref=candidate.stable_source_ref, + status=component_status, + scope=EvidenceScope.COMPONENT, + confidence=0.95 if candidate.healthcheck_present else 0.8, + observed_at=now, + valid_until=now + evidence_ttl, + received_at=now, + summary="Docker healthcheck" if candidate.healthcheck_present else "Docker runtime state", + metadata={"container_id": candidate.container_id, "state": candidate.state, "health": candidate.health}, + ) + ) + normalized_appops = appops_health_status(candidate.appops_health) + if candidate.appops_app_id and normalized_appops is not None: + self.store.record_health_evidence( + HealthEvidence( + id=str(uuid4()), + subject_type="application", + subject_id=f"appops:{candidate.appops_app_id}", + service_id=None, + provider=EvidenceProvider.APPOPS, + provider_ref=candidate.appops_app_id, + status=normalized_appops, + scope=EvidenceScope.APPLICATION, + confidence=candidate.appops_discovery_confidence if candidate.appops_discovery_confidence is not None else 0.5, + observed_at=now, + valid_until=now + evidence_ttl, + received_at=now, + summary="AppOps application health; not used as component direct health", + metadata={ + "app_id": candidate.appops_app_id, + "category": candidate.appops_category, + "policy_profile": candidate.appops_policy_profile, + "app_type": candidate.appops_app_type, + "discovery_confidence": candidate.appops_discovery_confidence, + }, + ), + service_ids=[service.id], + ) + + def _service_by_ref(self) -> dict[str, Service]: + return {service.source_ref: service for service in self.store.list_services() if service.source_ref} + + def reconcile(self, request: DiscoveryReconcileRequest) -> ReconcileResult: + run = self.discovery_store.get_run(request.run_id) if request.run_id else self.discovery_store.last_completed() + if run is None or run.status != DiscoveryRunStatus.COMPLETED: + raise ValueError("A completed discovery run is required before reconciliation") + self.discovery_store.set_run_status(run.id, DiscoveryRunStatus.RECONCILING) + candidates = [DockerCandidate.model_validate(item.get("candidate", item)) for item in self.discovery_store.candidates_for_run(run.id)] + by_ref = self._service_by_ref() + created = updated = unchanged = missing = retired = 0 + service_ids: list[str] = [] + for candidate in candidates: + if not candidate.stable_source_ref: + continue + existing = by_ref.get(candidate.stable_source_ref) + if candidate.lifecycle in {DiscoveryLifecycle.MISSING, DiscoveryLifecycle.STALE, DiscoveryLifecycle.RETIRED}: + if existing: + if candidate.lifecycle == DiscoveryLifecycle.MISSING: + missing += 1 + else: + retired += 1 + payload = ServiceUpdate.model_validate(existing.model_dump()) + payload.lifecycle = candidate.lifecycle + payload.last_discovery_status = "missing" + payload.monitoring_enabled = False + self.store.update_service(existing.id, payload) + continue + if candidate.visibility.value == "ignored": + continue + if existing is None and candidate.state == "running" and request.apply_high_confidence: + payload = self._payload_for_candidate(candidate) + name_conflict = self.store.get_service_by_name(payload.name) + if name_conflict: + payload.name = f"{payload.name} [{candidate.container_id[:6]}]" + existing = self.store.create_service(payload) + by_ref[candidate.stable_source_ref] = existing + created += 1 + self.discovery_store.record_change("discovered", f"Discovered {existing.name}", service_id=existing.id) + elif existing is not None: + current = existing.model_dump() + updated_service = self._update_discovered_service(existing, candidate) + unchanged += 1 if updated_service.model_dump() == current else 0 + updated += 0 if updated_service.model_dump() == current else 1 + existing = updated_service + by_ref[candidate.stable_source_ref] = existing + if existing: + service_ids.append(existing.id) + source = self.discovery_store.get_source("docker", candidate.stable_source_ref) + if source: + self.discovery_store.attach_source(existing.id, source.id) + self._record_candidate_evidence(candidate, existing) + + # The v1.1 generic baseline is preserved for history but no longer + # appears as the primary production topology after explicit bootstrap. + if request.apply_high_confidence and created: + discovered_ids = set(service_ids) + for service in self.store.list_services(): + if service.id in discovered_ids or service.source_type: + continue + payload = ServiceUpdate.model_validate(service.model_dump()) + payload.visibility = "hidden" + payload.lifecycle = DiscoveryLifecycle.RETIRED + payload.monitoring_enabled = False + self.store.update_service(service.id, payload) + retired += 1 + + accepted_relations = 0 + suggestions = self.discovery_store.list_suggestions(run_id=run.id) + by_ref = self._service_by_ref() + for suggestion in suggestions: + if suggestion.suggestion_type != "relation" or not suggestion.source_ref or not suggestion.target_ref: + continue + source = by_ref.get(suggestion.source_ref) + target = by_ref.get(suggestion.target_ref) + if not source or not target: + continue + relation_type = suggestion.relation_type or RelationType.DEPENDS_ON + previous_evidence = self.discovery_store.get_evidence_for_relation( + source.id, + target.id, + suggestion.target_ref, + relation_type, + ) + confidence_rank = { + DiscoveryConfidence.LOW: 1, + DiscoveryConfidence.MEDIUM: 2, + DiscoveryConfidence.HIGH: 3, + DiscoveryConfidence.CONFIRMED: 4, + } + evidence_parts = list(suggestion.evidence) + if previous_evidence: + evidence_parts = [previous_evidence.explanation, *evidence_parts] + evidence_text = "; ".join(dict.fromkeys(part for part in evidence_parts if part)) + evidence = RelationEvidence( + id=suggestion.id, + source_service_id=source.id, + target_service_id=target.id, + target_ref=suggestion.target_ref, + relation_type=relation_type, + evidence_type="discovery", + confidence=( + previous_evidence.confidence + if previous_evidence and confidence_rank[previous_evidence.confidence] >= confidence_rank[suggestion.confidence] + else suggestion.confidence + ), + explanation=evidence_text, + source_ref=suggestion.source_ref, + locked=previous_evidence.locked if previous_evidence else suggestion.status == SuggestionStatus.ACCEPTED, + active=previous_evidence.active if previous_evidence else True, + first_seen_at=previous_evidence.first_seen_at if previous_evidence else suggestion.created_at, + last_seen_at=suggestion.updated_at, + ) + self.discovery_store.save_evidence(evidence) + if previous_evidence and not previous_evidence.active: + # An operator rejected/removed this inferred edge. Keep the + # durable evidence for audit, but do not resurrect its health + # propagation on a later discovery cycle. + continue + should_accept = suggestion.status == SuggestionStatus.ACCEPTED or ( + request.apply_high_confidence and suggestion.confidence in {DiscoveryConfidence.CONFIRMED, DiscoveryConfidence.HIGH} + ) + if not should_accept or suggestion.relation_type != RelationType.DEPENDS_ON: + continue + if target.id in source.depends_on: + if suggestion.status != SuggestionStatus.ACCEPTED: + self.discovery_store.set_suggestion_status(suggestion.id, SuggestionStatus.ACCEPTED) + continue + try: + validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id) + except ValueError: + continue + payload = ServiceUpdate.model_validate(source.model_dump()) + payload.depends_on = [*source.depends_on, target.id] + self.store.update_service(source.id, payload) + self.discovery_store.set_suggestion_status(suggestion.id, SuggestionStatus.ACCEPTED) + self.discovery_store.record_change("dependency_added", f"{source.name} depends on {target.name}", source_service_id=source.id, target_service_id=target.id) + accepted_relations += 1 + + final_run = self.discovery_store.finish_run( + run.id, + status=DiscoveryRunStatus.COMPLETED, + source_counts=run.source_counts, + diff=run.diff, + ) + self.discovery_store.capture_snapshot(run_id=final_run.id) + return ReconcileResult( + run=final_run, + created=created, + updated=updated, + unchanged=unchanged, + missing=missing, + retired=retired, + accepted_relations=accepted_relations, + open_suggestions=len(self.discovery_store.list_suggestions(SuggestionStatus.OPEN)), + service_ids=sorted(set(service_ids)), + ) + + @staticmethod + def _endpoint_label(service: Service | ServiceCreate) -> str | None: + if service.url: + return service.url + if service.host: + return f"{service.host}{f':{service.port}' if service.port else ''}" + return None + + def repair_probe_reachability(self, *, run_id: str | None = None, dry_run: bool = True) -> ProbeRepairReport: + """Safely retire unsafe discovery endpoints without touching probe history. + + Only Docker-managed services are considered. User probe overrides remain + authoritative and the operation intentionally never deletes a service, + relation, probe result, or discovery observation. + """ + + run = self.discovery_store.get_run(run_id) if run_id else self.discovery_store.last_completed() + if run is None or run.status != DiscoveryRunStatus.COMPLETED: + raise ValueError("A completed discovery run is required before repairing probe reachability") + candidates = [DockerCandidate.model_validate(item.get("candidate", item)) for item in self.discovery_store.candidates_for_run(run.id)] + services = self._service_by_ref() + changes: list[ProbeRepairChange] = [] + disabled_unsafe = enabled_published = docker_health_observed = 0 + appops_observed = runtime_observed = unmonitored = 0 + for candidate in candidates: + if not candidate.stable_source_ref or candidate.lifecycle != DiscoveryLifecycle.ACTIVE: + continue + current = services.get(candidate.stable_source_ref) + if current is None: + continue + desired = self._payload_for_candidate(candidate, current.name) + is_override = self.discovery_store.has_override("service", current.id, "probe") + next_endpoint = self._endpoint_label(desired) + previous_endpoint = self._endpoint_label(current) + if is_override: + next_endpoint = previous_endpoint + changed = (not is_override) and any( + getattr(current, field) != getattr(desired, field) + for field in ("kind", "url", "host", "port", "monitoring_enabled", "monitoring_state", "probe_source", "probe_reachability", "probe_confidence") + ) + if not changed: + if not dry_run: + self.store.set_runtime_observation( + current.id, + runtime_state=candidate.state, + runtime_health=candidate.health, + monitoring_state=current.monitoring_state.value, + ) + continue + if current.monitoring_enabled and not desired.monitoring_enabled: + reason = "Internal container endpoint is not reachable from ServiceMap; active probe disabled" + disabled_unsafe += 1 + elif desired.monitoring_enabled: + reason = "Host-published endpoint selected using the configured probe host" + enabled_published += 1 + elif candidate.healthcheck_present: + reason = "Docker healthcheck retained as runtime observation without a network probe" + docker_health_observed += 1 + elif desired.monitoring_state == MonitoringState.OBSERVED: + reason = "No verified reachable endpoint; Docker runtime observation is retained without a network probe" + runtime_observed += 1 + else: + reason = "No verified reachable endpoint; service remains observed as unmonitored" + unmonitored += 1 + changes.append( + ProbeRepairChange( + service_id=current.id, + service_name=current.name, + previous_monitoring_state=current.monitoring_state, + next_monitoring_state=desired.monitoring_state, + previous_endpoint=previous_endpoint, + next_endpoint=next_endpoint, + next_probe_source=desired.probe_source, + next_probe_confidence=desired.probe_confidence, + reason=reason, + ) + ) + + if not dry_run: + updated = self._update_discovered_service(current, candidate) + monitoring_state = updated.monitoring_state.value + self.store.set_runtime_observation( + updated.id, + runtime_state=candidate.state, + runtime_health=candidate.health, + monitoring_state=monitoring_state, + ) + self.discovery_store.record_change("probe_endpoint_superseded", reason, service_id=updated.id) + resolved = 0 + if not dry_run: + from .incidents import sync_incidents + + before = len([item for item in self.store.list_incidents(active_only=True)]) + sync_incidents(self.store, resolution_reason="Monitoring endpoint superseded; active probe was replaced by reachability-safe observation") + after = len([item for item in self.store.list_incidents(active_only=True)]) + resolved = max(0, before - after) + return ProbeRepairReport( + run_id=run.id, + dry_run=dry_run, + inspected=sum(1 for candidate in candidates if candidate.stable_source_ref in services), + changed=len(changes), + disabled_unsafe=disabled_unsafe, + enabled_published=enabled_published, + docker_health_observed=docker_health_observed, + appops_observed=appops_observed, + runtime_observed=runtime_observed, + unmonitored=unmonitored, + resolved_incidents=resolved, + changes=changes, + ) + + def apply_edge(self, request: EdgeMutationRequest) -> RelationEvidence: + source = self.store.get_service(request.source_service_id) + target = self.store.get_service(request.target_service_id) + if source is None or target is None: + raise ValueError("Both edge services must exist") + if request.relation_type == RelationType.DEPENDS_ON and target.id not in source.depends_on: + validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id) + payload = ServiceUpdate.model_validate(source.model_dump()) + payload.depends_on = [*source.depends_on, target.id] + self.store.update_service(source.id, payload) + now = _now() + evidence = RelationEvidence( + id=f"manual-edge-{uuid4().hex[:14]}", + source_service_id=source.id, + target_service_id=target.id, + relation_type=request.relation_type, + evidence_type="manual", + confidence=DiscoveryConfidence.CONFIRMED, + explanation=request.explanation, + source_ref=f"manual:{source.id}->{target.id}", + locked=request.lock, + first_seen_at=now, + last_seen_at=now, + ) + saved = self.discovery_store.save_evidence(evidence) + self.discovery_store.save_override("relation", saved.id, "locked", request.lock, request.lock) + self.discovery_store.record_change("dependency_added", request.explanation, source_service_id=source.id, target_service_id=target.id) + return saved + + def suggestion_action(self, suggestion_id: str, action: str) -> TopologySuggestion: + suggestion = self.discovery_store.get_suggestion(suggestion_id) + if suggestion is None: + raise ValueError("Suggestion not found") + if action == "reject": + if suggestion.suggestion_type == "relation" and suggestion.relation_type == RelationType.DEPENDS_ON: + source = self._service_by_ref().get(suggestion.source_ref or "") + target = self._service_by_ref().get(suggestion.target_ref or "") + existing_evidence = self.discovery_store.get_evidence(suggestion.id) + if source and target and (existing_evidence is None or not existing_evidence.locked): + payload = ServiceUpdate.model_validate(source.model_dump()) + payload.depends_on = [item for item in source.depends_on if item != target.id] + self.store.update_service(source.id, payload) + if existing_evidence: + self.discovery_store.set_evidence_state(suggestion.id, active=False) + return self.discovery_store.set_suggestion_status(suggestion_id, SuggestionStatus.REJECTED) or suggestion + if action in {"confirm", "lock"}: + if suggestion.suggestion_type == "service": + if not suggestion.source_ref: + raise ValueError("Service suggestion has no stable source reference") + existing = self._service_by_ref().get(suggestion.source_ref) + if existing is None: + candidate = next( + ( + DockerCandidate.model_validate(item.get("candidate", item)) + for item in self.discovery_store.candidates_for_run(suggestion.run_id) + if item.get("candidate", {}).get("stable_source_ref") == suggestion.source_ref + ), + None, + ) + if candidate is None: + raise ValueError("Discovery observation for service suggestion is no longer available") + existing = self.store.create_service(self._payload_for_candidate(candidate)) + source = self.discovery_store.get_source("docker", suggestion.source_ref) + if source: + self.discovery_store.attach_source(existing.id, source.id) + self.discovery_store.record_change("accepted", f"Accepted discovered service {existing.name}", service_id=existing.id) + self.discovery_store.save_override("service", existing.id, "source", suggestion.source_ref, True) + elif suggestion.suggestion_type == "relation": + source = self._service_by_ref().get(suggestion.source_ref or "") + target = self._service_by_ref().get(suggestion.target_ref or "") + if source is None or target is None: + raise ValueError("Accept the related services before accepting this relationship") + if suggestion.relation_type == RelationType.DEPENDS_ON and target.id not in source.depends_on: + validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id) + payload = ServiceUpdate.model_validate(source.model_dump()) + payload.depends_on = [*source.depends_on, target.id] + self.store.update_service(source.id, payload) + now = _now() + self.discovery_store.save_evidence( + RelationEvidence( + id=suggestion.id, + source_service_id=source.id, + target_service_id=target.id, + target_ref=suggestion.target_ref, + relation_type=suggestion.relation_type or RelationType.DEPENDS_ON, + evidence_type="discovery", + confidence=suggestion.confidence, + explanation="; ".join(suggestion.evidence), + source_ref=suggestion.source_ref, + locked=action == "lock", + first_seen_at=suggestion.created_at, + last_seen_at=now, + ) + ) + self.discovery_store.record_change("dependency_added", f"Accepted relationship {source.name} → {target.name}", source_service_id=source.id, target_service_id=target.id) + return self.discovery_store.set_suggestion_status(suggestion_id, SuggestionStatus.ACCEPTED) or suggestion + raise ValueError("Unsupported suggestion action") + + def record_service_override(self, before: Service, after: Service) -> None: + for field in ("name", "group", "category", "criticality", "environment", "visibility"): + if getattr(before, field) != getattr(after, field): + value = getattr(after, field) + self.discovery_store.save_override("service", after.id, field, value.value if hasattr(value, "value") else value) + if (before.kind, before.url, before.host, before.port, before.monitoring_enabled) != ( + after.kind, + after.url, + after.host, + after.port, + after.monitoring_enabled, + ): + self.discovery_store.save_override("service", after.id, "probe", after.model_dump(include={"kind", "url", "host", "port", "monitoring_enabled"})) + + def edge_action(self, evidence_id: str, action: str) -> RelationEvidence: + evidence = self.discovery_store.get_evidence(evidence_id) + if evidence is None or evidence.target_service_id is None: + raise ValueError("Edge evidence not found") + source = self.store.get_service(evidence.source_service_id) + if source is None: + raise ValueError("Edge source service not found") + target_id = evidence.target_service_id + if action in {"remove", "reject"} and evidence.relation_type == RelationType.DEPENDS_ON: + payload = ServiceUpdate.model_validate(source.model_dump()) + payload.depends_on = [item for item in source.depends_on if item != target_id] + self.store.update_service(source.id, payload) + self.discovery_store.record_change("dependency_removed", f"Removed relationship from {source.name}", source_service_id=source.id, target_service_id=target_id) + return self.discovery_store.set_evidence_state(evidence_id, active=False) or evidence + if action == "confirm" and evidence.relation_type == RelationType.DEPENDS_ON and target_id not in source.depends_on: + validate_dependencies([*source.depends_on, target_id], self.store, service_id=source.id) + payload = ServiceUpdate.model_validate(source.model_dump()) + payload.depends_on = [*source.depends_on, target_id] + self.store.update_service(source.id, payload) + if action == "lock": + self.discovery_store.save_override("relation", evidence_id, "locked", True, True) + return self.discovery_store.set_evidence_state(evidence_id, active=True, locked=action == "lock" or evidence.locked) or evidence diff --git a/backend/app/discovery_store.py b/backend/app/discovery_store.py new file mode 100644 index 0000000..11d560c --- /dev/null +++ b/backend/app/discovery_store.py @@ -0,0 +1,979 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime +from hashlib import sha256 +from typing import Any +from uuid import uuid4 + +from .models import ( + DiscoveryConfidence, + DiscoveryDiff, + DiscoveryLifecycle, + DiscoveryObservation, + DiscoveryRun, + DiscoveryRunStatus, + LogicalRelation, + RelationEvidence, + RelationType, + ServiceSource, + SuggestionStatus, + TopologyChange, + TopologyDiff, + TopologySnapshot, + TopologySuggestion, +) +from .store import Store + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _json(value: object) -> str: + return json.dumps(value, separators=(",", ":"), sort_keys=True) + + +def _obj(value: object) -> dict[str, object]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + +def _list(value: object) -> list[str]: + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + try: + parsed = json.loads(value) + return [str(item) for item in parsed] if isinstance(parsed, list) else [] + except json.JSONDecodeError: + return [] + return [] + + +_CONFIDENCE_RANK = { + DiscoveryConfidence.LOW: 1, + DiscoveryConfidence.MEDIUM: 2, + DiscoveryConfidence.HIGH: 3, + DiscoveryConfidence.CONFIRMED: 4, +} +_CONFIDENCE_RANK_INV = {rank: confidence.value for confidence, rank in _CONFIDENCE_RANK.items()} + + +def _higher_confidence(*values: DiscoveryConfidence) -> DiscoveryConfidence: + return max(values, key=lambda value: _CONFIDENCE_RANK[value]) + + +class DiscoveryStore: + """Durable discovery observations and review state. + + The existing Store remains the owner of canonical services and probe state. + This companion store deliberately keeps observations and suggestions separate + so a discovery run cannot mutate health topology unless reconciliation or an + explicit suggestion action asks it to do so. + """ + + def __init__(self, store: Store) -> None: + self.store = store + self._init_schema() + self.ensure_baseline_snapshot() + + def _connect(self) -> sqlite3.Connection: + return self.store._connect() + + def _init_schema(self) -> None: + with self._connect() as db: + db.executescript( + """ + CREATE TABLE IF NOT EXISTS discovery_runs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + source_counts TEXT NOT NULL DEFAULT '{}', + diff TEXT NOT NULL DEFAULT '{}', + error TEXT + ); + CREATE INDEX IF NOT EXISTS idx_discovery_runs_status + ON discovery_runs(status, started_at DESC); + + CREATE TABLE IF NOT EXISTS discovery_sources ( + id TEXT PRIMARY KEY, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + source_name TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + lifecycle TEXT NOT NULL DEFAULT 'active', + managed INTEGER NOT NULL DEFAULT 1, + metadata TEXT NOT NULL DEFAULT '{}', + UNIQUE(source_type, source_id) + ); + CREATE INDEX IF NOT EXISTS idx_discovery_sources_identity + ON discovery_sources(source_type, source_id); + CREATE INDEX IF NOT EXISTS idx_discovery_sources_last_seen + ON discovery_sources(last_seen_at DESC); + + CREATE TABLE IF NOT EXISTS service_sources ( + service_id TEXT NOT NULL, + source_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'observed', + PRIMARY KEY(service_id, source_id), + FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE, + FOREIGN KEY(source_id) REFERENCES discovery_sources(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_service_sources_source + ON service_sources(source_id, service_id); + + CREATE TABLE IF NOT EXISTS discovery_observations ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + source_id TEXT NOT NULL, + source_type TEXT NOT NULL, + source_name TEXT NOT NULL, + lifecycle TEXT NOT NULL, + change_type TEXT NOT NULL, + observed_at TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE CASCADE, + FOREIGN KEY(source_id) REFERENCES discovery_sources(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_discovery_observations_run + ON discovery_observations(run_id, observed_at DESC); + CREATE INDEX IF NOT EXISTS idx_discovery_observations_source + ON discovery_observations(source_id, observed_at DESC); + + CREATE TABLE IF NOT EXISTS relation_evidence ( + id TEXT PRIMARY KEY, + source_service_id TEXT NOT NULL, + target_service_id TEXT, + target_ref TEXT, + relation_type TEXT NOT NULL DEFAULT 'depends_on', + evidence_type TEXT NOT NULL, + confidence TEXT NOT NULL, + explanation TEXT NOT NULL, + source_ref TEXT, + locked INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + UNIQUE(source_service_id, target_service_id, target_ref, relation_type, evidence_type), + FOREIGN KEY(source_service_id) REFERENCES services(id) ON DELETE CASCADE, + FOREIGN KEY(target_service_id) REFERENCES services(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_relation_evidence_source_target + ON relation_evidence(source_service_id, target_service_id); + CREATE INDEX IF NOT EXISTS idx_relation_evidence_confidence + ON relation_evidence(confidence, active); + + CREATE TABLE IF NOT EXISTS topology_suggestions ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + suggestion_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + fingerprint TEXT NOT NULL UNIQUE, + source_ref TEXT, + source_service_id TEXT, + target_ref TEXT, + target_service_id TEXT, + proposed_name TEXT, + proposed_kind TEXT, + proposed_url TEXT, + proposed_host TEXT, + proposed_port INTEGER, + proposed_category TEXT NOT NULL DEFAULT 'unknown', + proposed_group TEXT, + relation_type TEXT, + confidence TEXT NOT NULL, + evidence TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_topology_suggestions_status + ON topology_suggestions(status, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_topology_suggestions_source + ON topology_suggestions(source_ref, target_ref); + + CREATE TABLE IF NOT EXISTS topology_overrides ( + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + field_name TEXT NOT NULL, + value TEXT NOT NULL, + locked INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + PRIMARY KEY(target_type, target_id, field_name) + ); + CREATE INDEX IF NOT EXISTS idx_topology_overrides_target + ON topology_overrides(target_type, target_id); + + CREATE TABLE IF NOT EXISTS topology_change_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + service_id TEXT, + source_service_id TEXT, + target_service_id TEXT, + detail TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_topology_change_events_time + ON topology_change_events(created_at DESC); + + CREATE TABLE IF NOT EXISTS topology_snapshots ( + id TEXT PRIMARY KEY, + run_id TEXT, + created_at TEXT NOT NULL, + revision TEXT NOT NULL UNIQUE, + service_count INTEGER NOT NULL, + relationship_count INTEGER NOT NULL, + summary TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_topology_snapshots_time + ON topology_snapshots(created_at DESC); + CREATE TABLE IF NOT EXISTS topology_snapshot_services ( + snapshot_id TEXT NOT NULL, + service_id TEXT NOT NULL, + name TEXT NOT NULL, + fingerprint TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY(snapshot_id, service_id), + FOREIGN KEY(snapshot_id) REFERENCES topology_snapshots(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS topology_snapshot_relations ( + snapshot_id TEXT NOT NULL, + relation_id TEXT NOT NULL, + source_service_id TEXT NOT NULL, + target_service_id TEXT, + relation_type TEXT NOT NULL, + fingerprint TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY(snapshot_id, relation_id), + FOREIGN KEY(snapshot_id) REFERENCES topology_snapshots(id) ON DELETE CASCADE + ); + """ + ) + + @staticmethod + def _diff(value: object) -> DiscoveryDiff: + data = _obj(value) + return DiscoveryDiff(**{key: int(data.get(key, 0) or 0) for key in DiscoveryDiff.model_fields}) + + @classmethod + def _run(cls, row: sqlite3.Row | None) -> DiscoveryRun | None: + if row is None: + return None + return DiscoveryRun( + id=row["id"], + status=DiscoveryRunStatus(row["status"]), + started_at=datetime.fromisoformat(row["started_at"]), + completed_at=datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None, + source_counts={str(k): int(v) for k, v in _obj(row["source_counts"]).items()}, + diff=cls._diff(row["diff"]), + error=row["error"], + ) + + @classmethod + def _source(cls, row: sqlite3.Row) -> ServiceSource: + return ServiceSource( + id=row["id"], + source_type=row["source_type"], + source_id=row["source_id"], + source_name=row["source_name"], + first_seen_at=datetime.fromisoformat(row["first_seen_at"]), + last_seen_at=datetime.fromisoformat(row["last_seen_at"]), + lifecycle=DiscoveryLifecycle(row["lifecycle"]), + managed=bool(row["managed"]), + metadata=_obj(row["metadata"]), + ) + + @classmethod + def _suggestion(cls, row: sqlite3.Row) -> TopologySuggestion: + return TopologySuggestion( + id=row["id"], + run_id=row["run_id"], + suggestion_type=row["suggestion_type"], + status=SuggestionStatus(row["status"]), + source_ref=row["source_ref"], + source_service_id=row["source_service_id"], + target_ref=row["target_ref"], + target_service_id=row["target_service_id"], + proposed_name=row["proposed_name"], + proposed_kind=row["proposed_kind"], + proposed_url=row["proposed_url"], + proposed_host=row["proposed_host"], + proposed_port=row["proposed_port"], + proposed_category=row["proposed_category"], + proposed_group=row["proposed_group"], + relation_type=row["relation_type"], + confidence=DiscoveryConfidence(row["confidence"]), + evidence=_list(row["evidence"]), + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + ) + + @classmethod + def _evidence(cls, row: sqlite3.Row) -> RelationEvidence: + return RelationEvidence( + id=row["id"], + source_service_id=row["source_service_id"], + target_service_id=row["target_service_id"], + target_ref=row["target_ref"], + relation_type=RelationType(row["relation_type"]), + evidence_type=row["evidence_type"], + confidence=DiscoveryConfidence(row["confidence"]), + explanation=row["explanation"], + source_ref=row["source_ref"], + locked=bool(row["locked"]), + active=bool(row["active"]), + first_seen_at=datetime.fromisoformat(row["first_seen_at"]), + last_seen_at=datetime.fromisoformat(row["last_seen_at"]), + ) + + def start_run(self) -> DiscoveryRun: + run = DiscoveryRun(id=f"disc-{uuid4().hex[:12]}", status=DiscoveryRunStatus.DISCOVERING, started_at=_now()) + with self._connect() as db: + # A process restart or an unexpected reconcile exception can leave + # a durable run in an active state. Close that stale marker before + # creating the next run so status never reports a phantom active + # discovery forever. + db.execute( + "UPDATE discovery_runs SET status=?, completed_at=?, error=COALESCE(error, ?) WHERE status IN (?, ?)", + ( + DiscoveryRunStatus.FAILED.value, + run.started_at.isoformat(), + "Superseded by a newer discovery run", + DiscoveryRunStatus.DISCOVERING.value, + DiscoveryRunStatus.RECONCILING.value, + ), + ) + db.execute( + "INSERT INTO discovery_runs (id, status, started_at) VALUES (?, ?, ?)", + (run.id, run.status.value, run.started_at.isoformat()), + ) + return run + + def finish_run( + self, + run_id: str, + *, + status: DiscoveryRunStatus, + source_counts: dict[str, int], + diff: DiscoveryDiff, + error: str | None = None, + ) -> DiscoveryRun: + completed = _now() + with self._connect() as db: + db.execute( + "UPDATE discovery_runs SET status=?, completed_at=?, source_counts=?, diff=?, error=? WHERE id=?", + (status.value, completed.isoformat(), _json(source_counts), _json(diff.model_dump()), error, run_id), + ) + return self.get_run(run_id) or DiscoveryRun( + id=run_id, + status=status, + started_at=completed, + completed_at=completed, + source_counts=source_counts, + diff=diff, + error=error, + ) + + def get_run(self, run_id: str) -> DiscoveryRun | None: + with self._connect() as db: + row = db.execute("SELECT * FROM discovery_runs WHERE id = ?", (run_id,)).fetchone() + return self._run(row) + + def last_completed(self) -> DiscoveryRun | None: + with self._connect() as db: + row = db.execute( + "SELECT * FROM discovery_runs WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1" + ).fetchone() + return self._run(row) + + def status(self) -> tuple[DiscoveryRun | None, DiscoveryRun | None]: + with self._connect() as db: + current_row = db.execute( + "SELECT * FROM discovery_runs WHERE status IN ('discovering', 'reconciling') ORDER BY started_at DESC LIMIT 1" + ).fetchone() + return self._run(current_row), self.last_completed() + + @staticmethod + def _snapshot_row(row: sqlite3.Row | None) -> TopologySnapshot | None: + if row is None: + return None + return TopologySnapshot( + id=row["id"], + run_id=row["run_id"], + created_at=datetime.fromisoformat(row["created_at"]), + revision=row["revision"], + service_count=int(row["service_count"]), + relationship_count=int(row["relationship_count"]), + summary={str(key): int(value) for key, value in _obj(row["summary"]).items()}, + ) + + def ensure_baseline_snapshot(self) -> TopologySnapshot: + """Create one migration baseline without manufacturing repeated no-op snapshots.""" + + with self._connect() as db: + existing = db.execute("SELECT * FROM topology_snapshots ORDER BY created_at DESC LIMIT 1").fetchone() + return self._snapshot_row(existing) if existing else self.capture_snapshot(run_id=None) + + def capture_snapshot(self, *, run_id: str | None) -> TopologySnapshot: + """Persist a compact canonical topology capture when its revision changes.""" + + services = sorted(self.store.list_services(), key=lambda item: item.id) + service_rows: list[tuple[str, str, str, dict[str, object]]] = [] + relation_rows: list[tuple[str, str, str | None, str, str, dict[str, object]]] = [] + for service in services: + payload: dict[str, object] = { + "id": service.id, + "name": service.name, + "kind": service.kind.value, + "source_ref": service.source_ref, + "application": service.application, + "domain": service.domain, + "component_role": service.component_role.value, + "category": service.category.value, + "criticality": service.criticality.value, + "visibility": service.visibility.value, + "lifecycle": service.lifecycle.value, + "monitoring_state": service.monitoring_state.value, + "depends_on": sorted(service.depends_on), + } + service_rows.append((service.id, service.name, sha256(_json(payload).encode()).hexdigest(), payload)) + for target_id in sorted(service.depends_on): + relation_payload: dict[str, object] = { + "source_service_id": service.id, + "target_service_id": target_id, + "relation_type": RelationType.DEPENDS_ON.value, + } + relation_id = sha256(_json(relation_payload).encode()).hexdigest()[:24] + relation_rows.append( + ( + relation_id, + service.id, + target_id, + RelationType.DEPENDS_ON.value, + sha256(_json(relation_payload).encode()).hexdigest(), + relation_payload, + ) + ) + revision_payload = {"services": [(item[0], item[2]) for item in service_rows], "relations": [(item[0], item[4]) for item in relation_rows]} + revision = sha256(_json(revision_payload).encode()).hexdigest() + with self._connect() as db: + existing = db.execute("SELECT * FROM topology_snapshots WHERE revision=?", (revision,)).fetchone() + if existing: + return self._snapshot_row(existing) # type: ignore[return-value] + created_at = _now() + summary = { + "visible_services": sum(1 for item in services if item.visibility.value == "visible"), + "active_services": sum(1 for item in services if item.lifecycle.value == "active"), + } + snapshot = TopologySnapshot( + id=f"topo-{uuid4().hex[:14]}", + run_id=run_id, + created_at=created_at, + revision=revision, + service_count=len(service_rows), + relationship_count=len(relation_rows), + summary=summary, + ) + db.execute( + "INSERT INTO topology_snapshots VALUES (?, ?, ?, ?, ?, ?, ?)", + (snapshot.id, snapshot.run_id, snapshot.created_at.isoformat(), snapshot.revision, snapshot.service_count, snapshot.relationship_count, _json(snapshot.summary)), + ) + db.executemany( + "INSERT INTO topology_snapshot_services VALUES (?, ?, ?, ?, ?)", + [(snapshot.id, service_id, name, fingerprint, _json(payload)) for service_id, name, fingerprint, payload in service_rows], + ) + db.executemany( + "INSERT INTO topology_snapshot_relations VALUES (?, ?, ?, ?, ?, ?, ?)", + [(snapshot.id, relation_id, source_id, target_id, relation_type, fingerprint, _json(payload)) for relation_id, source_id, target_id, relation_type, fingerprint, payload in relation_rows], + ) + self.record_change("topology_snapshot", f"Topology snapshot captured ({snapshot.service_count} services)") + return snapshot + + def list_snapshots(self, *, limit: int = 50) -> list[TopologySnapshot]: + with self._connect() as db: + rows = db.execute("SELECT * FROM topology_snapshots ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return [self._snapshot_row(row) for row in rows if self._snapshot_row(row) is not None] # type: ignore[list-item] + + def topology_diff(self, *, after_snapshot_id: str, before_snapshot_id: str | None = None) -> TopologyDiff: + with self._connect() as db: + after = db.execute("SELECT * FROM topology_snapshots WHERE id=?", (after_snapshot_id,)).fetchone() + if after is None: + raise ValueError("Topology snapshot not found") + before = ( + db.execute("SELECT * FROM topology_snapshots WHERE id=?", (before_snapshot_id,)).fetchone() + if before_snapshot_id + else db.execute("SELECT * FROM topology_snapshots WHERE created_at < ? ORDER BY created_at DESC LIMIT 1", (after["created_at"],)).fetchone() + ) + after_services = { + row["service_id"]: row + for row in db.execute("SELECT * FROM topology_snapshot_services WHERE snapshot_id=?", (after_snapshot_id,)).fetchall() + } + before_services = ( + {row["service_id"]: row for row in db.execute("SELECT * FROM topology_snapshot_services WHERE snapshot_id=?", (before["id"],)).fetchall()} + if before else {} + ) + after_relations = { + row["relation_id"]: row + for row in db.execute("SELECT * FROM topology_snapshot_relations WHERE snapshot_id=?", (after_snapshot_id,)).fetchall() + } + before_relations = ( + {row["relation_id"]: row for row in db.execute("SELECT * FROM topology_snapshot_relations WHERE snapshot_id=?", (before["id"],)).fetchall()} + if before else {} + ) + + added: list[TopologyChange] = [] + removed: list[TopologyChange] = [] + changed: list[TopologyChange] = [] + for item_id, row in after_services.items(): + payload = _obj(row["payload"]) + if item_id not in before_services: + added.append(TopologyChange(change_type="added", entity_type="service", entity_id=item_id, name=row["name"], after=payload)) + elif row["fingerprint"] != before_services[item_id]["fingerprint"]: + changed.append(TopologyChange(change_type="changed", entity_type="service", entity_id=item_id, name=row["name"], before=_obj(before_services[item_id]["payload"]), after=payload)) + for item_id, row in before_services.items(): + if item_id not in after_services: + removed.append(TopologyChange(change_type="removed", entity_type="service", entity_id=item_id, name=row["name"], before=_obj(row["payload"]))) + for item_id, row in after_relations.items(): + name = f"{row['source_service_id']} → {row['target_service_id'] or 'unknown'}" + if item_id not in before_relations: + added.append(TopologyChange(change_type="added", entity_type="relationship", entity_id=item_id, name=name, after=_obj(row["payload"]))) + elif row["fingerprint"] != before_relations[item_id]["fingerprint"]: + changed.append(TopologyChange(change_type="changed", entity_type="relationship", entity_id=item_id, name=name, before=_obj(before_relations[item_id]["payload"]), after=_obj(row["payload"]))) + for item_id, row in before_relations.items(): + if item_id not in after_relations: + removed.append(TopologyChange(change_type="removed", entity_type="relationship", entity_id=item_id, name=f"{row['source_service_id']} → {row['target_service_id'] or 'unknown'}", before=_obj(row["payload"]))) + return TopologyDiff( + before_snapshot_id=before["id"] if before else None, + after_snapshot_id=after_snapshot_id, + added=added, + removed=removed, + changed=changed, + summary={"added": len(added), "removed": len(removed), "changed": len(changed)}, + ) + + def save_source( + self, + *, + source_type: str, + source_id: str, + source_name: str | None, + lifecycle: DiscoveryLifecycle, + metadata: dict[str, object], + managed: bool = True, + observed_at: datetime | None = None, + ) -> ServiceSource: + at = observed_at or _now() + with self._connect() as db: + existing = db.execute( + "SELECT * FROM discovery_sources WHERE source_type=? AND source_id=?", + (source_type, source_id), + ).fetchone() + source_pk = existing["id"] if existing else f"src-{uuid4().hex[:14]}" + first_seen = existing["first_seen_at"] if existing else at.isoformat() + db.execute( + """ + INSERT INTO discovery_sources + (id, source_type, source_id, source_name, first_seen_at, last_seen_at, lifecycle, managed, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source_type, source_id) DO UPDATE SET + source_name=excluded.source_name, last_seen_at=excluded.last_seen_at, + lifecycle=excluded.lifecycle, managed=excluded.managed, metadata=excluded.metadata + """, + (source_pk, source_type, source_id, source_name, first_seen, at.isoformat(), lifecycle.value, int(managed), _json(metadata)), + ) + row = db.execute("SELECT * FROM discovery_sources WHERE id=?", (source_pk,)).fetchone() + return self._source(row) + + def attach_source(self, service_id: str, source_id: str, role: str = "observed") -> None: + with self._connect() as db: + db.execute( + "INSERT OR IGNORE INTO service_sources (service_id, source_id, role) VALUES (?, ?, ?)", + (service_id, source_id, role), + ) + + def get_source(self, source_type: str, source_id: str) -> ServiceSource | None: + with self._connect() as db: + row = db.execute( + "SELECT * FROM discovery_sources WHERE source_type=? AND source_id=?", + (source_type, source_id), + ).fetchone() + return self._source(row) if row else None + + def save_observation( + self, + *, + run_id: str, + source: ServiceSource, + source_name: str, + lifecycle: DiscoveryLifecycle, + change_type: str, + metadata: dict[str, object], + observed_at: datetime | None = None, + ) -> DiscoveryObservation: + at = observed_at or _now() + observation = DiscoveryObservation( + id=f"obs-{uuid4().hex[:14]}", + run_id=run_id, + source_id=source.id, + source_type=source.source_type, + source_name=source_name, + lifecycle=lifecycle, + change_type=change_type, + observed_at=at, + metadata=metadata, + ) + with self._connect() as db: + db.execute( + "INSERT INTO discovery_observations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (observation.id, run_id, source.id, source.source_type, source_name, lifecycle.value, change_type, at.isoformat(), _json(metadata)), + ) + return observation + + def save_suggestion(self, suggestion: TopologySuggestion, fingerprint: str) -> TopologySuggestion: + with self._connect() as db: + existing = db.execute( + "SELECT evidence, confidence FROM topology_suggestions WHERE fingerprint=?", (fingerprint,) + ).fetchone() + merged_evidence = list(dict.fromkeys([ + *(_list(existing["evidence"]) if existing else []), + *suggestion.evidence, + ])) + confidence = _higher_confidence( + suggestion.confidence, + DiscoveryConfidence(existing["confidence"]) if existing else suggestion.confidence, + ) + db.execute( + """ + INSERT INTO topology_suggestions + (id, run_id, suggestion_type, status, fingerprint, source_ref, source_service_id, target_ref, + target_service_id, proposed_name, proposed_kind, proposed_url, proposed_host, proposed_port, + proposed_category, proposed_group, relation_type, confidence, evidence, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET + run_id=excluded.run_id, + source_ref=excluded.source_ref, + source_service_id=excluded.source_service_id, + target_ref=excluded.target_ref, + target_service_id=excluded.target_service_id, + proposed_name=excluded.proposed_name, + proposed_kind=excluded.proposed_kind, + proposed_url=excluded.proposed_url, + proposed_host=excluded.proposed_host, + proposed_port=excluded.proposed_port, + proposed_category=excluded.proposed_category, + proposed_group=excluded.proposed_group, + relation_type=excluded.relation_type, + confidence=excluded.confidence, + evidence=excluded.evidence, + updated_at=excluded.updated_at + """, + ( + suggestion.id, + suggestion.run_id, + suggestion.suggestion_type, + suggestion.status.value, + fingerprint, + suggestion.source_ref, + suggestion.source_service_id, + suggestion.target_ref, + suggestion.target_service_id, + suggestion.proposed_name, + suggestion.proposed_kind.value if suggestion.proposed_kind else None, + suggestion.proposed_url, + suggestion.proposed_host, + suggestion.proposed_port, + suggestion.proposed_category.value, + suggestion.proposed_group, + suggestion.relation_type.value if suggestion.relation_type else None, + confidence.value, + _json(merged_evidence), + suggestion.created_at.isoformat(), + suggestion.updated_at.isoformat(), + ), + ) + row = db.execute("SELECT * FROM topology_suggestions WHERE fingerprint=?", (fingerprint,)).fetchone() + return self._suggestion(row) + + def list_suggestions( + self, + status: SuggestionStatus | None = None, + limit: int = 500, + run_id: str | None = None, + ) -> list[TopologySuggestion]: + query = "SELECT * FROM topology_suggestions" + params: list[Any] = [] + filters: list[str] = [] + if status: + filters.append("status = ?") + params.append(status.value) + if run_id: + filters.append("run_id = ?") + params.append(run_id) + if filters: + query += " WHERE " + " AND ".join(filters) + query += " ORDER BY updated_at DESC LIMIT ?" + params.append(limit) + with self._connect() as db: + rows = db.execute(query, params).fetchall() + return [self._suggestion(row) for row in rows] + + def get_suggestion(self, suggestion_id: str) -> TopologySuggestion | None: + with self._connect() as db: + row = db.execute("SELECT * FROM topology_suggestions WHERE id=?", (suggestion_id,)).fetchone() + return self._suggestion(row) if row else None + + def set_suggestion_status(self, suggestion_id: str, status: SuggestionStatus) -> TopologySuggestion | None: + now = _now() + with self._connect() as db: + db.execute("UPDATE topology_suggestions SET status=?, updated_at=? WHERE id=?", (status.value, now.isoformat(), suggestion_id)) + row = db.execute("SELECT * FROM topology_suggestions WHERE id=?", (suggestion_id,)).fetchone() + return self._suggestion(row) if row else None + + def save_evidence(self, evidence: RelationEvidence) -> RelationEvidence: + with self._connect() as db: + existing = db.execute( + """ + SELECT * FROM relation_evidence + WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ? + AND relation_type=? AND evidence_type=? + """, + (evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type), + ).fetchone() + if existing: + previous = self._evidence(existing) + evidence = evidence.model_copy(update={ + "confidence": _higher_confidence(previous.confidence, evidence.confidence), + "explanation": "; ".join(dict.fromkeys(part for part in (previous.explanation, evidence.explanation) if part)), + "source_ref": evidence.source_ref or previous.source_ref, + "locked": previous.locked or evidence.locked, + "active": evidence.active, + "first_seen_at": min(previous.first_seen_at, evidence.first_seen_at), + }) + db.execute( + """ + INSERT INTO relation_evidence + (id, source_service_id, target_service_id, target_ref, relation_type, evidence_type, + confidence, explanation, source_ref, locked, active, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source_service_id, target_service_id, target_ref, relation_type, evidence_type) DO UPDATE SET + target_service_id=excluded.target_service_id, confidence=excluded.confidence, + explanation=excluded.explanation, source_ref=excluded.source_ref, + locked=MAX(relation_evidence.locked, excluded.locked), active=excluded.active, + last_seen_at=excluded.last_seen_at + """, + (evidence.id, evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type, evidence.confidence.value, evidence.explanation, evidence.source_ref, int(evidence.locked), int(evidence.active), evidence.first_seen_at.isoformat(), evidence.last_seen_at.isoformat()), + ) + row = db.execute( + "SELECT * FROM relation_evidence WHERE id=?", + (evidence.id,), + ).fetchone() + if row is None: + row = db.execute( + "SELECT * FROM relation_evidence WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ? AND relation_type=? AND evidence_type=?", + (evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type), + ).fetchone() + return self._evidence(row) + + def list_logical_relations(self, service_id: str | None = None, active_only: bool = True) -> list[LogicalRelation]: + """Return one canonical graph edge while retaining every evidence row. + + `relation_evidence` is an append/merge audit record. Rendering every + row as an edge made a relation appear duplicated whenever independent + discovery sources agreed. The aggregation is deliberately query-time + so existing v1.3 evidence is preserved without a destructive rebuild. + """ + + grouped: dict[tuple[str, str | None, str | None, RelationType], list[RelationEvidence]] = {} + for evidence in self.list_evidence(service_id=service_id, active_only=active_only): + key = (evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type) + grouped.setdefault(key, []).append(evidence) + result: list[LogicalRelation] = [] + for (source_id, target_id, target_ref, relation_type), evidence_rows in grouped.items(): + confidence = _higher_confidence(*(item.confidence for item in evidence_rows)) + independent_sources = {f"{item.evidence_type}:{item.source_ref or item.explanation}" for item in evidence_rows} + if len(independent_sources) > 1 and confidence != DiscoveryConfidence.CONFIRMED: + confidence = DiscoveryConfidence(_CONFIDENCE_RANK_INV[_CONFIDENCE_RANK[confidence] + 1]) + first_seen = min(item.first_seen_at for item in evidence_rows) + last_seen = max(item.last_seen_at for item in evidence_rows) + digest = sha256("|".join((source_id, target_id or "", target_ref or "", relation_type.value)).encode("utf-8")).hexdigest()[:16] + result.append(LogicalRelation( + id=f"relation-{digest}", + source_service_id=source_id, + target_service_id=target_id, + target_ref=target_ref, + relation_type=relation_type, + confidence=confidence, + active=any(item.active for item in evidence_rows), + locked=any(item.locked for item in evidence_rows), + first_seen_at=first_seen, + last_seen_at=last_seen, + evidence=sorted(evidence_rows, key=lambda item: item.last_seen_at, reverse=True), + )) + return sorted(result, key=lambda item: item.last_seen_at, reverse=True) + + def list_evidence(self, service_id: str | None = None, active_only: bool = True) -> list[RelationEvidence]: + query = "SELECT * FROM relation_evidence WHERE 1=1" + params: list[Any] = [] + if service_id: + query += " AND (source_service_id=? OR target_service_id=?)" + params.extend([service_id, service_id]) + if active_only: + query += " AND active=1" + query += " ORDER BY last_seen_at DESC" + with self._connect() as db: + rows = db.execute(query, params).fetchall() + return [self._evidence(row) for row in rows] + + def get_evidence(self, evidence_id: str) -> RelationEvidence | None: + with self._connect() as db: + row = db.execute("SELECT * FROM relation_evidence WHERE id=?", (evidence_id,)).fetchone() + return self._evidence(row) if row else None + + def get_evidence_for_relation( + self, + source_service_id: str, + target_service_id: str | None, + target_ref: str | None, + relation_type: RelationType, + evidence_type: str = "discovery", + ) -> RelationEvidence | None: + with self._connect() as db: + row = db.execute( + """ + SELECT * FROM relation_evidence + WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ? + AND relation_type=? AND evidence_type=? + """, + (source_service_id, target_service_id, target_ref, relation_type.value, evidence_type), + ).fetchone() + return self._evidence(row) if row else None + + def set_evidence_state( + self, + evidence_id: str, + *, + active: bool | None = None, + locked: bool | None = None, + ) -> RelationEvidence | None: + current = self.get_evidence(evidence_id) + if current is None: + return None + with self._connect() as db: + db.execute( + "UPDATE relation_evidence SET active=?, locked=?, last_seen_at=? WHERE id=?", + (int(current.active if active is None else active), int(current.locked if locked is None else locked), _now().isoformat(), evidence_id), + ) + return self.get_evidence(evidence_id) + + def evidence_for_service(self, service_id: str) -> list[RelationEvidence]: + return self.list_evidence(service_id, active_only=True) + + def sources_for_service(self, service_id: str) -> list[ServiceSource]: + with self._connect() as db: + rows = db.execute( + "SELECT s.* FROM discovery_sources s JOIN service_sources ss ON ss.source_id=s.id WHERE ss.service_id=? ORDER BY s.source_type, s.source_name", + (service_id,), + ).fetchall() + return [self._source(row) for row in rows] + + def observations_for_run(self, run_id: str, limit: int = 1000) -> list[DiscoveryObservation]: + with self._connect() as db: + rows = db.execute( + "SELECT * FROM discovery_observations WHERE run_id=? ORDER BY source_name COLLATE NOCASE LIMIT ?", + (run_id, limit), + ).fetchall() + return [ + DiscoveryObservation( + id=row["id"], + run_id=row["run_id"], + source_id=row["source_id"], + source_type=row["source_type"], + source_name=row["source_name"], + lifecycle=DiscoveryLifecycle(row["lifecycle"]), + change_type=row["change_type"], + observed_at=datetime.fromisoformat(row["observed_at"]), + metadata=_obj(row["metadata"]), + ) + for row in rows + ] + + def candidates_for_run(self, run_id: str, limit: int = 1000) -> list[dict[str, object]]: + candidates: list[dict[str, object]] = [] + for observation in self.observations_for_run(run_id, limit): + candidate = observation.metadata.get("candidate") + if isinstance(candidate, dict): + candidates.append(candidate) + return candidates + + def set_run_status(self, run_id: str, status: DiscoveryRunStatus) -> None: + with self._connect() as db: + db.execute("UPDATE discovery_runs SET status=? WHERE id=?", (status.value, run_id)) + + def record_change( + self, + event_type: str, + detail: str, + *, + service_id: str | None = None, + source_service_id: str | None = None, + target_service_id: str | None = None, + ) -> None: + with self._connect() as db: + db.execute( + "INSERT INTO topology_change_events (id, event_type, service_id, source_service_id, target_service_id, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (f"change-{uuid4().hex[:14]}", event_type, service_id, source_service_id, target_service_id, detail, _now().isoformat()), + ) + + def save_override(self, target_type: str, target_id: str, field_name: str, value: object, locked: bool = True) -> None: + with self._connect() as db: + db.execute( + "INSERT INTO topology_overrides (target_type, target_id, field_name, value, locked, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(target_type, target_id, field_name) DO UPDATE SET value=excluded.value, locked=excluded.locked, updated_at=excluded.updated_at", + (target_type, target_id, field_name, _json(value), int(locked), _now().isoformat()), + ) + + def has_override(self, target_type: str, target_id: str, field_name: str) -> bool: + with self._connect() as db: + row = db.execute( + "SELECT 1 FROM topology_overrides WHERE target_type=? AND target_id=? AND field_name=? AND locked=1", + (target_type, target_id, field_name), + ).fetchone() + return row is not None + + def list_overrides(self, target_type: str | None = None, target_id: str | None = None) -> list[dict[str, object]]: + query = "SELECT target_type, target_id, field_name, value, locked, updated_at FROM topology_overrides WHERE 1=1" + params: list[Any] = [] + if target_type: + query += " AND target_type=?" + params.append(target_type) + if target_id: + query += " AND target_id=?" + params.append(target_id) + with self._connect() as db: + rows = db.execute(query, params).fetchall() + return [ + { + "target_type": row["target_type"], + "target_id": row["target_id"], + "field": row["field_name"], + "value": _obj(row["value"]) if row["value"].startswith("{") else row["value"], + "locked": bool(row["locked"]), + "updated_at": row["updated_at"], + } + for row in rows + ] diff --git a/backend/app/docker_discovery.py b/backend/app/docker_discovery.py new file mode 100644 index 0000000..6b7f9ec --- /dev/null +++ b/backend/app/docker_discovery.py @@ -0,0 +1,682 @@ +from __future__ import annotations + +import os +import re +import socket +import time +from dataclasses import dataclass +from urllib.parse import urlparse + +import docker +import httpx +from docker.errors import DockerException + +from .docker_provider import DockerProviderError, docker_client_for_inventory, docker_provider_mode +from .models import ( + DiscoveryCategory, + DiscoveryConfidence, + DiscoveryLifecycle, + DiscoveryVisibility, + DockerCandidate, + DockerDiscoveryResult, + DockerPort, + DockerSuggestion, + EvidenceScope, + ProbeEndpointCandidate, + ProbeEndpointSource, + ProbeReachability, + Service, + ServiceKind, +) + +ALLOWED_SCHEMES = {"unix", "tcp", "http", "https"} +_RELATION_ENV = re.compile(r"(URL|HOST|HOSTNAME|ADDRESS|ENDPOINT|DSN|DATABASE|REDIS|POSTGRES|MYSQL|MONGO|BROKER|UPSTREAM|S3|STORAGE)", re.IGNORECASE) +_SECRET_ENV = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|BEARER)", re.IGNORECASE) +_SECRET_LABEL = re.compile( + r"(PASSWORD|PASSWD|SECRET|TOKEN|(?:API|ACCESS|PRIVATE|SIGNING|ENCRYPTION)[_-]?KEY|" + r"CREDENTIAL|AUTH(?:ORIZATION)?|BEARER|COOKIE)", + re.IGNORECASE, +) +_HTTP_CONTAINER_PORTS = { + 80, 443, 3000, 3001, 5000, 6060, 8000, 8080, 8081, 8181, 8188, + 8265, 8989, 9000, 9443, 9494, 11434, +} + + +@dataclass(frozen=True) +class AppOpsInfo: + app_id: str + category: str | None + criticality: str | None + health: str | None + display_name: str | None + discovery_confidence: float | None = None + policy_profile: str | None = None + app_type: str | None = None + reasons: tuple[str, ...] = () + + +@dataclass(frozen=True) +class AppOpsInventory: + by_container: dict[str, AppOpsInfo] + available: bool + detail: str = "" + + +@dataclass(frozen=True) +class DockerHealthObservation: + """The small, health-only subset needed by the recurring observer.""" + + container_id: str + name: str + stable_source_ref: str + state: str + health: str | None + healthcheck_present: bool + + +@dataclass(frozen=True) +class ProxyRoute: + domains: tuple[str, ...] + forward_host: str + forward_port: int + forward_scheme: str + + +def _normal_name(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") + + +def stable_source_ref(name: str, labels: dict[str, str]) -> str: + project = labels.get("com.docker.compose.project", "").strip() + service = labels.get("com.docker.compose.service", "").strip() + if project and service: + return f"docker:compose:{_normal_name(project)}:{_normal_name(service)}" + return f"docker:name:{_normal_name(name)}" + + +def _legacy_source_ref(container_id: str, image_name: str) -> str: + return f"docker:{container_id[:12]}@{image_name}" + + +def _safe_labels(labels: dict[str, str]) -> dict[str, str]: + prefixes = ("servicemap.", "com.docker.compose.", "net.unraid.docker.", "org.opencontainers.image.") + return { + key: value + for key, value in labels.items() + if key.startswith(prefixes) and not _SECRET_LABEL.search(key) + } + + +def _classify(name: str, image: str, labels: dict[str, str], appops: AppOpsInfo | None = None) -> DiscoveryCategory: + explicit = labels.get("servicemap.category", "").strip().casefold() + if explicit in {item.value for item in DiscoveryCategory}: + return DiscoveryCategory(explicit) + # AppOps category is application metadata. It is helpful only when its + # discovery confidence is high enough; a low-confidence registry row must + # not override deterministic Docker or operator classification. + if appops and appops.category and (appops.discovery_confidence or 0) >= 0.75: + mapped = { + "database": DiscoveryCategory.DATABASE, + "database_admin": DiscoveryCategory.DATABASE, + "cache": DiscoveryCategory.CACHE, + "network": DiscoveryCategory.INFRASTRUCTURE, + "monitoring": DiscoveryCategory.MONITORING, + "media": DiscoveryCategory.APPLICATION, + "development": DiscoveryCategory.DEVELOPMENT, + "storage": DiscoveryCategory.STORAGE, + "infrastructure": DiscoveryCategory.INFRASTRUCTURE, + }.get(appops.category.casefold()) + if mapped: + return mapped + value = f"{name} {image}".casefold() + if any(token in value for token in ("postgres", "mariadb", "mysql", "mongo", "qdrant")): + return DiscoveryCategory.DATABASE + if "redis" in value: + return DiscoveryCategory.CACHE + if any(token in value for token in ("nginx", "traefik", "caddy", "proxy")): + return DiscoveryCategory.PROXY + if any(token in value for token in ("prometheus", "grafana", "zabbix", "netdata", "cadvisor", "loki", "alloy")): + return DiscoveryCategory.MONITORING + if any(token in value for token in ("worker", "agent", "runner", "backup", "scheduler", "node")): + return DiscoveryCategory.BACKGROUND_WORKER + if any(token in value for token in ("portainer", "dockdeck", "unraid", "watchtower", "appops", "gitea")): + return DiscoveryCategory.INFRASTRUCTURE + if any(token in value for token in ("dev", "forge", "lab", "builder", "runner")): + return DiscoveryCategory.DEVELOPMENT + return DiscoveryCategory.APPLICATION + + +def _visibility(name: str, state: str, category: DiscoveryCategory, labels: dict[str, str]) -> DiscoveryVisibility: + if labels.get("servicemap.ignore", "false").casefold() == "true": + return DiscoveryVisibility.IGNORED + if state != "running": + return DiscoveryVisibility.HIDDEN + if category == DiscoveryCategory.SYSTEM: + return DiscoveryVisibility.IGNORED + if labels.get("servicemap.visible", "true").casefold() == "false": + return DiscoveryVisibility.HIDDEN + return DiscoveryVisibility.VISIBLE + + +def _kind_and_endpoint( + name: str, + ports: list[DockerPort], + labels: dict[str, str], + probe_host: str | None = None, +) -> tuple[ServiceKind, str | None, str | None, int | None, list[str]]: + """Select only an endpoint ServiceMap can reach from its own network. + + Docker's embedded DNS is scoped to a container network. A name such as + ``postgres`` is useful discovery evidence but cannot become an active + probe merely because it exists in another project's network. + """ + probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip() + explicit_kind = labels.get("servicemap.probe", "").casefold() + explicit_port = labels.get("servicemap.port", "") + tcp_ports = [item for item in ports if item.container_port.endswith("/tcp")] + + def container_port(item: DockerPort) -> int | None: + raw = item.container_port.split("/", 1)[0] + return int(raw) if raw.isdigit() else None + + explicit_value = int(explicit_port) if explicit_port.isdigit() else None + selected = next((item for item in tcp_ports if container_port(item) == explicit_value), None) + if selected is None: + published = [item for item in tcp_ports if item.host_port is not None] + # The mapped host port is arbitrary. Select using the container's + # listener port so 8080 -> 8070 remains an HTTP candidate. + selected = next((item for item in published if container_port(item) in _HTTP_CONTAINER_PORTS), None) + selected = selected or (published[0] if published else (tcp_ports[0] if tcp_ports else None)) + container_listener_port = container_port(selected) if selected else None + host_port = selected.host_port if selected else None + port = explicit_value or container_listener_port + evidence = ["container metadata"] + if explicit_kind in {"http", "tcp", "dns", "tls"}: + kind = ServiceKind(explicit_kind) + evidence.append("servicemap.probe label") + elif port in _HTTP_CONTAINER_PORTS: + kind = ServiceKind.HTTP + evidence.append("HTTP-like container port") + elif port: + kind = ServiceKind.TCP + evidence.append("published TCP port") + else: + return ServiceKind.TCP, None, name, None, evidence + if kind == ServiceKind.HTTP: + protocol = "https" if port in {443, 8443, 9443} else "http" + if host_port is not None and probe_host: + # HTTP services are represented by a URL in the canonical model; + # carrying the published port alongside it creates an invalid + # mixed endpoint payload during reconciliation. + return kind, f"{protocol}://{probe_host}:{host_port}", None, None, evidence + ["host-published endpoint"] + return kind, None, name, port, evidence + ["internal container endpoint candidate"] + if kind == ServiceKind.DNS: + return kind, None, name, None, evidence + if host_port is not None and probe_host: + return kind, None, probe_host, host_port, evidence + ["host-published endpoint"] + return kind, None, name, port, evidence + ["internal container endpoint candidate"] + + +def _preflight_candidate(candidate: ProbeEndpointCandidate) -> ProbeEndpointCandidate: + """Verify only host-published endpoints before allowing active probes. + + Discovery never probes Docker-internal DNS names and never scans paths: the + endpoint must already come from a Docker port mapping or explicit label. + """ + if candidate.source not in { + ProbeEndpointSource.PUBLISHED_PORT, + ProbeEndpointSource.EXPLICIT_LABEL, + }: + return candidate + parsed = urlparse(candidate.url) if candidate.url else None + hostname = parsed.hostname if parsed else candidate.host + port = parsed.port if parsed else candidate.port + if not hostname or not port: + return candidate.model_copy(update={"preflight_detail": "No host-published endpoint to preflight"}) + started = time.perf_counter() + try: + with socket.create_connection((hostname, port), timeout=0.75): + pass + elapsed = round((time.perf_counter() - started) * 1000, 1) + if candidate.kind != ServiceKind.HTTP or not candidate.url: + return candidate.model_copy(update={ + "reachability": ProbeReachability.VERIFIED, + "primary": True, + "preflight_detail": "TCP connect succeeded", + "preflight_latency_ms": elapsed, + }) + with httpx.Client(timeout=1.25, follow_redirects=False) as client: + response = client.request("HEAD", candidate.url) + if response.status_code == 405: + response = client.request("GET", candidate.url) + return candidate.model_copy(update={ + "reachability": ProbeReachability.VERIFIED, + "primary": True, + "preflight_detail": f"HTTP {response.status_code} responded", + "preflight_latency_ms": elapsed, + }) + except (OSError, httpx.HTTPError) as exc: + return candidate.model_copy(update={ + "reachability": ProbeReachability.UNVERIFIED, + "primary": False, + "preflight_detail": f"Preflight failed: {type(exc).__name__}", + }) + + +def _probe_candidates( + suggestion: DockerSuggestion | None, + *, + healthcheck_present: bool, +) -> list[ProbeEndpointCandidate]: + candidates: list[ProbeEndpointCandidate] = [] + if suggestion: + source = ProbeEndpointSource.EXPLICIT_LABEL if "servicemap.probe label" in suggestion.evidence else ProbeEndpointSource.PUBLISHED_PORT + reachability = ProbeReachability.UNVERIFIED if "host-published endpoint" in suggestion.evidence else ProbeReachability.INTERNAL_ONLY + candidates.append( + ProbeEndpointCandidate( + source=source if reachability == ProbeReachability.UNVERIFIED else ProbeEndpointSource.CONTAINER_INTERNAL, + kind=suggestion.kind, + url=suggestion.url, + host=suggestion.host, + port=suggestion.port, + reachability=reachability, + confidence=DiscoveryConfidence.MEDIUM if reachability == ProbeReachability.UNVERIFIED else DiscoveryConfidence.LOW, + evidence="; ".join(suggestion.evidence), + ) + ) + if healthcheck_present: + candidates.append( + ProbeEndpointCandidate( + source=ProbeEndpointSource.DOCKER_HEALTHCHECK, + kind=suggestion.kind if suggestion else ServiceKind.TCP, + reachability=ProbeReachability.VERIFIED, + confidence=DiscoveryConfidence.HIGH, + evidence="Docker healthcheck present (observed without a network probe)", + ) + ) + return candidates + + +def safe_environment_evidence(env: list[str]) -> list[str]: + evidence: list[str] = [] + for entry in env: + name, separator, value = entry.partition("=") + if not separator or not value or _SECRET_ENV.search(name) or not _RELATION_ENV.search(name): + continue + try: + parsed = urlparse(value if "://" in value else f"//{value}") + hostname = parsed.hostname + except ValueError: + # Docker environments frequently contain comma-separated host + # lists or malformed IPv6-like values. Discovery evidence is + # best-effort; one invalid value must not abort the complete scan. + continue + if not hostname or hostname.casefold() in {"localhost", "127.0.0.1", "0.0.0.0"}: + continue + try: + port = parsed.port + except ValueError: + # A parseable hostname with a non-numeric suffix is still useful + # relation evidence; omit only the unsafe port component. + port = None + suffix = f":{port}" if port else "" + evidence.append(f"{name} hostname={hostname}{suffix}") + return sorted(set(evidence)) + + +def _appops_inventory(appops_url: str | None) -> AppOpsInventory: + if not appops_url: + return AppOpsInventory({}, False, "AppOps URL is not configured") + base = appops_url.rstrip("/") + try: + mappings_response = httpx.get(f"{base}/api/inventory/mappings", timeout=5) + apps_response = httpx.get(f"{base}/api/inventory/apps", timeout=5) + mappings_response.raise_for_status() + apps_response.raise_for_status() + mappings = mappings_response.json() + apps = apps_response.json() + except (httpx.HTTPError, ValueError) as exc: + return AppOpsInventory({}, False, f"AppOps inventory unavailable: {type(exc).__name__}") + app_by_id = {str(item.get("id")): item for item in apps if isinstance(item, dict)} + result: dict[str, AppOpsInfo] = {} + for mapping in mappings if isinstance(mappings, list) else []: + if not isinstance(mapping, dict): + continue + container = str(mapping.get("containerName") or "") + app_id = str(mapping.get("appId") or "") + app = app_by_id.get(app_id, {}) + try: + confidence = float(app.get("discovery_confidence")) if app.get("discovery_confidence") is not None else None + except (TypeError, ValueError): + confidence = None + if container: + result[container.casefold()] = AppOpsInfo( + app_id=app_id, + category=str(app.get("category")) if app.get("category") else None, + criticality=str(app.get("criticality")) if app.get("criticality") else None, + health=str(app.get("health_status")) if app.get("health_status") else None, + display_name=str(app.get("display_name")) if app.get("display_name") else None, + discovery_confidence=max(0.0, min(confidence, 1.0)) if confidence is not None else None, + policy_profile=str(app.get("policy_profile")) if app.get("policy_profile") else None, + app_type=str(app.get("app_type")) if app.get("app_type") else None, + reasons=tuple(str(item) for item in (app.get("discovery_reasons") or [])[:3]), + ) + return AppOpsInventory(result, True, f"{len(result)} container mappings loaded") + + +def appops_inventory(appops_url: str | None) -> AppOpsInventory: + """Read AppOps health inventory without triggering Docker discovery.""" + + return _appops_inventory(appops_url) + + +def observe_container_health(docker_host: str) -> list[DockerHealthObservation]: + """Read bounded Docker runtime/health facts without discovery side effects. + + This deliberately avoids endpoint preflight, relationship inference, + suggestion persistence, reconciliation and snapshot capture. It is safe + to run on the observation cadence because it cannot rewrite topology or + operator-owned configuration. + """ + + parsed = urlparse(docker_host) + if parsed.scheme not in ALLOWED_SCHEMES: + raise ValueError("docker_host must use unix://, tcp://, http:// or https://") + client = None + try: + client = ( + docker.DockerClient(base_url=docker_host, timeout=5) + if docker_provider_mode() == "socket" + else docker_client_for_inventory(docker_host) + ) + client.ping() + observations: list[DockerHealthObservation] = [] + for container in client.containers.list(all=True): + attrs = container.attrs or {} + state = attrs.get("State", {}) or {} + config = attrs.get("Config", {}) or {} + raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()} + name = str(getattr(container, "name", "unknown")).lstrip("/") + observations.append( + DockerHealthObservation( + container_id=container.id[:12], + name=name, + stable_source_ref=stable_source_ref(name, raw_labels), + state=str(state.get("Status", getattr(container, "status", "unknown"))), + health=( + str(state.get("Health", {}).get("Status")) + if isinstance(state.get("Health"), dict) and state.get("Health", {}).get("Status") is not None + else None + ), + healthcheck_present=bool(config.get("Healthcheck")), + ) + ) + return observations + except (DockerException, DockerProviderError) as exc: + raise RuntimeError(f"Docker observation unavailable: {type(exc).__name__}") from exc + finally: + close = getattr(client, "close", None) + if callable(close): + close() + + +def _appops_proxy_routes(appops_url: str | None) -> list[ProxyRoute]: + if not appops_url: + return [] + try: + payload = httpx.get(f"{appops_url.rstrip('/')}/api/apps/npm/proxy-hosts", timeout=5).json() + except (httpx.HTTPError, ValueError): + return [] + items = payload.get("items", []) if isinstance(payload, dict) else payload + routes: list[ProxyRoute] = [] + for item in items if isinstance(items, list) else []: + if not isinstance(item, dict) or item.get("enabled") is False: + continue + domains = tuple(sorted({str(domain).strip() for domain in item.get("domainNames", []) if str(domain).strip()})) + host = str(item.get("forwardHost") or "").strip() + try: + port = int(item.get("forwardPort")) + except (TypeError, ValueError): + continue + scheme = str(item.get("forwardScheme") or "http").casefold() + if not domains or not host or not 1 <= port <= 65535 or scheme not in {"http", "https"}: + continue + routes.append(ProxyRoute(domains=domains, forward_host=host, forward_port=port, forward_scheme=scheme)) + return routes + + +def _suggestions( + name: str, + networks: list[str], + ports: list[DockerPort], + labels: dict[str, str], + *, + image: str, + state: str, + appops: AppOpsInfo | None = None, + stable_ref: str, + probe_host: str | None = None, +) -> list[DockerSuggestion]: + if labels.get("servicemap.enabled", "true").casefold() == "false": + return [] + kind, url, host, port, evidence = _kind_and_endpoint(name, ports, labels, probe_host) + depends = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()] + compose_depends = labels.get("com.docker.compose.depends_on", "") + if compose_depends: + depends.extend(item.split(":", 1)[0].strip() for item in compose_depends.split(",") if item.strip()) + evidence.append("Compose depends_on metadata") + if depends: + evidence.append("explicit dependency metadata") + if appops: + confidence = f" ({appops.discovery_confidence:.2f})" if appops.discovery_confidence is not None else "" + evidence.append(f"AppOps application mapping{confidence}; application scope only") + evidence.extend(appops.reasons) + category = _classify(name, image, labels, appops) + return [ + DockerSuggestion( + name=labels.get("servicemap.name", appops.display_name if appops and appops.display_name else name), + kind=kind, + host=host, + port=port, + url=url, + group=labels.get("servicemap.group") or (f"Compose · {labels.get('com.docker.compose.project')}" if labels.get("com.docker.compose.project") else category.value.replace("_", " ").title()), + evidence=sorted(set(evidence)), + confidence="high" if labels.get("servicemap.probe") or labels.get("servicemap.name") or compose_depends or (appops and (appops.discovery_confidence or 0) >= 0.75) else "medium", + depends_on_names=sorted(set(depends)), + stable_source_ref=stable_ref, + category=category, + visibility=_visibility(name, state, category, labels), + ) + ] + + +def discover_containers( + docker_host: str, + existing_services: list[Service] | None = None, + *, + include_stopped: bool = True, + appops_url: str | None = None, + probe_host: str | None = None, +) -> DockerDiscoveryResult: + parsed = urlparse(docker_host) + if parsed.scheme not in ALLOWED_SCHEMES: + raise ValueError("docker_host must use unix://, tcp://, http:// or https://") + + # Keep the direct client import at this boundary so existing deployments + # and deterministic SDK-contract tests retain the socket behaviour. Broker + # mode is explicit and never falls back here. + client = docker.DockerClient(base_url=docker_host, timeout=5) if docker_provider_mode() == "socket" else docker_client_for_inventory(docker_host) + try: + client.ping() + containers = client.containers.list(all=include_stopped) + existing_services = existing_services or [] + appops_inventory = _appops_inventory(appops_url) + appops = appops_inventory.by_container + configured_probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip() or None + proxy_routes = _appops_proxy_routes(appops_url) + candidates: list[DockerCandidate] = [] + discovered_refs: set[str] = set() + for container in containers: + attrs = container.attrs or {} + state = attrs.get("State", {}) or {} + config = attrs.get("Config", {}) or {} + network_settings = attrs.get("NetworkSettings", {}) or {} + raw_ports = network_settings.get("Ports", {}) or {} + ports: list[DockerPort] = [] + for container_port, bindings in raw_ports.items(): + if not bindings: + ports.append(DockerPort(container_port=container_port)) + continue + for binding in bindings: + raw_host_port = binding.get("HostPort") + host_port = str(raw_host_port) if raw_host_port is not None else "" + ports.append( + DockerPort( + container_port=container_port, + host_ip=binding.get("HostIp") or None, + host_port=int(host_port) if host_port.isdigit() else None, + ) + ) + + image_tags = getattr(container.image, "tags", []) or [] + image_name = image_tags[0] if image_tags else str(config.get("Image", "unknown")) + name = str(getattr(container, "name", "unknown")).lstrip("/") + health = state.get("Health", {}).get("Status") if isinstance(state.get("Health"), dict) else None + state_name = str(state.get("Status", getattr(container, "status", "unknown"))) + networks = sorted((network_settings.get("Networks", {}) or {}).keys()) + raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()} + labels = _safe_labels(raw_labels) + stable_ref = stable_source_ref(name, raw_labels) + legacy_ref = _legacy_source_ref(container.id, image_name) + discovered_refs.add(stable_ref) + matching_source = next( + ( + service + for service in existing_services + if service.source_ref in {stable_ref, legacy_ref} + or (service.source_ref and service.source_ref.removeprefix("docker:").startswith(container.id[:12])) + ), + None, + ) + matching_name = next((service for service in existing_services if service.name.casefold() == name.casefold()), None) + appops_info = appops.get(name.casefold()) + category = _classify(name, image_name, raw_labels, appops_info) + visibility = _visibility(name, state_name, category, raw_labels) + if matching_source is not None: + import_state = "already imported" if matching_source.source_ref in {stable_ref, legacy_ref} else "changed" + elif matching_name is not None: + import_state = "already imported" + else: + import_state = "new" + suggestions = _suggestions( + name, + networks, + ports, + raw_labels, + image=image_name, + state=state_name, + appops=appops_info, + stable_ref=stable_ref, + probe_host=configured_probe_host, + ) + for suggestion in suggestions: + suggestion.source_type = "docker" + suggestion.source_ref = legacy_ref + env_evidence = safe_environment_evidence([str(item) for item in (config.get("Env") or [])]) + probe_candidates = _probe_candidates( + suggestions[0] if suggestions else None, + healthcheck_present=bool(config.get("Healthcheck")), + ) + if state_name == "running": + probe_candidates = [_preflight_candidate(item) for item in probe_candidates] + candidates.append( + DockerCandidate( + container_id=container.id[:12], + name=name, + image=image_name, + state=state_name, + health=health, + networks=networks, + ports=ports, + labels=labels, + import_state=import_state, + suggestions=suggestions, + stable_source_ref=stable_ref, + compose_project=raw_labels.get("com.docker.compose.project"), + compose_service=raw_labels.get("com.docker.compose.service"), + category=category, + visibility=visibility, + lifecycle=DiscoveryLifecycle.ACTIVE if state_name == "running" else DiscoveryLifecycle.STALE, + healthcheck_present=bool(config.get("Healthcheck")), + environment_evidence=env_evidence, + proxy_route_evidence=[], + appops_app_id=appops_info.app_id if appops_info else None, + appops_category=appops_info.category if appops_info else None, + appops_criticality=appops_info.criticality if appops_info else None, + appops_health=appops_info.health if appops_info else None, + appops_scope=EvidenceScope.APPLICATION if appops_info else None, + appops_discovery_confidence=appops_info.discovery_confidence if appops_info else None, + appops_policy_profile=appops_info.policy_profile if appops_info else None, + appops_app_type=appops_info.app_type if appops_info else None, + probe_candidates=probe_candidates, + ) + ) + + for candidate in candidates: + candidate.dependency_suggestions = sorted( + other.name + for other in candidates + if other.name != candidate.name and set(candidate.networks).intersection(other.networks) + ) + + # Reverse-proxy metadata is useful evidence, but it is never turned + # into a health dependency automatically. Persist only domains and the + # sanitized upstream tuple; AppOps metadata such as certificate or + # Let's Encrypt fields is intentionally discarded. + by_name = {candidate.name.casefold(): candidate for candidate in candidates} + by_published_port = { + port.host_port: candidate + for candidate in candidates + for port in candidate.ports + if port.host_port is not None + } + for route in proxy_routes: + target = by_name.get(route.forward_host.casefold()) or by_published_port.get(route.forward_port) + if target: + target.proxy_route_evidence.append( + f"Reverse proxy route: {', '.join(route.domains)} -> {route.forward_scheme}://{route.forward_host}:{route.forward_port}" + ) + + for service in existing_services: + if service.source_type != "docker" or not service.source_ref: + continue + if service.source_ref not in discovered_refs and service.lifecycle not in {DiscoveryLifecycle.RETIRED, DiscoveryLifecycle.IGNORED}: + candidates.append( + DockerCandidate( + container_id=service.source_ref.removeprefix("docker:")[:12], + name=service.name, + image=service.container_image or "not found", + state="missing", + health=None, + networks=[], + ports=[], + import_state="missing", + stable_source_ref=service.source_ref, + category=service.category, + visibility=service.visibility, + lifecycle=DiscoveryLifecycle.MISSING, + ) + ) + candidates.sort(key=lambda item: item.name.casefold()) + return DockerDiscoveryResult( + docker_host=docker_host, + candidates=candidates, + proxy_routes=len(proxy_routes), + appops_available=appops_inventory.available, + appops_detail=appops_inventory.detail, + ) + except (DockerException, DockerProviderError) as exc: + raise ValueError(f"Docker discovery failed: {exc}") from exc + finally: + client.close() diff --git a/backend/app/docker_provider.py b/backend/app/docker_provider.py new file mode 100644 index 0000000..0087250 --- /dev/null +++ b/backend/app/docker_provider.py @@ -0,0 +1,334 @@ +"""Docker inventory providers with an explicit security boundary. + +The legacy Unix-socket provider is retained for compatibility. The broker +provider is opt-in and deliberately never falls back to the socket: a failed +broker configuration must be visible to the operator rather than silently +re-expanding ServiceMap's Docker privilege boundary. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import docker +import httpx + + +class DockerProviderError(ValueError): + pass + + +_BROKER_CONNECT_TIMEOUT_SECONDS = 2.0 +_BROKER_OPERATION_TIMEOUT_SECONDS = 5.0 +_BROKER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +_BROKER_MAX_CONTAINER_ITEMS = 10_000 +_BROKER_PATH = "/v1/containers" +_BROKER_BEARER_TOKEN = re.compile(r"[^\x21-\x7e]") + + +def _invalid_inventory() -> DockerProviderError: + return DockerProviderError("Docker broker returned an invalid inventory response") + + +def _contains_ascii_control(value: str) -> bool: + return any(ord(char) < 32 or ord(char) == 127 for char in value) + + +@dataclass +class BrokerImage: + tags: list[str] + + +@dataclass +class BrokerContainer: + id: str + name: str + status: str + attrs: dict[str, Any] + image: BrokerImage + + +class BrokerContainers: + def __init__(self, provider: BrokerDockerClient) -> None: # type: ignore[name-defined] + self.provider = provider + + def list(self, *, all: bool = True) -> list[BrokerContainer]: + del all # Broker registration scope is deliberate; it returns all allowed targets. + payload = self.provider._get(_BROKER_PATH) + items = payload.get("items") + if not isinstance(items, list) or len(items) > _BROKER_MAX_CONTAINER_ITEMS: + raise _invalid_inventory() + if any(not isinstance(item, dict) for item in items): + raise _invalid_inventory() + return [self.provider._container(item) for item in items] + + +class BrokerDockerClient: + """Minimal Docker SDK-shaped client over the registered read broker.""" + + def __init__(self, base_url: str, token: str) -> None: + self.base_url = self._validated_base_url(base_url) + if ( + not isinstance(token, str) + or not token + or len(token) > 8192 + or _BROKER_BEARER_TOKEN.search(token) is not None + ): + raise DockerProviderError("Docker broker token is invalid") + # Keep the credential out of client-wide headers: health checks are + # public and only the one fixed inventory request needs authorization. + self._authorization = f"Bearer {token}" + self.http = httpx.Client( + timeout=httpx.Timeout( + _BROKER_OPERATION_TIMEOUT_SECONDS, + connect=_BROKER_CONNECT_TIMEOUT_SECONDS, + pool=_BROKER_CONNECT_TIMEOUT_SECONDS, + ), + follow_redirects=False, + trust_env=False, + ) + self.containers = BrokerContainers(self) + + @staticmethod + def _validated_base_url(base_url: str) -> str: + if ( + not isinstance(base_url, str) + or not base_url + or any(ord(char) < 33 for char in base_url) + ): + raise DockerProviderError("Docker broker URL is invalid") + try: + parsed = urlsplit(base_url) + # Accessing .port also rejects malformed or out-of-range ports. + _ = parsed.port + except ValueError: + raise DockerProviderError("Docker broker URL is invalid") from None + if ( + parsed.scheme.casefold() not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or "\\" in parsed.path + or any(part == ".." for part in parsed.path.split("/")) + ): + raise DockerProviderError("Docker broker URL is invalid") + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) + + @classmethod + def from_environment(cls) -> BrokerDockerClient: + # Never accept a request-provided Docker host as a broker destination: + # that could otherwise send the dedicated bearer token to an attacker- + # controlled endpoint. Broker identity is deployment configuration only. + url = (os.getenv("SERVICEMAP_DOCKER_BROKER_URL") or "").strip() + token_file = (os.getenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE") or "").strip() + if not url or not token_file: + raise DockerProviderError( + "Broker mode requires SERVICEMAP_DOCKER_BROKER_URL and SERVICEMAP_DOCKER_BROKER_TOKEN_FILE" + ) + path = Path(token_file) + if not path.is_file(): + raise DockerProviderError("Docker broker token file is unavailable") + try: + token = path.read_text(encoding="utf-8").strip() + except OSError: + raise DockerProviderError("Docker broker token file cannot be read") from None + if not token or len(token) > 8192 or _BROKER_BEARER_TOKEN.search(token) is not None: + raise DockerProviderError("Docker broker token file is invalid") + return cls(url, token) + + def _get(self, path: str) -> dict[str, Any]: + if path != _BROKER_PATH: + raise DockerProviderError("Docker broker operation is unsupported") + try: + with self.http.stream( + "GET", + f"{self.base_url}{path}", + headers={"Authorization": self._authorization}, + ) as response: + response.raise_for_status() + if ( + response.headers.get("content-type", "").split(";", 1)[0].strip().casefold() + != "application/json" + ): + raise _invalid_inventory() + length = response.headers.get("content-length") + if length is not None: + try: + if int(length) < 0 or int(length) > _BROKER_MAX_RESPONSE_BYTES: + raise _invalid_inventory() + except ValueError: + raise _invalid_inventory() from None + raw = bytearray() + for chunk in response.iter_bytes(): + if len(raw) + len(chunk) > _BROKER_MAX_RESPONSE_BYTES: + raise _invalid_inventory() + raw.extend(chunk) + except httpx.HTTPError: + raise DockerProviderError("Docker broker request failed") from None + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise DockerProviderError("Docker broker returned invalid JSON") from None + if not isinstance(payload, dict): + raise _invalid_inventory() + return payload + + def ping(self) -> bool: + # /healthz is intentionally unprivileged; an authenticated inventory + # request immediately follows and remains the actual authorization test. + try: + with self.http.stream("GET", f"{self.base_url}/healthz") as response: + response.raise_for_status() + except httpx.HTTPError: + raise DockerProviderError("Docker broker health check failed") from None + return True + + @staticmethod + def _container(item: dict[str, Any]) -> BrokerContainer: + def text_field(field: str, *, optional_default: str | None = None) -> str: + if field not in item: + if optional_default is not None: + return optional_default + raise _invalid_inventory() + value = item[field] + if optional_default is not None and value is None: + return optional_default + if ( + not isinstance(value, str) + or not value + or len(value) > 4096 + or _contains_ascii_control(value) + ): + raise _invalid_inventory() + return value + + ref = text_field("containerRef") + name = text_field("name").lstrip("/") + if not name: + raise _invalid_inventory() + state = text_field("state") + health = text_field("health", optional_default="none") + image = text_field("image") + + labels_value = item.get("labels") + if not isinstance(labels_value, dict) or any( + not isinstance(key, str) + or not isinstance(value, str) + or not key + or len(key) > 4096 + or len(value) > 4096 + or _contains_ascii_control(key + value) + for key, value in labels_value.items() + ): + raise _invalid_inventory() + networks_value = item.get("networks") + if not isinstance(networks_value, list) or any( + not isinstance(name, str) + or not name + or len(name) > 4096 + or _contains_ascii_control(name) + for name in networks_value + ): + raise _invalid_inventory() + # The deployed broker reports Docker health but does not yet emit an + # explicit healthcheckPresent flag. A concrete health status itself + # proves a healthcheck exists; preserve that discovery signal. + healthcheck_present = item.get("healthcheckPresent", health.casefold() not in {"none", "unknown"}) + if not isinstance(healthcheck_present, bool): + raise _invalid_inventory() + + ports: dict[str, list[dict[str, str]]] = {} + raw_ports = item.get("ports") + if not isinstance(raw_ports, dict): + raise _invalid_inventory() + for key, bindings in raw_ports.items(): + if ( + not isinstance(key, str) + or not key + or len(key) > 128 + or _contains_ascii_control(key) + ): + raise _invalid_inventory() + if bindings is None: + ports[key] = [] + continue + if not isinstance(bindings, list): + raise _invalid_inventory() + ports[key] = [] + for binding in bindings: + if not isinstance(binding, dict): + raise _invalid_inventory() + host_ip = binding.get("hostIp", "") + host_port = binding.get("hostPort", "") + if host_ip is None: + host_ip = "" + if host_port is None: + host_port = "" + if ( + not isinstance(host_ip, str) + or not isinstance(host_port, (str, int)) + or isinstance(host_port, bool) + ): + raise _invalid_inventory() + host_port = str(host_port) + if ( + len(host_ip) > 128 + or _contains_ascii_control(host_ip + host_port) + or ( + host_port + and ( + len(host_port) > 5 + or not all("0" <= char <= "9" for char in host_port) + or not 1 <= int(host_port) <= 65535 + ) + ) + ): + raise _invalid_inventory() + ports[key].append({"HostIp": host_ip, "HostPort": host_port}) + + return BrokerContainer( + id=ref, + name=name, + status=state, + attrs={ + "State": { + "Status": state, + "Health": {"Status": health} if health != "none" else {}, + }, + "Config": { + "Image": image, + "Labels": labels_value, + "Env": [], + **({"Healthcheck": {"BrokerReported": True}} if healthcheck_present else {}), + }, + "NetworkSettings": { + "Networks": {name: {} for name in networks_value}, + "Ports": ports, + }, + }, + image=BrokerImage(tags=[image]), + ) + + def close(self) -> None: + self.http.close() + + +def docker_client_for_inventory(docker_host: str): + provider = os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() + if provider == "socket": + return docker.DockerClient(base_url=docker_host, timeout=5) + if provider == "broker": + return BrokerDockerClient.from_environment() + raise DockerProviderError("SERVICEMAP_DOCKER_PROVIDER must be 'socket' or 'broker'") + + +def docker_provider_mode() -> str: + return os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() or "socket" diff --git a/backend/app/events.py b/backend/app/events.py new file mode 100644 index 0000000..b3f2010 --- /dev/null +++ b/backend/app/events.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import asyncio +import json +from queue import Empty, Full, Queue +from threading import Lock +from typing import Any + + +class EventBus: + """Small bounded fan-out bus used by the SSE endpoint. + + Persistence is handled by SQLite; this bus only wakes currently connected + clients. A queue per client keeps one slow browser from blocking probes or + other subscribers. + """ + + def __init__(self, max_queue_size: int = 100) -> None: + self.max_queue_size = max_queue_size + self._subscribers: set[Queue[dict[str, Any]]] = set() + self._lock = Lock() + + def subscribe(self) -> Queue[dict[str, Any]]: + queue: Queue[dict[str, Any]] = Queue(maxsize=self.max_queue_size) + with self._lock: + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: Queue[dict[str, Any]]) -> None: + with self._lock: + self._subscribers.discard(queue) + + def publish(self, payload: dict[str, Any]) -> None: + with self._lock: + subscribers = tuple(self._subscribers) + for queue in subscribers: + try: + queue.put_nowait(payload) + except Full: + # Drop the oldest event for a lagging client, then retain the + # newest state notification. The durable event journal remains + # the source of truth if a client reconnects. + try: + queue.get_nowait() + except Empty: + pass + try: + queue.put_nowait(payload) + except Full: + pass +event_bus = EventBus() + + +def sse_payload(payload: dict[str, Any]) -> str: + event_type = str(payload.get("event_type", "update")) + return f"event: {event_type}\ndata: {json.dumps(payload, default=str)}\n\n" + + +async def next_event(queue: Queue[dict[str, Any]], timeout: float = 15.0) -> dict[str, Any] | None: + try: + return await asyncio.to_thread(queue.get, True, timeout) + except Empty: + return None diff --git a/backend/app/evidence.py b/backend/app/evidence.py new file mode 100644 index 0000000..9ee3e0d --- /dev/null +++ b/backend/app/evidence.py @@ -0,0 +1,134 @@ +"""Health-evidence selection policy. + +The policy is deliberately small and pure. Storage retains every provider +claim; this module decides which *current component-scoped* claim can answer +the direct-health question. It keeps aggregate AppOps health useful in the +inspector without ever treating it as a component probe. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from .models import ( + EvidenceFreshness, + EvidenceProvider, + EvidenceScope, + HealthEvidence, + HealthResolution, + HealthResolutionReason, + HealthStatus, +) + +_PROVIDER_PRIORITY = { + EvidenceProvider.MANUAL: 100, + EvidenceProvider.PROBE: 90, + EvidenceProvider.DOCKER: 80, + EvidenceProvider.APPOPS: 40, + EvidenceProvider.RUNTIME: 20, +} + + +def appops_health_status(value: str | None) -> HealthStatus | None: + """Keep AppOps aggregate health scoped to the application it describes.""" + + return { + "unhealthy": HealthStatus.DOWN, + "down": HealthStatus.DOWN, + "warning": HealthStatus.DEGRADED, + "degraded": HealthStatus.DEGRADED, + "healthy": HealthStatus.HEALTHY, + }.get((value or "").casefold()) + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def freshness_for(evidence: HealthEvidence, *, now: datetime | None = None) -> EvidenceFreshness: + """Return freshness without mutating the persisted observation. + + The bounded stale window is solely explanatory: stale and expired claims + are both excluded from health selection. Showing the distinction helps an + operator understand whether a provider is merely late or genuinely old. + """ + + now = now or utc_now() + if evidence.valid_until >= now: + return EvidenceFreshness.FRESH + ttl = max(evidence.valid_until - evidence.observed_at, timedelta(seconds=1)) + return ( + EvidenceFreshness.STALE if evidence.valid_until + ttl >= now else EvidenceFreshness.EXPIRED + ) + + +def with_freshness(evidence: HealthEvidence, *, now: datetime | None = None) -> HealthEvidence: + return evidence.model_copy(update={"freshness": freshness_for(evidence, now=now)}) + + +def resolve_health( + evidence: list[HealthEvidence], *, now: datetime | None = None +) -> HealthResolution: + """Resolve current direct health from independently persisted evidence.""" + + now = now or utc_now() + annotated = [with_freshness(item, now=now) for item in evidence] + eligible = [ + item + for item in annotated + if item.active + and item.scope == EvidenceScope.COMPONENT + and item.freshness == EvidenceFreshness.FRESH + ] + if not eligible: + component_claims = [ + item for item in annotated if item.scope == EvidenceScope.COMPONENT and item.active + ] + app_claims = [ + item for item in annotated if item.scope == EvidenceScope.APPLICATION and item.active + ] + if component_claims: + reason_code = ( + HealthResolutionReason.STALE_EVIDENCE + if any(item.freshness == EvidenceFreshness.STALE for item in component_claims) + else HealthResolutionReason.EXPIRED_EVIDENCE + ) + elif app_claims: + reason_code = HealthResolutionReason.APPLICATION_SCOPED_ONLY + else: + reason_code = HealthResolutionReason.NO_CURRENT_COMPONENT_EVIDENCE + explanation = ( + "Only application-scoped evidence is current; it is shown for provenance and is not " + "used as component health." + if app_claims + else "No current component-scoped health evidence is available." + ) + return HealthResolution(reason_code=reason_code, explanation=explanation, evidence=annotated) + + ordered = sorted( + eligible, + key=lambda item: ( + _PROVIDER_PRIORITY[item.provider], + item.confidence, + item.observed_at, + item.received_at, + item.id, + ), + reverse=True, + ) + selected = ordered[0] + conflict = len({item.status for item in eligible}) > 1 + scope_note = " Conflicting current component signals are retained below." if conflict else "" + return HealthResolution( + status=selected.status, + selected_evidence_id=selected.id, + selected_provider=selected.provider, + confidence=selected.confidence, + reason_code=HealthResolutionReason.SELECTED_COMPONENT_EVIDENCE, + explanation=( + f"{selected.provider.value} selected from current component-scoped evidence " + f"(confidence {selected.confidence:.2f}).{scope_note}" + ), + conflict=conflict, + evidence=annotated, + ) diff --git a/backend/app/health.py b/backend/app/health.py new file mode 100644 index 0000000..2cc3090 --- /dev/null +++ b/backend/app/health.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from .models import HealthStatus, Service +from .store import Store + +SEVERITY = { + HealthStatus.MAINTENANCE: 0, + HealthStatus.UNMONITORED: 0, + HealthStatus.UNKNOWN: 0, + HealthStatus.HEALTHY: 1, + HealthStatus.RECOVERING: 2, + HealthStatus.DEGRADED: 3, + HealthStatus.DOWN: 4, +} + + +def _direct_status(service: Service, store: Store) -> HealthStatus: + return store.service_health(service.id) + + +def _dependency_status(service: Service, store: Store, visiting: set[str]) -> HealthStatus: + if not service.depends_on: + return HealthStatus.HEALTHY + + states: list[HealthStatus] = [] + for dependency_id in service.depends_on: + dependency = store.get_service(dependency_id) + if dependency is None: + states.append(HealthStatus.UNKNOWN) + continue + states.append(_effective_status(dependency, store, visiting)) + return max(states, key=lambda status: SEVERITY[status]) + + +def _effective_status(service: Service, store: Store, visiting: set[str]) -> HealthStatus: + if service.id in visiting: + return HealthStatus.UNKNOWN + next_visiting = set(visiting) + next_visiting.add(service.id) + own = _direct_status(service, store) + if own == HealthStatus.MAINTENANCE: + return own + dependency = _dependency_status(service, store, next_visiting) + if own == HealthStatus.DOWN: + return own + if dependency == HealthStatus.DOWN: + return HealthStatus.DEGRADED + if dependency in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} and own in { + HealthStatus.HEALTHY, + HealthStatus.RECOVERING, + }: + return HealthStatus.DEGRADED + return own + + +def dependency_status(service: Service, store: Store) -> HealthStatus: + return _dependency_status(service, store, {service.id}) + + +def effective_status(service: Service, store: Store) -> HealthStatus: + return _effective_status(service, store, set()) + + +def status_maps( + services: list[Service], + *, + direct_statuses: dict[str, HealthStatus] | None = None, +) -> tuple[dict[str, HealthStatus], dict[str, HealthStatus]]: + """Calculate the complete topology in memory for one bounded API read.""" + + by_id = {service.id: service for service in services} + def legacy_direct_state(service: Service) -> HealthStatus: + runtime_state = (service.runtime_state or "").casefold() + runtime_health = (service.runtime_health or "").casefold() + if not service.enabled: + return HealthStatus.UNKNOWN + if service.maintenance_enabled: + return HealthStatus.MAINTENANCE + if runtime_health == "unhealthy" or runtime_state in {"exited", "dead", "stopped"}: + return HealthStatus.DOWN + if runtime_state == "restarting": + return HealthStatus.DEGRADED + if not service.monitoring_enabled: + if runtime_health == "healthy": + return HealthStatus.HEALTHY + return HealthStatus.UNMONITORED if service.monitoring_state.value in {"unmonitored", "observed"} else HealthStatus.UNKNOWN + return service.state + + direct = { + service.id: (direct_statuses.get(service.id, HealthStatus.UNKNOWN) if direct_statuses else legacy_direct_state(service)) + for service in services + } + effective: dict[str, HealthStatus] = {} + dependency: dict[str, HealthStatus] = {} + + def visit(service_id: str, visiting: set[str]) -> HealthStatus: + if service_id in effective: + return effective[service_id] + if service_id in visiting: + return HealthStatus.UNKNOWN + service = by_id.get(service_id) + if service is None: + return HealthStatus.UNKNOWN + next_visiting = {*visiting, service_id} + own = direct[service_id] + if not service.depends_on: + dependency[service_id] = HealthStatus.HEALTHY + effective[service_id] = own + return own + dependencies = [visit(item, next_visiting) for item in service.depends_on] + dependency_state = max(dependencies, key=lambda status: SEVERITY[status]) + dependency[service_id] = dependency_state + if own == HealthStatus.MAINTENANCE or own == HealthStatus.DOWN: + result = own + elif dependency_state == HealthStatus.DOWN or ( + dependency_state in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} + and own in {HealthStatus.HEALTHY, HealthStatus.RECOVERING} + ): + result = HealthStatus.DEGRADED + else: + result = own + effective[service_id] = result + return result + + for service in services: + visit(service.id, set()) + return effective, dependency + + +def validate_dependencies( + depends_on: list[str], + store: Store, + *, + service_id: str | None = None, +) -> None: + if len(depends_on) != len(set(depends_on)): + raise ValueError("Dependencies must be unique") + if service_id and service_id in depends_on: + raise ValueError("A service cannot depend on itself") + + services = store.list_services() + known_ids = {service.id for service in services} + missing = [dependency_id for dependency_id in depends_on if dependency_id not in known_ids] + if missing: + raise ValueError(f"Unknown dependency ids: {', '.join(missing)}") + + if service_id is None: + return + + graph = {service.id: list(service.depends_on) for service in services} + graph[service_id] = list(depends_on) + visiting: set[str] = set() + visited: set[str] = set() + + def visit(node: str) -> None: + if node in visiting: + raise ValueError("Dependency graph contains a cycle") + if node in visited: + return + visiting.add(node) + for dependency_id in graph.get(node, []): + visit(dependency_id) + visiting.remove(node) + visited.add(node) + + for node in graph: + visit(node) diff --git a/backend/app/incidents.py b/backend/app/incidents.py new file mode 100644 index 0000000..107d2bc --- /dev/null +++ b/backend/app/incidents.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from .health import dependency_status, effective_status +from .models import ( + Criticality, + HealthStatus, + IncidentCandidate, + IncidentOverview, + IncidentRecord, + IncidentStatus, + Service, +) +from .store import Store + + +def _reverse_dependencies(services: list[Service]) -> dict[str, list[str]]: + reverse: dict[str, list[str]] = {service.id: [] for service in services} + for service in services: + for dependency_id in service.depends_on: + reverse.setdefault(dependency_id, []).append(service.id) + return reverse + + +def affected_service_ids(root_id: str, services: list[Service]) -> list[str]: + reverse = _reverse_dependencies(services) + seen: set[str] = set() + queue = list(reverse.get(root_id, [])) + while queue: + current = queue.pop(0) + if current in seen or current == root_id: + continue + seen.add(current) + queue.extend(reverse.get(current, [])) + return sorted(seen) + + +def incident_overview(store: Store) -> IncidentOverview: + services = store.list_services() + by_id = {service.id: service for service in services} + effective = {service.id: effective_status(service, store) for service in services} + dependency = {service.id: dependency_status(service, store) for service in services} + + down_ids = sorted(service_id for service_id, state in effective.items() if state == HealthStatus.DOWN) + degraded_ids = sorted( + service_id for service_id, state in effective.items() if state in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} + ) + + candidates: list[IncidentCandidate] = [] + for service in services: + state = effective[service.id] + if state != HealthStatus.DOWN or service.maintenance_enabled: + continue + down_dependencies = [ + dependency_id + for dependency_id in service.depends_on + if effective.get(dependency_id) == HealthStatus.DOWN + ] + if down_dependencies: + continue + affected_ids = affected_service_ids(service.id, services) + affected_names = [by_id[item].name for item in affected_ids if item in by_id] + reason = "Service is down and no configured dependency is also down" + if dependency[service.id] == HealthStatus.UNKNOWN: + reason += "; at least one dependency has unknown health" + candidates.append( + IncidentCandidate( + service_id=service.id, + service_name=service.name, + status=state, + reason=reason, + affected_service_ids=affected_ids, + affected_service_names=affected_names, + ) + ) + + candidates.sort(key=lambda item: (-len(item.affected_service_ids), item.service_name.lower())) + return IncidentOverview( + generated_at=datetime.now(UTC), + active=bool(down_ids or degraded_ids), + candidates=candidates, + degraded_service_ids=degraded_ids, + down_service_ids=down_ids, + incidents=[], + ) + + +def _severity(service: Service) -> Criticality: + return service.criticality + + +def sync_incidents(store: Store, *, resolution_reason: str = "Root cause recovered; incident resolved") -> list[IncidentRecord]: + """Reconcile the current graph with the durable incident journal. + + A root service has at most one active incident. Repeated probe cycles only + update its affected set; a later independent outage gets a new id. + """ + + overview = incident_overview(store) + services = {service.id: service for service in store.list_services()} + now = datetime.now(UTC) + active: dict[str, IncidentRecord] = {} + for incident in sorted( + store.list_incidents(active_only=True), + key=lambda item: (item.detected_at, item.id), + ): + if incident.root_service_id in active: + store.upsert_incident(incident.model_copy(update={ + "status": IncidentStatus.RESOLVED, + "resolved_at": now, + "latest_update_at": now, + })) + store.append_incident_event( + incident.id, + "incident.duplicate_resolved", + "Duplicate active incident closed; the earliest incident remains active", + incident.root_service_id, + ) + continue + active[incident.root_service_id] = incident + for candidate in overview.candidates: + service = services.get(candidate.service_id) + if service is None: + continue + incident = active.get(candidate.service_id) + if incident is None: + incident = IncidentRecord( + id=str(uuid4()), + status=IncidentStatus.OPEN, + root_service_id=candidate.service_id, + root_service_name=candidate.service_name, + started_at=now, + detected_at=now, + title=f"{candidate.service_name} is down", + root_cause_reason=candidate.reason, + affected_service_ids=candidate.affected_service_ids, + affected_service_names=candidate.affected_service_names, + severity=_severity(service), + latest_update_at=now, + ) + store.upsert_incident(incident) + store.append_incident_event( + incident.id, + "incident.opened", + f"Incident opened: {candidate.service_name} is the likely root cause", + candidate.service_id, + ) + active[candidate.service_id] = incident + elif incident.affected_service_ids != candidate.affected_service_ids: + updated = incident.model_copy( + update={ + "affected_service_ids": candidate.affected_service_ids, + "affected_service_names": candidate.affected_service_names, + "latest_update_at": now, + "root_cause_reason": candidate.reason, + } + ) + store.upsert_incident(updated) + store.append_incident_event(incident.id, "incident.updated", "Downstream impact recalculated", candidate.service_id) + + for root_id, incident in list(active.items()): + if any(candidate.service_id == root_id for candidate in overview.candidates): + continue + root = services.get(root_id) + if ( + (root is None or store.service_health(root_id) not in { + HealthStatus.DOWN, + HealthStatus.DEGRADED, + HealthStatus.RECOVERING, + }) + and incident.status != IncidentStatus.RESOLVED + ): + resolved = incident.model_copy( + update={"status": IncidentStatus.RESOLVED, "resolved_at": now, "latest_update_at": now} + ) + store.upsert_incident(resolved) + store.append_incident_event(incident.id, "incident.resolved", resolution_reason, root_id) + return store.list_incidents(active_only=False) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..da2808d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,743 @@ +from __future__ import annotations + +import asyncio +import os +import sqlite3 +from contextlib import asynccontextmanager, suppress +from datetime import timedelta +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Query, Response, status +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from .auth import AuthConfig, AuthorizationError, authorize_request +from .config_io import export_document, import_document, parse_document, serialize_document +from .demo import DEMO_ROOT_CAUSE_NAME, DEMO_SCENARIO_NAME, build_demo_records +from .discovery import DiscoveryManager +from .discovery_store import DiscoveryStore +from .docker_discovery import discover_containers +from .docker_provider import docker_provider_mode +from .events import event_bus, next_event, sse_payload +from .health import dependency_status, effective_status, status_maps, validate_dependencies +from .incidents import incident_overview, sync_incidents +from .models import ( + AvailabilitySummary, + BulkProbeResult, + ConfigImportRequest, + ConfigImportResult, + DemoMutationResult, + DemoState, + DiscoveryObservation, + DiscoveryReconcileRequest, + DiscoveryResult, + DiscoveryRunRequest, + DiscoveryStatus, + DockerDiscoveryRequest, + DockerDiscoveryResult, + EdgeActionRequest, + EdgeMutationRequest, + EventRecord, + HealthResolution, + IncidentOverview, + LogicalRelation, + MaintenanceRequest, + OverviewSummary, + ProbeRepairReport, + ProbeResult, + ProviderStatus, + ReconcileResult, + RelationEvidence, + SemanticTopology, + Service, + ServiceCreate, + ServiceSnapshot, + ServiceUpdate, + SuggestionStatus, + TopologyDiff, + TopologySnapshot, + TopologySuggestion, +) +from .observation import ObservationManager +from .probes import probe_many, probe_service +from .scheduler import scheduled_probe_loop +from .semantic import build_semantic_topology +from .store import Store + +store = Store() +discovery_store = DiscoveryStore(store) +discovery_manager = DiscoveryManager(store, discovery_store) + + +def _env_int(name: str, default: int, minimum: int = 0) -> int: + try: + return max(minimum, int(os.getenv(name, str(default)))) + except ValueError: + return default + + +def _env_float(name: str, default: float, minimum: float = 0.1) -> float: + try: + return max(minimum, float(os.getenv(name, str(default)))) + except ValueError: + return default + + +PROBE_INTERVAL = _env_int("SERVICEMAP_PROBE_INTERVAL", 0) +PROBE_TIMEOUT = _env_float("SERVICEMAP_PROBE_TIMEOUT", 5.0) +PROBE_RETRIES = _env_int("SERVICEMAP_PROBE_RETRIES", 1) +PROBE_CONCURRENCY = _env_int("SERVICEMAP_PROBE_CONCURRENCY", 10, minimum=1) +RETENTION_DAYS = _env_int("SERVICEMAP_RETENTION_DAYS", 30, minimum=1) +EVENT_RETENTION_DAYS = _env_int("SERVICEMAP_EVENT_RETENTION_DAYS", 90, minimum=1) +DISCOVERY_INTERVAL = _env_int("SERVICEMAP_DISCOVERY_INTERVAL_SECONDS", 0, minimum=0) +OBSERVATION_INTERVAL = _env_int("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", 60, minimum=10) +observation_manager = ObservationManager(store, interval_seconds=OBSERVATION_INTERVAL) + + +def _require_configured_discovery_targets( + docker_host: str | None = None, appops_url: str | None = None +) -> None: + """Request bodies cannot redirect an unauthenticated LAN deployment's inventory reads.""" + + configured_docker = os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock") + configured_appops = os.getenv("SERVICEMAP_APPOPS_URL", "") + if docker_host and docker_host != configured_docker: + raise HTTPException(status_code=403, detail="Docker discovery target is not configured") + if appops_url and appops_url.rstrip("/") != configured_appops.rstrip("/"): + raise HTTPException(status_code=403, detail="AppOps discovery target is not configured") + + +@asynccontextmanager +async def lifespan(_: FastAPI): + task: asyncio.Task[None] | None = None + discovery_task: asyncio.Task[None] | None = None + observation_task: asyncio.Task[None] | None = None + if PROBE_INTERVAL > 0: + task = asyncio.create_task( + scheduled_probe_loop( + store, + interval_seconds=PROBE_INTERVAL, + timeout_seconds=PROBE_TIMEOUT, + retries=PROBE_RETRIES, + concurrency=PROBE_CONCURRENCY, + retention_days=RETENTION_DAYS, + ) + ) + if DISCOVERY_INTERVAL > 0: + async def discovery_loop() -> None: + while True: + await asyncio.sleep(DISCOVERY_INTERVAL) + if store.demo_active(): + continue + try: + request = DiscoveryRunRequest() + result = await asyncio.to_thread(discovery_manager.run, request) + await asyncio.to_thread( + discovery_manager.reconcile, + DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=False), + ) + except (OSError, RuntimeError, ValueError): + # Discovery is review-first; a failed background run is + # persisted and must not take down the monitoring loop. + continue + discovery_task = asyncio.create_task(discovery_loop()) + async def observation_loop() -> None: + while True: + if not store.demo_active(): + try: + await asyncio.to_thread(observation_manager.refresh) + except (OSError, RuntimeError, ValueError): + # Provider availability is persisted by the observer; an + # unavailable provider must not stop scheduled probing. + pass + await asyncio.sleep(OBSERVATION_INTERVAL) + observation_task = asyncio.create_task(observation_loop()) + try: + yield + finally: + if task: + task.cancel() + with suppress(asyncio.CancelledError): + await task + if discovery_task: + discovery_task.cancel() + with suppress(asyncio.CancelledError): + await discovery_task + if observation_task: + observation_task.cancel() + with suppress(asyncio.CancelledError): + await observation_task + + +app = FastAPI(title="ServiceMap API", version="1.6.2", lifespan=lifespan) + + +@app.middleware("http") +async def authorization_boundary(request, call_next): + try: + authorize_request(request) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + return await call_next(request) + + +def _snapshot(service: Service) -> ServiceSnapshot: + services = store.list_services() + dependents = [item.name for item in services if service.id in item.depends_on] + return ServiceSnapshot( + service=service, + latest_probe=store.latest_probe(service.id), + availability_24h=store.availability_summary(service.id, 24), + dependency_status=dependency_status(service, store), + effective_status=effective_status(service, store), + direct_status=store.service_health(service.id), + flapping=service.flapping, + flap_count=service.flap_count, + next_probe_at=store.next_probe_at(service, PROBE_INTERVAL), + dependents=dependents, + sources=discovery_store.sources_for_service(service.id), + evidence=discovery_store.evidence_for_service(service.id), + health_evidence=store.health_evidence_for_service(service.id), + health_resolution=store.health_resolution(service.id), + ) + + +def _name_conflict(name: str, *, exclude_id: str | None = None) -> bool: + existing = store.get_service_by_name(name) + return existing is not None and existing.id != exclude_id + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/api/v1/meta") +def meta() -> dict[str, str | int | float | bool]: + try: + auth_mode = AuthConfig.from_environment().mode + except AuthorizationError: + auth_mode = "misconfigured" + return { + "name": "ServiceMap", + "version": "1.6.2", + "stage": "v1", + "scheduled_probing": PROBE_INTERVAL > 0, + "probe_interval_seconds": PROBE_INTERVAL, + "probe_timeout_seconds": PROBE_TIMEOUT, + "probe_retries": PROBE_RETRIES, + "probe_concurrency": PROBE_CONCURRENCY, + "retention_days": RETENTION_DAYS, + "event_retention_days": EVENT_RETENTION_DAYS, + "sse": True, + "discovery": True, + "discovery_interval_seconds": DISCOVERY_INTERVAL, + "observation_interval_seconds": OBSERVATION_INTERVAL, + "docker_configured": bool(os.getenv("SERVICEMAP_DOCKER_HOST")), + "docker_provider": docker_provider_mode(), + "appops_configured": bool(os.getenv("SERVICEMAP_APPOPS_URL")), + "auth_mode": auth_mode, + } + + +def _service_snapshots( + *, include_hidden: bool = False, include_context: bool = True +) -> list[ServiceSnapshot]: + all_services = store.list_services() + services = ( + all_services + if include_hidden + else [ + service + for service in all_services + if service.visibility.value == "visible" and service.lifecycle.value not in {"retired", "ignored"} + ] + ) + latest = store.latest_probes([service.id for service in services]) + availability = store.availability_summaries([service.id for service in services], 24) + evidence_by_service = store.health_evidence_for_services([service.id for service in all_services]) + # Calculate propagation over the complete canonical graph first. Hidden + # infrastructure nodes may still sit between a visible service and its + # dependency; filtering before this calculation would turn that impact + # into a misleading unknown state. + direct_all, resolutions = store.service_healths( + [service.id for service in all_services], evidence_by_service=evidence_by_service + ) + effective_all, dependency_all = status_maps(all_services, direct_statuses=direct_all) + dependent_names = { + service.id: [item.name for item in services if service.id in item.depends_on] + for service in services + } + return [ + ServiceSnapshot( + service=service, + latest_probe=latest.get(service.id), + availability_24h=availability.get(service.id), + dependency_status=dependency_all.get(service.id, service.state), + effective_status=effective_all.get(service.id, service.state), + direct_status=direct_all.get(service.id, service.state), + flapping=service.flapping, + flap_count=service.flap_count, + next_probe_at=( + latest[service.id].checked_at + timedelta(seconds=service.interval_seconds or PROBE_INTERVAL) + if service.id in latest and (service.interval_seconds or PROBE_INTERVAL) > 0 + else None + ), + dependents=dependent_names.get(service.id, []), + sources=discovery_store.sources_for_service(service.id) if include_context else [], + evidence=discovery_store.evidence_for_service(service.id) if include_context else [], + health_evidence=evidence_by_service.get(service.id, []), + health_resolution=resolutions.get(service.id, HealthResolution()), + ) + for service in services + ] + + +@app.get("/api/v1/services", response_model=list[ServiceSnapshot]) +def list_services(include_hidden: bool = Query(default=False)) -> list[ServiceSnapshot]: + return _service_snapshots(include_hidden=include_hidden) + + +@app.post("/api/v1/services", response_model=Service, status_code=status.HTTP_201_CREATED) +def create_service(payload: ServiceCreate) -> Service: + if _name_conflict(payload.name): + raise HTTPException(status_code=409, detail="Service name already exists") + try: + validate_dependencies(payload.depends_on, store) + return store.create_service(payload) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except sqlite3.IntegrityError as exc: + raise HTTPException(status_code=409, detail="Service name already exists") from exc + + +@app.get("/api/v1/services/{service_id}", response_model=ServiceSnapshot) +def get_service(service_id: str) -> ServiceSnapshot: + service = store.get_service(service_id) + if service is None: + raise HTTPException(status_code=404, detail="Service not found") + return _snapshot(service) + + +@app.put("/api/v1/services/{service_id}", response_model=Service) +def update_service(service_id: str, payload: ServiceUpdate) -> Service: + before = store.get_service(service_id) + if before is None: + raise HTTPException(status_code=404, detail="Service not found") + if _name_conflict(payload.name, exclude_id=service_id): + raise HTTPException(status_code=409, detail="Service name already exists") + try: + validate_dependencies(payload.depends_on, store, service_id=service_id) + updated = store.update_service(service_id, payload) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except sqlite3.IntegrityError as exc: + raise HTTPException(status_code=409, detail="Service name already exists") from exc + if updated is None: + raise HTTPException(status_code=404, detail="Service not found") + if before.source_type: + discovery_manager.record_service_override(before, updated) + return updated + + +@app.delete("/api/v1/services/{service_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_service(service_id: str) -> None: + if not store.delete_service(service_id): + raise HTTPException(status_code=404, detail="Service not found") + + +@app.post("/api/v1/services/{service_id}/probe", response_model=ProbeResult) +async def run_probe( + service_id: str, + timeout_seconds: float | None = Query(default=None, ge=0.1, le=30), + retries: int | None = Query(default=None, ge=0, le=5), +) -> ProbeResult: + if store.demo_active(): + raise HTTPException(status_code=409, detail="Explore demo uses synthetic probe data; live probing is paused") + service = store.get_service(service_id) + if service is None: + raise HTTPException(status_code=404, detail="Service not found") + result = await probe_service( + service, + timeout_seconds=timeout_seconds, + retries=retries, + trigger="manual", + default_timeout_seconds=PROBE_TIMEOUT, + default_retries=PROBE_RETRIES, + ) + return store.save_probe(result) + + +@app.post("/api/v1/probe-all", response_model=BulkProbeResult) +async def run_all_probes( + timeout_seconds: float | None = Query(default=None, ge=0.1, le=30), + retries: int | None = Query(default=None, ge=0, le=5), + concurrency: int = Query(default=PROBE_CONCURRENCY, ge=1, le=50), +) -> BulkProbeResult: + if store.demo_active(): + return BulkProbeResult(requested=0, completed=0, results=[]) + services = [service for service in store.list_services() if service.enabled and service.monitoring_enabled] + results = await probe_many( + services, + timeout_seconds=timeout_seconds, + retries=retries, + concurrency=concurrency, + trigger="manual", + default_timeout_seconds=PROBE_TIMEOUT, + default_retries=PROBE_RETRIES, + ) + for result in results: + store.save_probe(result) + return BulkProbeResult(requested=len(services), completed=len(results), results=results) + + +@app.get("/api/v1/services/{service_id}/history", response_model=list[ProbeResult]) +def probe_history(service_id: str, limit: int = Query(default=50, ge=1, le=500)) -> list[ProbeResult]: + if store.get_service(service_id) is None: + raise HTTPException(status_code=404, detail="Service not found") + return store.probe_history(service_id, limit) + + +@app.get("/api/v1/services/{service_id}/availability", response_model=AvailabilitySummary) +def availability(service_id: str, hours: int = Query(default=24, ge=1, le=24 * 90)) -> AvailabilitySummary: + if store.get_service(service_id) is None: + raise HTTPException(status_code=404, detail="Service not found") + return store.availability_summary(service_id, hours) + + +@app.get("/api/v1/services/{service_id}/evidence", response_model=HealthResolution) +def service_evidence(service_id: str) -> HealthResolution: + if store.get_service(service_id) is None: + raise HTTPException(status_code=404, detail="Service not found") + return store.health_resolution(service_id) + + +@app.get("/api/v1/providers", response_model=list[ProviderStatus]) +def providers() -> list[ProviderStatus]: + return store.provider_statuses() + + +@app.post("/api/v1/services/{service_id}/maintenance", response_model=Service) +def maintenance(service_id: str, payload: MaintenanceRequest) -> Service: + updated = store.set_maintenance(service_id, payload.enabled, payload.reason) + if updated is None: + raise HTTPException(status_code=404, detail="Service not found") + return updated + + +@app.get("/api/v1/incidents", response_model=IncidentOverview) +def incidents() -> IncidentOverview: + sync_incidents(store) + overview = incident_overview(store) + overview.incidents = store.list_incidents(active_only=False) + return overview + + +@app.get("/api/v1/incidents/{incident_id}") +def get_incident(incident_id: str) -> dict[str, object]: + incident = store.get_incident(incident_id) + if incident is None: + raise HTTPException(status_code=404, detail="Incident not found") + return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]} + + +@app.post("/api/v1/incidents/{incident_id}/acknowledge") +def acknowledge_incident(incident_id: str) -> dict[str, object]: + try: + incident = store.acknowledge_incident(incident_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if incident is None: + raise HTTPException(status_code=404, detail="Incident not found") + return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]} + + +@app.get("/api/v1/events", response_model=list[EventRecord]) +def events(limit: int = Query(default=50, ge=1, le=500), service_id: str | None = None) -> list[EventRecord]: + return store.list_events(limit=limit, service_id=service_id) + + +@app.get("/api/v1/events/stream") +async def event_stream() -> StreamingResponse: + queue = event_bus.subscribe() + + async def stream(): + try: + yield ": connected\n\n" + while True: + payload = await next_event(queue) + if payload is None: + yield ": keepalive\n\n" + else: + yield sse_payload(payload) + finally: + event_bus.unsubscribe(queue) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.get("/api/v1/overview", response_model=OverviewSummary) +def overview() -> OverviewSummary: + sync_incidents(store) + snapshots = _service_snapshots(include_hidden=False) + overview_state = incident_overview(store) + counts: dict[str, int] = {} + coverage: dict[str, int] = {} + for snapshot in snapshots: + counts[snapshot.effective_status.value] = counts.get(snapshot.effective_status.value, 0) + 1 + state = snapshot.service.monitoring_state.value + coverage[state] = coverage.get(state, 0) + 1 + return OverviewSummary( + generated_at=overview_state.generated_at, + total_services=len(snapshots), + health_counts=counts, + active_incidents=len(store.list_incidents(active_only=True)), + likely_root_causes=[candidate.service_name for candidate in overview_state.candidates], + flapping_services=[snapshot.service.name for snapshot in snapshots if snapshot.flapping], + recent_events=store.list_events(limit=10), + monitoring_coverage=coverage, + ) + + +@app.get("/api/v1/config") +def export_config(format: str = Query(default="yaml", pattern="^(yaml|json)$")) -> Response: + document = export_document(store) + content = serialize_document(document, format) + media_type = "application/json" if format == "json" else "application/yaml" + extension = "json" if format == "json" else "yaml" + return Response(content=content, media_type=media_type, headers={"Content-Disposition": f'attachment; filename="servicemap.{extension}"'}) + + +@app.post("/api/v1/config/import", response_model=ConfigImportResult) +def import_config(payload: ConfigImportRequest) -> ConfigImportResult: + try: + document = parse_document(payload.content, payload.format) + return import_document(store, document, payload.mode) + except (ValueError, sqlite3.IntegrityError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +def _demo_state() -> DemoState: + if not store.demo_active(): + return DemoState(active=False) + sync_incidents(store) + overview_state = incident_overview(store) + candidate = overview_state.candidates[0] if overview_state.candidates else None + return DemoState( + active=True, + synthetic=True, + scenario_name=DEMO_SCENARIO_NAME, + service_count=len(store.list_services()), + root_cause_service_id=candidate.service_id if candidate else None, + root_cause_service_name=candidate.service_name if candidate else DEMO_ROOT_CAUSE_NAME, + affected_service_ids=candidate.affected_service_ids if candidate else [], + affected_service_names=candidate.affected_service_names if candidate else [], + affected_count=len(candidate.affected_service_ids) if candidate else 0, + ) + + +def _demo_result(result: ConfigImportResult | None = None, *, restored: bool = False) -> DemoMutationResult: + state = _demo_state() + return DemoMutationResult( + **state.model_dump(), + created=result.created if result else 0, + updated=result.updated if result else 0, + total=result.total if result else state.service_count, + restored=restored, + ) + + +@app.get("/api/v1/demo", response_model=DemoState) +def demo_state() -> DemoState: + return _demo_state() + + +@app.post("/api/v1/demo", response_model=DemoMutationResult) +def load_demo() -> DemoMutationResult: + services, probes = build_demo_records() + result = store.activate_demo(services, probes) + sync_incidents(store) + return _demo_result(result) + + +@app.post("/api/v1/demo/reset", response_model=DemoMutationResult) +def reset_demo() -> DemoMutationResult: + services, probes = build_demo_records() + result = store.activate_demo(services, probes) + sync_incidents(store) + return _demo_result(result) + + +@app.post("/api/v1/demo/exit", response_model=DemoMutationResult) +async def exit_demo() -> DemoMutationResult: + restored = store.exit_demo() + # Refresh live provider evidence immediately after restoring the saved + # production topology. Test stores intentionally do not inherit the + # process-global observer, but production always does. + if restored and observation_manager.store is store: + await asyncio.to_thread(observation_manager.refresh) + return _demo_result(restored=restored) + + +@app.post("/api/v1/discovery/run", response_model=DiscoveryResult) +def run_discovery(payload: DiscoveryRunRequest | None = None) -> DiscoveryResult: + if store.demo_active(): + raise HTTPException(status_code=409, detail="Exit Explore demo before discovering production infrastructure") + request = payload or DiscoveryRunRequest() + _require_configured_discovery_targets(request.docker_host, request.appops_url) + try: + return discovery_manager.run(request) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@app.get("/api/v1/discovery/status", response_model=DiscoveryStatus) +def discovery_status() -> DiscoveryStatus: + return DiscoveryStatus(**discovery_manager.status()) + + +@app.get("/api/v1/discovery/items", response_model=list[DiscoveryObservation]) +def discovery_items(run_id: str | None = None) -> list[DiscoveryObservation]: + selected = discovery_store.get_run(run_id) if run_id else discovery_store.last_completed() + return discovery_store.observations_for_run(selected.id) if selected else [] + + +@app.get("/api/v1/discovery/suggestions", response_model=list[TopologySuggestion]) +def discovery_suggestions( + status_filter: str | None = Query(default=None, alias="status"), +) -> list[TopologySuggestion]: + try: + suggestion_status = SuggestionStatus(status_filter) if status_filter else None + except ValueError as exc: + raise HTTPException(status_code=422, detail="Unknown suggestion status") from exc + return discovery_store.list_suggestions(status=suggestion_status) + + +@app.post("/api/v1/discovery/suggestions/{suggestion_id}/accept", response_model=TopologySuggestion) +def accept_discovery_suggestion(suggestion_id: str) -> TopologySuggestion: + try: + return discovery_manager.suggestion_action(suggestion_id, "confirm") + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post("/api/v1/discovery/suggestions/{suggestion_id}/reject", response_model=TopologySuggestion) +def reject_discovery_suggestion(suggestion_id: str) -> TopologySuggestion: + try: + return discovery_manager.suggestion_action(suggestion_id, "reject") + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post("/api/v1/discovery/reconcile", response_model=ReconcileResult) +def reconcile_discovery(payload: DiscoveryReconcileRequest | None = None) -> ReconcileResult: + if store.demo_active(): + raise HTTPException(status_code=409, detail="Exit Explore demo before reconciling production infrastructure") + try: + return discovery_manager.reconcile(payload or DiscoveryReconcileRequest()) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.post("/api/v1/discovery/probes/repair", response_model=ProbeRepairReport) +def repair_discovered_probes( + dry_run: bool = Query(default=True), + run_id: str | None = Query(default=None), +) -> ProbeRepairReport: + if store.demo_active(): + raise HTTPException(status_code=409, detail="Exit Explore demo before repairing production probe endpoints") + try: + return discovery_manager.repair_probe_reachability(run_id=run_id, dry_run=dry_run) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.get("/api/v1/topology/edges", response_model=list[RelationEvidence]) +def topology_edges() -> list[RelationEvidence]: + return discovery_store.list_evidence() + + +@app.get("/api/v1/topology/relations", response_model=list[LogicalRelation]) +def topology_relations() -> list[LogicalRelation]: + """Canonical relationships: one graph edge, all supporting evidence retained.""" + return discovery_store.list_logical_relations() + + +@app.get("/api/v1/topology/snapshots", response_model=list[TopologySnapshot]) +def topology_snapshots(limit: int = Query(default=30, ge=1, le=200)) -> list[TopologySnapshot]: + return discovery_store.list_snapshots(limit=limit) + + +@app.get("/api/v1/topology/diff", response_model=TopologyDiff) +def topology_diff( + after_snapshot_id: str = Query(...), + before_snapshot_id: str | None = Query(default=None), +) -> TopologyDiff: + try: + return discovery_store.topology_diff( + after_snapshot_id=after_snapshot_id, + before_snapshot_id=before_snapshot_id, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.get("/api/v1/topology/semantic", response_model=SemanticTopology) +def semantic_topology(include_hidden: bool = Query(default=False)) -> SemanticTopology: + snapshots = _service_snapshots(include_hidden=include_hidden, include_context=False) + incident_state = incident_overview(store) + root_ids = {candidate.service_id for candidate in incident_state.candidates} + affected_ids = { + service_id + for candidate in incident_state.candidates + for service_id in candidate.affected_service_ids + } + return build_semantic_topology( + snapshots, + discovery_store.list_logical_relations(), + root_service_ids=root_ids, + affected_service_ids=affected_ids, + ) + + +@app.post("/api/v1/topology/edges", response_model=RelationEvidence, status_code=status.HTTP_201_CREATED) +def create_topology_edge(payload: EdgeMutationRequest) -> RelationEvidence: + try: + return discovery_manager.apply_edge(payload) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.post("/api/v1/topology/edges/{evidence_id}/action", response_model=RelationEvidence) +def topology_edge_action(evidence_id: str, payload: EdgeActionRequest) -> RelationEvidence: + try: + return discovery_manager.edge_action(evidence_id, payload.action) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.post("/api/v1/docker/discover", response_model=DockerDiscoveryResult) +def docker_discover(payload: DockerDiscoveryRequest) -> DockerDiscoveryResult: + _require_configured_discovery_targets(payload.docker_host) + try: + return discover_containers( + payload.docker_host, + store.list_services(), + appops_url=os.getenv("SERVICEMAP_APPOPS_URL"), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.post("/api/v1/maintenance/prune") +def prune_history(retention_days: int = Query(default=RETENTION_DAYS, ge=1, le=3650)) -> dict[str, int]: + return {"deleted_probe_results": store.prune_probes(retention_days, EVENT_RETENTION_DAYS)} + + +static_dir = Path(os.getenv("SERVICEMAP_STATIC_DIR", "/app/static")) +if static_dir.is_dir(): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..452375b --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,862 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from urllib.parse import urlparse + +from pydantic import AliasChoices, BaseModel, Field, model_validator + + +class ServiceKind(StrEnum): + HTTP = "http" + TCP = "tcp" + DNS = "dns" + TLS = "tls" + + +class HealthStatus(StrEnum): + UNKNOWN = "unknown" + UNMONITORED = "unmonitored" + HEALTHY = "healthy" + DEGRADED = "degraded" + DOWN = "down" + RECOVERING = "recovering" + MAINTENANCE = "maintenance" + + +class EvidenceProvider(StrEnum): + """Systems that can make a health claim about an infrastructure subject.""" + + PROBE = "active_probe" + DOCKER = "docker" + APPOPS = "appops" + RUNTIME = "runtime" + MANUAL = "manual" + + +class EvidenceScope(StrEnum): + """Whether a claim describes one component or an aggregate application.""" + + COMPONENT = "component" + APPLICATION = "application" + + +class EvidenceFreshness(StrEnum): + FRESH = "fresh" + STALE = "stale" + EXPIRED = "expired" + + +class HealthResolutionReason(StrEnum): + """Machine-readable explanation for a health answer or lack of one.""" + + SELECTED_COMPONENT_EVIDENCE = "selected_component_evidence" + APPLICATION_SCOPED_ONLY = "application_scoped_only" + NO_CURRENT_COMPONENT_EVIDENCE = "no_current_component_evidence" + STALE_EVIDENCE = "stale_evidence" + EXPIRED_EVIDENCE = "expired_evidence" + SELECTED_APPLICATION_EVIDENCE = "selected_application_evidence" + COMPONENT_FAILURE = "component_failure" + COMPONENT_IMPACT = "component_dependency_impact" + COMPONENT_COVERAGE = "component_coverage" + NO_APPLICATION_EVIDENCE = "no_application_evidence" + + +class ProviderHealth(StrEnum): + HEALTHY = "healthy" + DEGRADED = "degraded" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +class MonitoringState(StrEnum): + """How a service is currently observed, independently of its health.""" + + ACTIVE = "active_probe" + DOCKER_HEALTH = "docker_health" + APPOPS = "appops_observation" + OBSERVED = "observed" + UNMONITORED = "unmonitored" + DISABLED = "disabled" + + +class ProbeEndpointSource(StrEnum): + MANUAL = "manual" + EXPLICIT_LABEL = "explicit_label" + KNOWN_URL = "known_url" + APPOPS = "appops" + REVERSE_PROXY = "reverse_proxy" + PUBLISHED_PORT = "published_port" + DOCKER_HEALTHCHECK = "docker_healthcheck" + CONTAINER_INTERNAL = "container_internal" + DISCOVERED_ENVIRONMENT = "discovered_environment" + + +class ProbeReachability(StrEnum): + VERIFIED = "verified" + REACHABLE = "reachable" + UNVERIFIED = "unverified" + INTERNAL_ONLY = "internal_only" + NOT_CONFIGURED = "not_configured" + + +class Criticality(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class IncidentStatus(StrEnum): + OPEN = "open" + ACKNOWLEDGED = "acknowledged" + RESOLVED = "resolved" + + +class DiscoveryCategory(StrEnum): + APPLICATION = "application" + DATABASE = "database" + CACHE = "cache" + PROXY = "proxy" + MONITORING = "monitoring" + DEVELOPMENT = "development" + STORAGE = "storage" + INFRASTRUCTURE = "infrastructure" + BACKGROUND_WORKER = "background_worker" + SYSTEM = "system" + UNKNOWN = "unknown" + + +class ComponentRole(StrEnum): + """The operational role a component plays inside an application.""" + + FRONTEND = "frontend" + API = "api" + DATABASE = "database" + CACHE = "cache" + PROXY = "proxy" + WORKER = "worker" + STORAGE = "storage" + MONITORING = "monitoring" + AGENT = "agent" + SCHEDULER = "scheduler" + AUXILIARY = "auxiliary" + UNKNOWN = "unknown" + + +class DiscoveryVisibility(StrEnum): + VISIBLE = "visible" + HIDDEN = "hidden" + IGNORED = "ignored" + + +class DiscoveryLifecycle(StrEnum): + ACTIVE = "active" + MISSING = "missing" + STALE = "stale" + IGNORED = "ignored" + RETIRED = "retired" + + +class DiscoveryConfidence(StrEnum): + CONFIRMED = "confirmed" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +class DiscoveryRunStatus(StrEnum): + IDLE = "idle" + DISCOVERING = "discovering" + RECONCILING = "reconciling" + COMPLETED = "completed" + FAILED = "failed" + + +class SuggestionStatus(StrEnum): + OPEN = "open" + ACCEPTED = "accepted" + REJECTED = "rejected" + SUPERSEDED = "superseded" + + +class RelationType(StrEnum): + DEPENDS_ON = "depends_on" + ROUTES_TO = "routes_to" + USES_STORAGE = "uses_storage" + COMMUNICATES_WITH = "communicates_with" + + +class ServiceCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + kind: ServiceKind + url: str | None = None + host: str | None = Field(default=None, validation_alias=AliasChoices("host", "hostname")) + port: int | None = Field(default=None, ge=1, le=65535) + depends_on: list[str] = Field(default_factory=list) + description: str | None = Field(default=None, max_length=500) + tags: list[str] = Field(default_factory=list, max_length=20) + group: str | None = Field(default=None, max_length=80) + environment: str | None = Field(default=None, max_length=80) + owner: str | None = Field(default=None, max_length=120) + enabled: bool = True + monitoring_enabled: bool = True + monitoring_state: MonitoringState = MonitoringState.ACTIVE + probe_source: ProbeEndpointSource = ProbeEndpointSource.MANUAL + probe_reachability: ProbeReachability = ProbeReachability.VERIFIED + probe_confidence: DiscoveryConfidence = DiscoveryConfidence.CONFIRMED + runtime_state: str | None = Field(default=None, max_length=40) + runtime_health: str | None = Field(default=None, max_length=40) + application: str | None = Field(default=None, max_length=120) + domain: str | None = Field(default=None, max_length=80) + component_role: ComponentRole = ComponentRole.UNKNOWN + application_locked: bool = False + domain_locked: bool = False + component_role_locked: bool = False + criticality: Criticality = Criticality.MEDIUM + category: DiscoveryCategory = DiscoveryCategory.UNKNOWN + visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE + lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE + source_type: str | None = Field(default=None, max_length=40) + source_ref: str | None = Field(default=None, max_length=240) + first_seen_at: datetime | None = None + last_seen_at: datetime | None = None + last_discovery_status: str | None = Field(default=None, max_length=40) + container_image: str | None = Field(default=None, max_length=240) + compose_project: str | None = Field(default=None, max_length=120) + layout_x: float | None = None + layout_y: float | None = None + layout_pinned: bool = False + interval_seconds: int | None = Field(default=None, ge=1, le=86400) + timeout_seconds: float | None = Field(default=None, ge=0.1, le=60) + retries: int | None = Field(default=None, ge=0, le=5) + failure_threshold: int = Field(default=1, ge=1, le=20) + recovery_threshold: int = Field(default=1, ge=1, le=20) + expected_status_min: int | None = Field(default=None, ge=100, le=599) + expected_status_max: int | None = Field(default=None, ge=100, le=599) + availability_target: float | None = Field(default=None, ge=0, le=100) + maintenance_enabled: bool = False + maintenance_reason: str | None = Field(default=None, max_length=240) + + @model_validator(mode="after") + def validate_endpoint(self) -> ServiceCreate: + if not self.monitoring_enabled and not self.url and not self.host and self.port is None: + return self + if self.kind == ServiceKind.HTTP and not self.url: + raise ValueError("HTTP services require url") + if self.kind == ServiceKind.HTTP: + parsed = urlparse(self.url or "") + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("HTTP services require an absolute http(s) url") + if self.host or self.port: + raise ValueError("HTTP services must use url instead of host/port") + if self.kind in {ServiceKind.TCP, ServiceKind.TLS} and (not self.host or self.port is None): + raise ValueError(f"{self.kind.value.upper()} services require host and port") + if self.kind == ServiceKind.DNS and not self.host: + raise ValueError("DNS services require hostname in host") + if self.kind in {ServiceKind.TCP, ServiceKind.TLS, ServiceKind.DNS} and self.url: + raise ValueError(f"{self.kind.value.upper()} services must use host/port instead of url") + if self.kind == ServiceKind.DNS and self.port is not None: + raise ValueError("DNS services use a hostname without a port") + if ( + self.expected_status_min is not None + and self.expected_status_max is not None + and self.expected_status_min > self.expected_status_max + ): + raise ValueError("expected_status_min cannot exceed expected_status_max") + return self + + +class ServiceUpdate(ServiceCreate): + pass + + +class Service(ServiceCreate): + id: str + created_at: datetime + updated_at: datetime + state: HealthStatus = HealthStatus.UNKNOWN + failure_streak: int = 0 + recovery_streak: int = 0 + flapping: bool = False + flap_count: int = 0 + last_state_change_at: datetime | None = None + + +class ProbeResult(BaseModel): + id: str + service_id: str + status: HealthStatus + latency_ms: float | None + detail: str + checked_at: datetime + trigger: str = "manual" + metadata: dict[str, object] = Field(default_factory=dict) + + +class HealthEvidence(BaseModel): + """An immutable-ish, provider-scoped claim used to resolve direct health. + + Application-scoped evidence is intentionally retained for provenance, but + cannot be promoted to a component's direct health without an explicit + component-level observation from that provider. + """ + + id: str + subject_type: str = Field(default="service", max_length=40) + subject_id: str = Field(max_length=240) + service_id: str | None = None + provider: EvidenceProvider + provider_ref: str | None = Field(default=None, max_length=240) + status: HealthStatus + scope: EvidenceScope = EvidenceScope.COMPONENT + confidence: float = Field(default=1.0, ge=0, le=1) + observed_at: datetime + valid_until: datetime + received_at: datetime + summary: str = Field(default="", max_length=500) + metadata: dict[str, object] = Field(default_factory=dict) + active: bool = True + superseded_by: str | None = None + freshness: EvidenceFreshness = EvidenceFreshness.FRESH + + +class HealthResolution(BaseModel): + status: HealthStatus = HealthStatus.UNKNOWN + selected_evidence_id: str | None = None + selected_provider: EvidenceProvider | None = None + confidence: float | None = Field(default=None, ge=0, le=1) + reason_code: HealthResolutionReason = HealthResolutionReason.NO_CURRENT_COMPONENT_EVIDENCE + explanation: str = "No current component-scoped health evidence is available." + conflict: bool = False + evidence: list[HealthEvidence] = Field(default_factory=list) + + +class ApplicationHealthResolution(BaseModel): + """Aggregate health answer that keeps application and component scopes separate.""" + + status: HealthStatus = HealthStatus.UNKNOWN + confidence: float | None = Field(default=None, ge=0, le=1) + explanation: str = "No current application or component health evidence is available." + reason_code: HealthResolutionReason = HealthResolutionReason.NO_APPLICATION_EVIDENCE + selected_evidence_id: str | None = None + selected_provider: EvidenceProvider | None = None + evidence_freshness: EvidenceFreshness | None = None + conflict: bool = False + resolved_components: int = 0 + unresolved_components: int = 0 + component_coverage_percent: float = 0 + + +class ProviderStatus(BaseModel): + provider: EvidenceProvider + status: ProviderHealth = ProviderHealth.UNKNOWN + observed_at: datetime + valid_until: datetime + detail: str = Field(default="", max_length=500) + metadata: dict[str, object] = Field(default_factory=dict) + + +class ServiceSnapshot(BaseModel): + service: Service + latest_probe: ProbeResult | None = None + availability_24h: AvailabilitySummary | None = None + dependency_status: HealthStatus = HealthStatus.UNKNOWN + effective_status: HealthStatus = HealthStatus.UNKNOWN + direct_status: HealthStatus = HealthStatus.UNKNOWN + flapping: bool = False + flap_count: int = 0 + next_probe_at: datetime | None = None + dependents: list[str] = Field(default_factory=list) + sources: list[ServiceSource] = Field(default_factory=list) + evidence: list[RelationEvidence] = Field(default_factory=list) + health_evidence: list[HealthEvidence] = Field(default_factory=list) + health_resolution: HealthResolution = Field(default_factory=HealthResolution) + + +class AvailabilitySummary(BaseModel): + hours: int = 24 + total_checks: int = 0 + healthy_checks: int = 0 + degraded_checks: int = 0 + down_checks: int = 0 + uptime_percent: float | None = None + average_latency_ms: float | None = None + last_checked_at: datetime | None = None + target_percent: float | None = None + meets_target: bool | None = None + + +class BulkProbeResult(BaseModel): + requested: int + completed: int + results: list[ProbeResult] + + +class IncidentCandidate(BaseModel): + service_id: str + service_name: str + status: HealthStatus + reason: str + affected_service_ids: list[str] = Field(default_factory=list) + affected_service_names: list[str] = Field(default_factory=list) + + +class IncidentRecord(BaseModel): + id: str + status: IncidentStatus + root_service_id: str + root_service_name: str + started_at: datetime + detected_at: datetime + acknowledged_at: datetime | None = None + resolved_at: datetime | None = None + title: str + root_cause_reason: str + affected_service_ids: list[str] = Field(default_factory=list) + affected_service_names: list[str] = Field(default_factory=list) + severity: Criticality = Criticality.MEDIUM + latest_update_at: datetime + + +class IncidentEvent(BaseModel): + id: str + incident_id: str + event_type: str + message: str + created_at: datetime + service_id: str | None = None + + +class EventRecord(BaseModel): + id: str + event_type: str + service_id: str | None = None + incident_id: str | None = None + from_status: HealthStatus | None = None + to_status: HealthStatus | None = None + message: str + metadata: dict[str, object] = Field(default_factory=dict) + created_at: datetime + + +class IncidentOverview(BaseModel): + generated_at: datetime + active: bool + candidates: list[IncidentCandidate] + degraded_service_ids: list[str] + down_service_ids: list[str] + incidents: list[IncidentRecord] = Field(default_factory=list) + + +class ServiceSource(BaseModel): + id: str + source_type: str + source_id: str + source_name: str | None = None + first_seen_at: datetime + last_seen_at: datetime + lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE + managed: bool = True + metadata: dict[str, object] = Field(default_factory=dict) + + +class RelationEvidence(BaseModel): + id: str + source_service_id: str + target_service_id: str | None = None + target_ref: str | None = None + relation_type: RelationType = RelationType.DEPENDS_ON + evidence_type: str + confidence: DiscoveryConfidence + explanation: str + source_ref: str | None = None + locked: bool = False + active: bool = True + first_seen_at: datetime + last_seen_at: datetime + + +class LogicalRelation(BaseModel): + """One canonical relationship with all independent supporting evidence.""" + + id: str + source_service_id: str + target_service_id: str | None = None + target_ref: str | None = None + relation_type: RelationType = RelationType.DEPENDS_ON + confidence: DiscoveryConfidence + active: bool = True + locked: bool = False + first_seen_at: datetime + last_seen_at: datetime + evidence: list[RelationEvidence] = Field(default_factory=list) + + +class SemanticComponent(BaseModel): + service_id: str + name: str + kind: ServiceKind + application_id: str + domain_id: str + role: ComponentRole = ComponentRole.UNKNOWN + direct_status: HealthStatus + effective_status: HealthStatus + unknown_reason: HealthResolutionReason | None = None + runtime_state: str | None = None + runtime_health: str | None = None + monitoring_state: MonitoringState + latest_latency_ms: float | None = None + dependency_ids: list[str] = Field(default_factory=list) + dependent_ids: list[str] = Field(default_factory=list) + root_cause: bool = False + affected: bool = False + + +class SemanticApplication(BaseModel): + id: str + name: str + domain_id: str + component_ids: list[str] = Field(default_factory=list) + component_count: int = 0 + roles: list[ComponentRole] = Field(default_factory=list) + status: HealthStatus + status_explanation: str + health_resolution: ApplicationHealthResolution = Field(default_factory=ApplicationHealthResolution) + dependency_application_ids: list[str] = Field(default_factory=list) + dependent_application_ids: list[str] = Field(default_factory=list) + monitored_components: int = 0 + unmonitored_components: int = 0 + resolved_components: int = 0 + unresolved_components: int = 0 + component_coverage_percent: float = 0 + root_cause: bool = False + affected: bool = False + + +class SemanticDomain(BaseModel): + id: str + name: str + application_ids: list[str] = Field(default_factory=list) + application_count: int = 0 + status: HealthStatus + resolved_application_count: int = 0 + unresolved_application_count: int = 0 + application_coverage_percent: float = 0 + incident_count: int = 0 + root_cause: bool = False + affected: bool = False + + +class SemanticTopology(BaseModel): + generated_at: datetime + domains: list[SemanticDomain] = Field(default_factory=list) + applications: list[SemanticApplication] = Field(default_factory=list) + components: list[SemanticComponent] = Field(default_factory=list) + relations: list[LogicalRelation] = Field(default_factory=list) + monitoring_coverage: dict[str, int] = Field(default_factory=dict) + + +class DiscoveryObservation(BaseModel): + id: str + run_id: str + source_id: str + source_type: str + source_name: str + lifecycle: DiscoveryLifecycle + change_type: str + observed_at: datetime + metadata: dict[str, object] = Field(default_factory=dict) + + +class TopologySuggestion(BaseModel): + id: str + run_id: str + suggestion_type: str + status: SuggestionStatus = SuggestionStatus.OPEN + source_ref: str | None = None + source_service_id: str | None = None + target_ref: str | None = None + target_service_id: str | None = None + proposed_name: str | None = None + proposed_kind: ServiceKind | None = None + proposed_url: str | None = None + proposed_host: str | None = None + proposed_port: int | None = None + proposed_category: DiscoveryCategory = DiscoveryCategory.UNKNOWN + proposed_group: str | None = None + relation_type: RelationType | None = None + confidence: DiscoveryConfidence = DiscoveryConfidence.LOW + evidence: list[str] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class DiscoveryDiff(BaseModel): + new: int = 0 + changed: int = 0 + missing: int = 0 + unchanged: int = 0 + stale: int = 0 + new_suggestions: int = 0 + + +class DiscoveryRun(BaseModel): + id: str + status: DiscoveryRunStatus + started_at: datetime + completed_at: datetime | None = None + source_counts: dict[str, int] = Field(default_factory=dict) + diff: DiscoveryDiff = Field(default_factory=DiscoveryDiff) + error: str | None = None + + +class TopologySnapshot(BaseModel): + """Compact, immutable capture of the canonical topology after a run.""" + + id: str + run_id: str | None = None + created_at: datetime + revision: str + service_count: int = 0 + relationship_count: int = 0 + summary: dict[str, int] = Field(default_factory=dict) + + +class TopologyChange(BaseModel): + change_type: str + entity_type: str + entity_id: str + name: str + before: dict[str, object] | None = None + after: dict[str, object] | None = None + + +class TopologyDiff(BaseModel): + before_snapshot_id: str | None = None + after_snapshot_id: str + added: list[TopologyChange] = Field(default_factory=list) + removed: list[TopologyChange] = Field(default_factory=list) + changed: list[TopologyChange] = Field(default_factory=list) + summary: dict[str, int] = Field(default_factory=dict) + + +class DiscoveryRunRequest(BaseModel): + docker_host: str | None = None + appops_url: str | None = None + include_stopped: bool = False + + +class DiscoveryReconcileRequest(BaseModel): + run_id: str | None = None + apply_high_confidence: bool = False + include_stopped: bool = False + + +class EdgeMutationRequest(BaseModel): + source_service_id: str + target_service_id: str + relation_type: RelationType = RelationType.DEPENDS_ON + lock: bool = True + explanation: str = Field(default="Manual topology relationship", max_length=500) + + +class EdgeActionRequest(BaseModel): + action: str = Field(pattern="^(confirm|reject|lock|remove)$") + + +class ConfigService(ServiceCreate): + pass + + +class ConfigDocument(BaseModel): + # Version 1 remains accepted. Version 2 is emitted when the document uses + # the richer service fields; the parser keeps the old empty v2 rejection + # compatibility used by the v1 contract tests. + version: int = Field(default=2, ge=1, le=2) + services: list[ConfigService] = Field(default_factory=list) + + +class ConfigImportRequest(BaseModel): + content: str + format: str = Field(pattern="^(yaml|json)$") + mode: str = Field(default="merge", pattern="^(merge|replace)$") + + +class ConfigImportResult(BaseModel): + created: int + updated: int + total: int + + +class DemoState(BaseModel): + active: bool + synthetic: bool = False + scenario_name: str | None = None + service_count: int = 0 + root_cause_service_id: str | None = None + root_cause_service_name: str | None = None + affected_service_ids: list[str] = Field(default_factory=list) + affected_service_names: list[str] = Field(default_factory=list) + affected_count: int = 0 + + +class DemoMutationResult(DemoState): + created: int = 0 + updated: int = 0 + total: int = 0 + restored: bool = False + + +class DockerDiscoveryRequest(BaseModel): + docker_host: str = "unix:///var/run/docker.sock" + + +class DockerPort(BaseModel): + container_port: str + host_ip: str | None = None + host_port: int | None = None + + +class DockerSuggestion(BaseModel): + name: str + kind: ServiceKind + host: str | None = None + port: int | None = None + url: str | None = None + group: str | None = None + evidence: list[str] = Field(default_factory=list) + confidence: str = "low" + depends_on_names: list[str] = Field(default_factory=list) + source_type: str | None = None + source_ref: str | None = None + stable_source_ref: str | None = None + category: DiscoveryCategory = DiscoveryCategory.UNKNOWN + visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE + relation_type: RelationType = RelationType.DEPENDS_ON + + +class ProbeEndpointCandidate(BaseModel): + """A reachable-or-reviewable endpoint discovered for a Docker workload.""" + + source: ProbeEndpointSource + kind: ServiceKind + url: str | None = None + host: str | None = None + port: int | None = Field(default=None, ge=1, le=65535) + reachability: ProbeReachability = ProbeReachability.UNVERIFIED + confidence: DiscoveryConfidence = DiscoveryConfidence.LOW + primary: bool = False + evidence: str = "" + preflight_detail: str | None = None + preflight_latency_ms: float | None = None + + +class DockerCandidate(BaseModel): + container_id: str + name: str + image: str + state: str + health: str | None = None + networks: list[str] + ports: list[DockerPort] + labels: dict[str, str] = Field(default_factory=dict) + import_state: str = "new" + suggestions: list[DockerSuggestion] = Field(default_factory=list) + dependency_suggestions: list[str] = Field(default_factory=list) + stable_source_ref: str | None = None + compose_project: str | None = None + compose_service: str | None = None + category: DiscoveryCategory = DiscoveryCategory.UNKNOWN + visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE + lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE + healthcheck_present: bool = False + environment_evidence: list[str] = Field(default_factory=list) + proxy_route_evidence: list[str] = Field(default_factory=list) + appops_app_id: str | None = None + appops_category: str | None = None + appops_criticality: str | None = None + appops_health: str | None = None + appops_scope: EvidenceScope | None = None + appops_discovery_confidence: float | None = Field(default=None, ge=0, le=1) + appops_policy_profile: str | None = None + appops_app_type: str | None = None + probe_candidates: list[ProbeEndpointCandidate] = Field(default_factory=list) + + +class DockerDiscoveryResult(BaseModel): + docker_host: str + candidates: list[DockerCandidate] + proxy_routes: int = 0 + appops_available: bool = False + appops_detail: str = "" + + +class DiscoveryStatus(BaseModel): + current: DiscoveryRun | None = None + last_completed: DiscoveryRun | None = None + active: bool = False + + +class DiscoveryResult(DockerDiscoveryResult): + run: DiscoveryRun + suggestions: list[TopologySuggestion] = Field(default_factory=list) + observations: list[DiscoveryObservation] = Field(default_factory=list) + appops_available: bool = False + appops_mappings: int = 0 + proxy_routes: int = 0 + + +class ReconcileResult(BaseModel): + run: DiscoveryRun + created: int = 0 + updated: int = 0 + unchanged: int = 0 + missing: int = 0 + retired: int = 0 + accepted_relations: int = 0 + open_suggestions: int = 0 + service_ids: list[str] = Field(default_factory=list) + + +class ProbeRepairChange(BaseModel): + service_id: str + service_name: str + previous_monitoring_state: MonitoringState + next_monitoring_state: MonitoringState + previous_endpoint: str | None = None + next_endpoint: str | None = None + next_probe_source: ProbeEndpointSource | None = None + next_probe_confidence: DiscoveryConfidence | None = None + reason: str + + +class ProbeRepairReport(BaseModel): + run_id: str | None = None + dry_run: bool = True + inspected: int = 0 + changed: int = 0 + disabled_unsafe: int = 0 + enabled_published: int = 0 + docker_health_observed: int = 0 + appops_observed: int = 0 + runtime_observed: int = 0 + unmonitored: int = 0 + resolved_incidents: int = 0 + changes: list[ProbeRepairChange] = Field(default_factory=list) + + +class OverviewSummary(BaseModel): + generated_at: datetime + total_services: int + health_counts: dict[str, int] + active_incidents: int + likely_root_causes: list[str] = Field(default_factory=list) + flapping_services: list[str] = Field(default_factory=list) + recent_events: list[EventRecord] = Field(default_factory=list) + monitoring_coverage: dict[str, int] = Field(default_factory=dict) + + +class MaintenanceRequest(BaseModel): + enabled: bool + reason: str | None = Field(default=None, max_length=240) diff --git a/backend/app/observation.py b/backend/app/observation.py new file mode 100644 index 0000000..e6b0740 --- /dev/null +++ b/backend/app/observation.py @@ -0,0 +1,206 @@ +"""Recurring, side-effect-bounded infrastructure health observation.""" + +from __future__ import annotations + +import os +import threading +from dataclasses import dataclass +from datetime import timedelta +from uuid import uuid4 + +from .docker_discovery import appops_inventory, observe_container_health +from .docker_provider import docker_provider_mode +from .evidence import appops_health_status, utc_now +from .models import ( + EvidenceProvider, + EvidenceScope, + HealthEvidence, + HealthStatus, + ProviderHealth, + ProviderStatus, +) +from .store import Store + + +def observation_ttl(interval_seconds: int) -> timedelta: + """Provider observation TTL with room for one delayed refresh cycle.""" + + return timedelta(seconds=max(150, interval_seconds * 3)) + + +def _appops_status(value: str | None) -> HealthStatus | None: + return appops_health_status(value) + + +def _docker_status(state: str, health: str | None) -> HealthStatus | None: + if (health or "").casefold() in {"unhealthy", "down"} or state.casefold() in {"exited", "dead", "stopped"}: + return HealthStatus.DOWN + if (health or "").casefold() == "degraded" or state.casefold() == "restarting": + return HealthStatus.DEGRADED + if (health or "").casefold() == "healthy": + return HealthStatus.HEALTHY + return None + + +@dataclass(frozen=True) +class ObservationResult: + docker_observations: int = 0 + appops_observations: int = 0 + skipped: bool = False + + +class ObservationManager: + """Refresh provider evidence only; discovery and reconciliation stay manual.""" + + def __init__(self, store: Store, *, interval_seconds: int) -> None: + self.store = store + self.interval_seconds = interval_seconds + self._lock = threading.Lock() + + @staticmethod + def _docker_host() -> str: + return os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock") + + @staticmethod + def _appops_url() -> str | None: + return os.getenv("SERVICEMAP_APPOPS_URL") + + def refresh(self) -> ObservationResult: + if self.store.demo_active() or not self._lock.acquire(blocking=False): + return ObservationResult(skipped=True) + try: + now = utc_now() + valid_until = now + observation_ttl(self.interval_seconds) + services_by_ref = { + service.source_ref: service + for service in self.store.list_services() + if service.source_ref + } + persisted_app_links = self.store.application_evidence_links(EvidenceProvider.APPOPS) + appops = appops_inventory(self._appops_url()) + if appops.available: + self.store.set_provider_status( + ProviderStatus( + provider=EvidenceProvider.APPOPS, + status=ProviderHealth.HEALTHY, + observed_at=now, + valid_until=valid_until, + detail=appops.detail, + metadata={"mapping_count": len(appops.by_container)}, + ) + ) + else: + self.store.set_provider_status( + ProviderStatus( + provider=EvidenceProvider.APPOPS, + status=ProviderHealth.UNAVAILABLE, + observed_at=now, + valid_until=valid_until, + detail=appops.detail, + ) + ) + + linked_apps: dict[str, set[str]] = { + app_id: set(service_ids) for app_id, service_ids in persisted_app_links.items() + } + docker_count = 0 + try: + docker_observations = observe_container_health(self._docker_host()) + if ( + docker_provider_mode() == "broker" + and not docker_observations + and any(ref.startswith("docker:") for ref in services_by_ref) + ): + raise ValueError("Docker broker returned no containers for configured services") + self.store.set_provider_status( + ProviderStatus( + provider=EvidenceProvider.DOCKER, + status=ProviderHealth.HEALTHY, + observed_at=now, + valid_until=valid_until, + detail=f"{len(docker_observations)} containers observed", + metadata={"container_count": len(docker_observations)}, + ) + ) + for observation in docker_observations: + service = services_by_ref.get(observation.stable_source_ref) + if service is None: + continue + docker_count += 1 + appops_info = appops.by_container.get(observation.name.casefold()) + if appops_info: + linked_apps.setdefault(appops_info.app_id, set()).add(service.id) + status = _docker_status(observation.state, observation.health) + if status is None: + continue + self.store.record_health_evidence( + HealthEvidence( + id=str(uuid4()), + subject_type="service", + subject_id=service.id, + service_id=service.id, + provider=EvidenceProvider.DOCKER, + provider_ref=observation.stable_source_ref, + status=status, + scope=EvidenceScope.COMPONENT, + confidence=0.95 if observation.healthcheck_present else 0.8, + observed_at=now, + valid_until=valid_until, + received_at=now, + summary="Docker healthcheck" if observation.healthcheck_present else "Docker runtime state", + metadata={ + "container_id": observation.container_id, + "state": observation.state, + "health": observation.health, + }, + ) + ) + except (OSError, RuntimeError, ValueError) as exc: + self.store.set_provider_status( + ProviderStatus( + provider=EvidenceProvider.DOCKER, + status=ProviderHealth.UNAVAILABLE, + observed_at=now, + valid_until=valid_until, + detail=str(exc)[:500], + ) + ) + + appops_count = 0 + if appops.available: + for app_id, service_ids in linked_apps.items(): + info = next( + (item for item in appops.by_container.values() if item.app_id == app_id), + None, + ) + status = _appops_status(info.health if info else None) + if info is None or status is None or not service_ids: + continue + appops_count += 1 + self.store.record_health_evidence( + HealthEvidence( + id=str(uuid4()), + subject_type="application", + subject_id=f"appops:{app_id}", + provider=EvidenceProvider.APPOPS, + provider_ref=app_id, + status=status, + scope=EvidenceScope.APPLICATION, + confidence=info.discovery_confidence if info.discovery_confidence is not None else 0.5, + observed_at=now, + valid_until=valid_until, + received_at=now, + summary="AppOps application health; never used as component direct health", + metadata={ + "app_id": app_id, + "category": info.category, + "policy_profile": info.policy_profile, + "app_type": info.app_type, + "discovery_confidence": info.discovery_confidence, + }, + ), + service_ids=sorted(service_ids), + ) + return ObservationResult(docker_observations=docker_count, appops_observations=appops_count) + finally: + self._lock.release() diff --git a/backend/app/probes.py b/backend/app/probes.py new file mode 100644 index 0000000..b216b6a --- /dev/null +++ b/backend/app/probes.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import asyncio +import contextvars +import os +import socket +import ssl +from datetime import UTC, datetime +from time import perf_counter +from uuid import uuid4 + +import httpx + +from .models import HealthStatus, ProbeResult, Service, ServiceKind + +_probe_metadata: contextvars.ContextVar[dict[str, object] | None] = contextvars.ContextVar( + "servicemap_probe_metadata", default=None +) +_service_locks: dict[str, asyncio.Lock] = {} + + +def _lock_for(service_id: str) -> asyncio.Lock: + lock = _service_locks.get(service_id) + if lock is None: + lock = asyncio.Lock() + _service_locks[service_id] = lock + return lock + + +def _http_status(status_code: int, service: Service) -> HealthStatus: + minimum = service.expected_status_min + maximum = service.expected_status_max + if minimum is not None or maximum is not None: + if (minimum is not None and status_code < minimum) or (maximum is not None and status_code > maximum): + return HealthStatus.DOWN if status_code >= 500 else HealthStatus.DEGRADED + return HealthStatus.HEALTHY + if status_code < 400: + return HealthStatus.HEALTHY + if status_code < 500: + return HealthStatus.DEGRADED + return HealthStatus.DOWN + + +async def _probe_once_with_metadata(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str, dict[str, object]]: + if service.kind == ServiceKind.HTTP: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: + response = await client.get(service.url or "") + status = _http_status(response.status_code, service) + return status, f"HTTP {response.status_code}", {"status_code": response.status_code} + + if service.kind == ServiceKind.DNS: + started = perf_counter() + infos = await asyncio.wait_for( + asyncio.get_running_loop().getaddrinfo(service.host, None, type=socket.SOCK_STREAM), + timeout=timeout_seconds, + ) + addresses = sorted({str(info[4][0]) for info in infos if info[4]}) + if not addresses: + return HealthStatus.DOWN, f"DNS {service.host} returned no addresses", {"addresses": []} + return ( + HealthStatus.HEALTHY, + f"DNS {service.host} resolved ({round((perf_counter() - started) * 1000, 2)} ms)", + {"addresses": addresses[:20]}, + ) + + if service.kind == ServiceKind.TLS: + context = ssl.create_default_context() + reader, writer = await asyncio.wait_for( + asyncio.open_connection(service.host, service.port, ssl=context, server_hostname=service.host), + timeout=timeout_seconds, + ) + del reader + ssl_object = writer.get_extra_info("ssl_object") + metadata: dict[str, object] = {"hostname": service.host, "port": service.port} + detail = f"TLS {service.host}:{service.port} certificate reachable" + status = HealthStatus.HEALTHY + if ssl_object is not None: + certificate = ssl_object.getpeercert() or {} + not_after = certificate.get("notAfter") if isinstance(certificate, dict) else None + if not_after: + expires_at = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), UTC) + days_remaining = (expires_at - datetime.now(UTC)).total_seconds() / 86400 + metadata.update({"expires_at": expires_at.isoformat(), "days_remaining": round(days_remaining, 2)}) + warning_days = max(1, int(os.getenv("SERVICEMAP_TLS_WARNING_DAYS", "30"))) + if days_remaining <= 0: + status = HealthStatus.DOWN + detail = f"TLS certificate expired on {expires_at.date().isoformat()}" + elif days_remaining <= warning_days: + status = HealthStatus.DEGRADED + detail = f"TLS certificate expires in {round(days_remaining, 1)} days" + writer.close() + await writer.wait_closed() + return status, detail, metadata + + _, writer = await asyncio.wait_for( + asyncio.open_connection(service.host, service.port), + timeout=timeout_seconds, + ) + writer.close() + await writer.wait_closed() + return HealthStatus.HEALTHY, f"TCP {service.host}:{service.port} reachable", {} + + +async def _probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]: + status, detail, metadata = await _probe_once_with_metadata(service, timeout_seconds) + _probe_metadata.set(metadata) + return status, detail + + +async def probe_service( + service: Service, + timeout_seconds: float | None = 5.0, + retries: int | None = 1, + *, + trigger: str = "manual", + default_timeout_seconds: float = 5.0, + default_retries: int = 1, +) -> ProbeResult: + if not service.enabled or not service.monitoring_enabled: + return ProbeResult( + id=str(uuid4()), + service_id=service.id, + status=HealthStatus.UNKNOWN, + latency_ms=None, + detail="Monitoring disabled for service", + checked_at=datetime.now(UTC), + trigger=trigger, + ) + timeout = service.timeout_seconds if timeout_seconds is None else timeout_seconds + timeout = timeout if timeout is not None else default_timeout_seconds + retry_count = service.retries if retries is None else retries + retry_count = retry_count if retry_count is not None else default_retries + attempts = max(1, retry_count + 1) + status = HealthStatus.DOWN + detail = "Probe failed" + latency_ms: float | None = None + metadata: dict[str, object] = {} + + async with _lock_for(service.id): + for attempt in range(1, attempts + 1): + _probe_metadata.set({}) + started = perf_counter() + try: + status, detail = await _probe_once(service, timeout) + metadata = _probe_metadata.get() or {} + latency_ms = round((perf_counter() - started) * 1000, 2) + if attempt > 1: + detail = f"{detail} after {attempt} attempts" + break + except (TimeoutError, httpx.HTTPError, OSError, ssl.SSLError, socket.gaierror) as exc: + latency_ms = round((perf_counter() - started) * 1000, 2) + detail = f"{type(exc).__name__}: {exc}" + if isinstance(exc, ssl.CertificateError): + status = HealthStatus.DOWN + detail = f"TLS hostname mismatch: {exc}" + if attempt < attempts: + await asyncio.sleep(min(0.2 * attempt, 1.0)) + + return ProbeResult( + id=str(uuid4()), + service_id=service.id, + status=status, + latency_ms=latency_ms, + detail=detail[:500], + checked_at=datetime.now(UTC), + trigger=trigger, + metadata=metadata, + ) + + +async def probe_many( + services: list[Service], + *, + timeout_seconds: float | None = 5.0, + retries: int | None = 1, + concurrency: int = 10, + trigger: str = "manual", + default_timeout_seconds: float = 5.0, + default_retries: int = 1, +) -> list[ProbeResult]: + semaphore = asyncio.Semaphore(max(1, concurrency)) + + async def run(service: Service) -> ProbeResult: + async with semaphore: + if trigger == "manual" and default_timeout_seconds == 5.0 and default_retries == 1: + return await probe_service(service, timeout_seconds=timeout_seconds, retries=retries) + if trigger == "manual": + return await probe_service( + service, + timeout_seconds=timeout_seconds, + retries=retries, + default_timeout_seconds=default_timeout_seconds, + default_retries=default_retries, + ) + return await probe_service( + service, + timeout_seconds=timeout_seconds, + retries=retries, + trigger=trigger, + default_timeout_seconds=default_timeout_seconds, + default_retries=default_retries, + ) + + return list(await asyncio.gather(*(run(service) for service in services))) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py new file mode 100644 index 0000000..9a963f4 --- /dev/null +++ b/backend/app/scheduler.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +import logging +from time import monotonic + +from .probes import probe_many +from .store import Store + +logger = logging.getLogger(__name__) + + +async def scheduled_probe_loop( + store: Store, + *, + interval_seconds: int, + timeout_seconds: float, + retries: int, + concurrency: int, + retention_days: int, +) -> None: + next_due: dict[str, float] = {} + while True: + sleep_seconds = interval_seconds + try: + # The Explore demo is deliberately synthetic. Pausing scheduled + # probes while it is active keeps its seeded incident and history + # deterministic and avoids any dependency on external services. + if not getattr(store, "demo_active", lambda: False)(): + services = store.list_services() + now = monotonic() + due = [ + service + for service in services + if service.enabled + and service.monitoring_enabled + and now >= next_due.get(service.id, 0) + ] + if due: + results = await probe_many( + due, + timeout_seconds=None, + retries=None, + concurrency=concurrency, + trigger="scheduled", + default_timeout_seconds=timeout_seconds, + default_retries=retries, + ) + for result in results: + store.save_probe(result) + for service in due: + next_due[service.id] = now + (service.interval_seconds or interval_seconds) + next_intervals = [ + service.interval_seconds or interval_seconds + for service in services + if service.enabled and service.monitoring_enabled + ] + sleep_seconds = min([interval_seconds, *next_intervals]) if next_intervals else interval_seconds + else: + sleep_seconds = interval_seconds + store.prune_probes(retention_days) + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover - defensive daemon boundary + logger.exception("Scheduled probe cycle failed") + + await asyncio.sleep(max(1, sleep_seconds)) diff --git a/backend/app/semantic.py b/backend/app/semantic.py new file mode 100644 index 0000000..f2fc4da --- /dev/null +++ b/backend/app/semantic.py @@ -0,0 +1,429 @@ +"""Deterministic semantic topology derived from the canonical service graph. + +The discovery store remains the source of truth. This module deliberately +does not persist a second topology: a domain, application and component view +can therefore evolve without risking probe history or an operator's graph. +""" + +from __future__ import annotations + +import hashlib +import re +from collections import defaultdict +from datetime import UTC, datetime + +from .evidence import with_freshness +from .models import ( + ApplicationHealthResolution, + ComponentRole, + Criticality, + EvidenceFreshness, + EvidenceScope, + HealthResolutionReason, + HealthStatus, + LogicalRelation, + SemanticApplication, + SemanticComponent, + SemanticDomain, + SemanticTopology, + Service, + ServiceSnapshot, +) + +_ROLE_BY_CATEGORY = { + "database": ComponentRole.DATABASE, + "cache": ComponentRole.CACHE, + "proxy": ComponentRole.PROXY, + "background_worker": ComponentRole.WORKER, + "storage": ComponentRole.STORAGE, + "monitoring": ComponentRole.MONITORING, +} +_ROLE_HINTS = ( + (ComponentRole.DATABASE, ("postgres", "mariadb", "mysql", "mongo", "influx", "database", "db")), + (ComponentRole.CACHE, ("redis", "memcache", "cache")), + (ComponentRole.PROXY, ("proxy", "nginx", "traefik", "caddy", "swag", "haproxy", "cloudflare")), + (ComponentRole.WORKER, ("worker", "runner", "queue", "task")), + (ComponentRole.STORAGE, ("minio", "storage", "s3", "volume", "files")), + (ComponentRole.MONITORING, ("grafana", "prometheus", "zabbix", "netdata", "loki", "uptime")), + (ComponentRole.AGENT, ("agent", "exporter", "sidecar")), + (ComponentRole.SCHEDULER, ("scheduler", "cron", "schedule")), + (ComponentRole.FRONTEND, ("frontend", "webui", "dashboard", "ui")), + (ComponentRole.API, ("api", "backend", "server")), +) +_DOMAIN_HINTS = ( + ("Edge", ("proxy", "nginx", "traefik", "caddy", "swag", "cloudflare", "authelia", "auth")), + ("Media", ("plex", "jellyfin", "sonarr", "radarr", "lidarr", "readarr", "bazarr", "media", "tautulli")), + ("Dev", ("gitea", "git", "code", "dev", "ci", "runner")), + ("Monitoring", ("grafana", "prometheus", "zabbix", "netdata", "loki", "uptime", "monitor")), + ("AI", ("ollama", "open webui", "comfy", "ai", "llm")), + ("Storage", ("postgres", "mariadb", "mysql", "mongo", "redis", "minio", "storage", "nextcloud", "file")), + ("Automation", ("n8n", "worker", "scheduler", "cron", "automation", "arr")), + ("Productivity", ("book", "paperless", "vault", "wiki", "project", "calendar", "office")), +) +def _slug(prefix: str, value: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") or "unknown" + digest = hashlib.sha1(value.casefold().encode("utf-8")).hexdigest()[:8] + return f"{prefix}:{normalized[:48]}-{digest}" + + +def component_role(service: Service) -> ComponentRole: + if service.component_role_locked or service.component_role != ComponentRole.UNKNOWN: + return service.component_role + category_role = _ROLE_BY_CATEGORY.get(service.category.value) + if category_role: + return category_role + haystack = " ".join(filter(None, (service.name, service.container_image, service.source_ref, service.description))).casefold() + for role, hints in _ROLE_HINTS: + if any(hint in haystack for hint in hints): + return role + # A monitoring gap says nothing about the component's workload role. Keep + # it unknown until source metadata, a deterministic hint or an operator + # override supplies evidence. + return ComponentRole.UNKNOWN + + +def application_name(service: Service) -> str: + """Use stable provenance before falling back to a standalone application. + + Category is intentionally *not* used as a shared application name. Doing + so was the old "Independent · Category" compaction bug: unrelated services + looked like one stack. Categories still inform domain derivation below. + """ + + if service.application: + return service.application.strip() + if service.compose_project: + return service.compose_project.replace("_", " ").replace("-", " ").title() + ref = service.source_ref or "" + compose = re.match(r"docker:compose:([^:]+):", ref, flags=re.IGNORECASE) + if compose: + return compose.group(1).replace("_", " ").replace("-", " ").title() + name = re.sub(r"(?:[-_ ](?:server|web|api|db|database|redis|worker|frontend|backend))$", "", service.name, flags=re.IGNORECASE).strip() + return name or service.name + + +def application_identity(service: Service) -> tuple[str, str]: + """Return a stable grouping key plus its operator-facing display name. + + Compose provenance wins over a generic display label. This keeps two + unrelated ``PostgreSQL`` containers from being merged merely because an + aggregate provider happens to use the same name for both applications. + Explicit operator grouping remains authoritative. + """ + + name = application_name(service) + if service.application_locked and service.application: + return f"operator:{service.application.casefold().strip()}", name + if service.compose_project: + return f"compose:{service.compose_project.casefold().strip()}", name + ref = service.source_ref or "" + compose = re.match(r"docker:compose:([^:]+):", ref, flags=re.IGNORECASE) + if compose: + return f"compose:{compose.group(1).casefold()}", name + if ref: + return f"source:{ref.casefold()}", name + if service.application: + return f"application:{service.application.casefold().strip()}", name + return f"service:{service.id}", name + + +def domain_name(service: Service, role: ComponentRole, application: str) -> str: + if service.domain: + return service.domain.strip() + haystack = " ".join(filter(None, (application, service.name, service.container_image, service.category.value))).casefold() + for domain, hints in _DOMAIN_HINTS: + if any(hint in haystack for hint in hints): + return domain + if role in {ComponentRole.DATABASE, ComponentRole.CACHE, ComponentRole.STORAGE}: + return "Storage" + if role in {ComponentRole.PROXY}: + return "Edge" + if role in {ComponentRole.MONITORING, ComponentRole.AGENT}: + return "Monitoring" + return "Infrastructure" if service.category.value in {"infrastructure", "system"} else "Productivity" + + +def _application_resolution( + members: list[ServiceSnapshot], roles: dict[str, ComponentRole] +) -> ApplicationHealthResolution: + """Resolve application health without promoting aggregate evidence to a component. + + A fresh, high-confidence application observation can answer the health of + its application even where individual components are intentionally not + probed. It can never change a component's direct status. Conversely a + concrete component failure always outweighs a weak aggregate healthy + claim, which preserves conflict visibility instead of masking incidents. + """ + + resolved = [ + item + for item in members + if item.direct_status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED} + ] + unresolved = len(members) - len(resolved) + coverage = round((len(resolved) / len(members)) * 100, 1) if members else 0.0 + application_evidence = { + item.id: with_freshness(item) + for snapshot in members + for item in snapshot.health_evidence + if item.active and item.scope == EvidenceScope.APPLICATION + } + fresh_claims = [ + item + for item in application_evidence.values() + if item.freshness == EvidenceFreshness.FRESH + ] + selected = max( + fresh_claims, + key=lambda item: (item.confidence, item.observed_at, item.received_at, item.id), + default=None, + ) + + direct_down = [item for item in members if item.direct_status == HealthStatus.DOWN] + impacted = [ + item + for item in members + if item.effective_status in {HealthStatus.DOWN, HealthStatus.DEGRADED, HealthStatus.RECOVERING} + and item.direct_status != HealthStatus.DOWN + ] + conflicting_aggregate = bool( + selected + and ( + (selected.status == HealthStatus.HEALTHY and direct_down) + or (selected.status in {HealthStatus.DOWN, HealthStatus.DEGRADED} and resolved) + ) + ) + common = { + "selected_evidence_id": selected.id if selected else None, + "selected_provider": selected.provider if selected else None, + "evidence_freshness": selected.freshness if selected else None, + "conflict": conflicting_aggregate, + "resolved_components": len(resolved), + "unresolved_components": unresolved, + "component_coverage_percent": coverage, + } + if direct_down: + primary = [ + item + for item in direct_down + if item.service.criticality == Criticality.CRITICAL + or roles[item.service.id] in {ComponentRole.FRONTEND, ComponentRole.API, ComponentRole.PROXY} + ] + status = HealthStatus.DOWN if len(members) == 1 or len(direct_down) == len(resolved) or primary else HealthStatus.DEGRADED + names = ", ".join(item.service.name for item in direct_down[:3]) + return ApplicationHealthResolution( + status=status, + confidence=max((item.health_resolution.confidence or 1.0 for item in direct_down), default=None), + explanation=f"{names} has current component failure evidence; aggregate health does not override it.", + reason_code=HealthResolutionReason.COMPONENT_FAILURE, + **common, + ) + if impacted: + names = ", ".join(item.service.name for item in impacted[:3]) + return ApplicationHealthResolution( + status=HealthStatus.DEGRADED, + confidence=selected.confidence if selected else None, + explanation=f"{names} is impacted through a dependency health signal.", + reason_code=HealthResolutionReason.COMPONENT_IMPACT, + **common, + ) + if selected and selected.confidence >= 0.7 and selected.status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}: + return ApplicationHealthResolution( + status=selected.status, + confidence=selected.confidence, + explanation=( + f"{selected.provider.value} application observation selected " + f"(confidence {selected.confidence:.2f}); component coverage is {coverage:.0f}% ." + ), + reason_code=HealthResolutionReason.SELECTED_APPLICATION_EVIDENCE, + **common, + ) + if resolved: + status = HealthStatus.HEALTHY + if any(item.direct_status in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} for item in resolved): + status = HealthStatus.DEGRADED + return ApplicationHealthResolution( + status=status, + explanation=f"{len(resolved)} of {len(members)} components have current direct health evidence.", + reason_code=HealthResolutionReason.COMPONENT_COVERAGE, + **common, + ) + stale = list(application_evidence.values()) + if stale: + reason = ( + HealthResolutionReason.STALE_EVIDENCE + if any(item.freshness == EvidenceFreshness.STALE for item in stale) + else HealthResolutionReason.EXPIRED_EVIDENCE + ) + return ApplicationHealthResolution( + status=HealthStatus.UNKNOWN, + explanation="Application evidence is no longer current; waiting for a provider refresh.", + reason_code=reason, + **common, + ) + if members and all(item.direct_status == HealthStatus.UNMONITORED for item in members): + return ApplicationHealthResolution( + status=HealthStatus.UNMONITORED, + explanation="No active health signal is configured for this application.", + reason_code=HealthResolutionReason.NO_APPLICATION_EVIDENCE, + **common, + ) + return ApplicationHealthResolution( + status=HealthStatus.UNKNOWN, + explanation="No current application or component health evidence is available.", + reason_code=HealthResolutionReason.NO_APPLICATION_EVIDENCE, + **common, + ) + + +def build_semantic_topology( + snapshots: list[ServiceSnapshot], + relations: list[LogicalRelation], + root_service_ids: set[str] | None = None, + affected_service_ids: set[str] | None = None, +) -> SemanticTopology: + root_service_ids = root_service_ids or set() + affected_service_ids = affected_service_ids or set() + snapshot_by_id = {item.service.id: item for item in snapshots} + roles = {item.service.id: component_role(item.service) for item in snapshots} + apps_by_key: dict[str, list[ServiceSnapshot]] = defaultdict(list) + app_names: dict[str, str] = {} + app_domain_names: dict[str, str] = {} + component_app_ids: dict[str, str] = {} + components: list[SemanticComponent] = [] + dependent_ids: dict[str, list[str]] = defaultdict(list) + for snapshot in snapshots: + for dependency_id in snapshot.service.depends_on: + dependent_ids[dependency_id].append(snapshot.service.id) + for snapshot in snapshots: + application_key, application = application_identity(snapshot.service) + apps_by_key[application_key].append(snapshot) + app_names[application_key] = application + role = roles[snapshot.service.id] + app_domain_names.setdefault(application_key, domain_name(snapshot.service, role, application)) + component_app_ids[snapshot.service.id] = _slug("app", application_key) + + applications: list[SemanticApplication] = [] + application_by_id: dict[str, SemanticApplication] = {} + domain_name_by_id: dict[str, str] = {} + for application_key, members in sorted(apps_by_key.items(), key=lambda item: app_names[item[0]].casefold()): + name = app_names[application_key] + app_id = _slug("app", application_key) + domain = app_domain_names[application_key] + domain_id = _slug("domain", domain) + domain_name_by_id[domain_id] = domain + resolution = _application_resolution(members, roles) + unmonitored = sum(item.direct_status == HealthStatus.UNMONITORED for item in members) + app = SemanticApplication( + id=app_id, + name=name, + domain_id=domain_id, + component_ids=[item.service.id for item in members], + component_count=len(members), + roles=sorted({roles[item.service.id] for item in members}, key=lambda role: role.value), + status=resolution.status, + status_explanation=resolution.explanation, + health_resolution=resolution, + monitored_components=resolution.resolved_components, + unmonitored_components=unmonitored, + resolved_components=resolution.resolved_components, + unresolved_components=resolution.unresolved_components, + component_coverage_percent=resolution.component_coverage_percent, + root_cause=any(item.service.id in root_service_ids for item in members), + affected=any(item.service.id in affected_service_ids for item in members), + ) + applications.append(app) + application_by_id[app_id] = app + + for snapshot in snapshots: + app_id = component_app_ids[snapshot.service.id] + app = application_by_id[app_id] + components.append(SemanticComponent( + service_id=snapshot.service.id, + name=snapshot.service.name, + kind=snapshot.service.kind, + application_id=app_id, + domain_id=app.domain_id, + role=roles[snapshot.service.id], + direct_status=snapshot.direct_status, + effective_status=snapshot.effective_status, + unknown_reason=( + snapshot.health_resolution.reason_code + if snapshot.direct_status == HealthStatus.UNKNOWN + else None + ), + runtime_state=snapshot.service.runtime_state, + runtime_health=snapshot.service.runtime_health, + monitoring_state=snapshot.service.monitoring_state, + latest_latency_ms=snapshot.latest_probe.latency_ms if snapshot.latest_probe else None, + dependency_ids=[item for item in snapshot.service.depends_on if item in snapshot_by_id], + dependent_ids=dependent_ids.get(snapshot.service.id, []), + root_cause=snapshot.service.id in root_service_ids, + affected=snapshot.service.id in affected_service_ids, + )) + + app_dependencies: dict[str, set[str]] = defaultdict(set) + app_dependents: dict[str, set[str]] = defaultdict(set) + for relation in relations: + if not relation.active or not relation.target_service_id: + continue + source_app = component_app_ids.get(relation.source_service_id) + target_app = component_app_ids.get(relation.target_service_id) + if source_app and target_app and source_app != target_app: + app_dependencies[source_app].add(target_app) + app_dependents[target_app].add(source_app) + for app in applications: + app.dependency_application_ids = sorted(app_dependencies[app.id]) + app.dependent_application_ids = sorted(app_dependents[app.id]) + + apps_by_domain: dict[str, list[SemanticApplication]] = defaultdict(list) + domain_names: dict[str, str] = {} + for app in applications: + apps_by_domain[app.domain_id].append(app) + domain_names[app.domain_id] = domain_name_by_id[app.domain_id] + domains: list[SemanticDomain] = [] + for domain_id, members in sorted(apps_by_domain.items(), key=lambda item: domain_names[item[0]].casefold()): + down = [item for item in members if item.status == HealthStatus.DOWN] + degraded = [ + item for item in members if item.status in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} + ] + healthy = [item for item in members if item.status == HealthStatus.HEALTHY] + resolved = [ + item + for item in members + if item.status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED} + ] + if down: + status = HealthStatus.DOWN + elif degraded: + status = HealthStatus.DEGRADED + elif healthy: + status = HealthStatus.HEALTHY + elif members and all(item.status == HealthStatus.UNMONITORED for item in members): + status = HealthStatus.UNMONITORED + else: + status = HealthStatus.UNKNOWN + domains.append(SemanticDomain( + id=domain_id, + name=domain_names[domain_id], + application_ids=[item.id for item in members], + application_count=len(members), + status=status, + resolved_application_count=len(resolved), + unresolved_application_count=len(members) - len(resolved), + application_coverage_percent=round((len(resolved) / len(members)) * 100, 1) if members else 0, + incident_count=sum(item.root_cause for item in members), + root_cause=any(item.root_cause for item in members), + affected=any(item.affected for item in members), + )) + coverage: dict[str, int] = defaultdict(int) + for snapshot in snapshots: + coverage[snapshot.service.monitoring_state.value] += 1 + return SemanticTopology( + generated_at=datetime.now(UTC), + domains=domains, + applications=applications, + components=components, + relations=relations, + monitoring_coverage=dict(coverage), + ) diff --git a/backend/app/store.py b/backend/app/store.py new file mode 100644 index 0000000..718fd65 --- /dev/null +++ b/backend/app/store.py @@ -0,0 +1,2012 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from .events import event_bus +from .evidence import resolve_health, with_freshness +from .models import ( + AvailabilitySummary, + ConfigImportResult, + ConfigService, + EventRecord, + EvidenceProvider, + EvidenceScope, + HealthEvidence, + HealthResolution, + HealthStatus, + IncidentEvent, + IncidentRecord, + IncidentStatus, + MonitoringState, + ProbeResult, + ProviderStatus, + Service, + ServiceCreate, + ServiceUpdate, +) + +SERVICE_COLUMNS = ( + "id, name, kind, url, host, port, depends_on, description, tags, \"group\", " + "environment, owner, enabled, monitoring_enabled, monitoring_state, probe_source, probe_reachability, probe_confidence, " + "runtime_state, runtime_health, application, domain, component_role, application_locked, domain_locked, component_role_locked, criticality, category, visibility, lifecycle, " + "source_type, source_ref, first_seen_at, last_seen_at, last_discovery_status, container_image, " + "compose_project, layout_x, layout_y, layout_pinned, " + "interval_seconds, timeout_seconds, retries, failure_threshold, recovery_threshold, " + "expected_status_min, expected_status_max, availability_target, maintenance_enabled, " + "maintenance_reason, state, failure_streak, recovery_streak, flapping, flap_count, " + "last_state_change_at, created_at, updated_at" +) + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _bool(value: object, default: bool = False) -> bool: + if value is None: + return default + return bool(int(value)) if isinstance(value, (int, str)) and str(value).isdigit() else bool(value) + + +class Store: + def __init__(self, db_path: str | None = None) -> None: + raw_path = db_path or os.getenv("SERVICEMAP_DB", "data/servicemap.db") + self.path = Path(raw_path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=5.0) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 5000") + return connection + + def _init_schema(self) -> None: + with self._connect() as db: + db.execute("PRAGMA journal_mode = WAL") + db.execute("PRAGMA synchronous = NORMAL") + db.executescript( + """ + CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + url TEXT, + host TEXT, + port INTEGER, + depends_on TEXT NOT NULL DEFAULT '[]', + description TEXT, + tags TEXT NOT NULL DEFAULT '[]', + "group" TEXT, + environment TEXT, + owner TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + monitoring_enabled INTEGER NOT NULL DEFAULT 1, + monitoring_state TEXT NOT NULL DEFAULT 'active_probe', + probe_source TEXT NOT NULL DEFAULT 'manual', + probe_reachability TEXT NOT NULL DEFAULT 'verified', + probe_confidence TEXT NOT NULL DEFAULT 'confirmed', + runtime_state TEXT, + runtime_health TEXT, + application TEXT, + domain TEXT, + component_role TEXT NOT NULL DEFAULT 'unknown', + application_locked INTEGER NOT NULL DEFAULT 0, + domain_locked INTEGER NOT NULL DEFAULT 0, + component_role_locked INTEGER NOT NULL DEFAULT 0, + criticality TEXT NOT NULL DEFAULT 'medium', + category TEXT NOT NULL DEFAULT 'unknown', + visibility TEXT NOT NULL DEFAULT 'visible', + lifecycle TEXT NOT NULL DEFAULT 'active', + source_type TEXT, + source_ref TEXT, + first_seen_at TEXT, + last_seen_at TEXT, + last_discovery_status TEXT, + container_image TEXT, + compose_project TEXT, + layout_x REAL, + layout_y REAL, + layout_pinned INTEGER NOT NULL DEFAULT 0, + interval_seconds INTEGER, + timeout_seconds REAL, + retries INTEGER, + failure_threshold INTEGER NOT NULL DEFAULT 1, + recovery_threshold INTEGER NOT NULL DEFAULT 1, + expected_status_min INTEGER, + expected_status_max INTEGER, + availability_target REAL, + maintenance_enabled INTEGER NOT NULL DEFAULT 0, + maintenance_reason TEXT, + state TEXT NOT NULL DEFAULT 'unknown', + failure_streak INTEGER NOT NULL DEFAULT 0, + recovery_streak INTEGER NOT NULL DEFAULT 0, + flapping INTEGER NOT NULL DEFAULT 0, + flap_count INTEGER NOT NULL DEFAULT 0, + last_state_change_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT + ); + + CREATE TABLE IF NOT EXISTS probe_results ( + id TEXT PRIMARY KEY, + service_id TEXT NOT NULL, + status TEXT NOT NULL, + latency_ms REAL, + detail TEXT NOT NULL, + checked_at TEXT NOT NULL, + trigger TEXT NOT NULL DEFAULT 'manual', + metadata TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_probe_service_time + ON probe_results(service_id, checked_at DESC); + CREATE INDEX IF NOT EXISTS idx_probe_time + ON probe_results(checked_at DESC); + + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + service_id TEXT, + incident_id TEXT, + from_status TEXT, + to_status TEXT, + message TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_events_time ON events(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_events_service_time ON events(service_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_events_incident_time ON events(incident_id, created_at ASC); + + CREATE TABLE IF NOT EXISTS incidents ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + root_service_id TEXT NOT NULL, + started_at TEXT NOT NULL, + detected_at TEXT NOT NULL, + acknowledged_at TEXT, + resolved_at TEXT, + title TEXT NOT NULL, + root_cause_reason TEXT NOT NULL, + affected_service_ids TEXT NOT NULL DEFAULT '[]', + severity TEXT NOT NULL DEFAULT 'medium', + latest_update_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status, latest_update_at DESC); + CREATE INDEX IF NOT EXISTS idx_incidents_root ON incidents(root_service_id, started_at DESC); + + CREATE TABLE IF NOT EXISTS demo_sessions ( + id INTEGER PRIMARY KEY CHECK (id = 1), + snapshot_services TEXT NOT NULL, + snapshot_probes TEXT NOT NULL, + snapshot_events TEXT NOT NULL DEFAULT '[]', + snapshot_incidents TEXT NOT NULL DEFAULT '[]', + snapshot_discovery TEXT NOT NULL DEFAULT '{}', + snapshot_health_evidence TEXT NOT NULL DEFAULT '{}', + demo_service_ids TEXT NOT NULL, + started_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS health_evidence ( + id TEXT PRIMARY KEY, + subject_type TEXT NOT NULL, + subject_id TEXT NOT NULL, + service_id TEXT, + provider TEXT NOT NULL, + provider_ref TEXT, + status TEXT NOT NULL, + scope TEXT NOT NULL, + confidence REAL NOT NULL, + observed_at TEXT NOT NULL, + valid_until TEXT NOT NULL, + received_at TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + active INTEGER NOT NULL DEFAULT 1, + superseded_by TEXT, + FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_health_evidence_subject + ON health_evidence(subject_type, subject_id, provider, active, observed_at DESC); + CREATE INDEX IF NOT EXISTS idx_health_evidence_service + ON health_evidence(service_id, active, observed_at DESC); + + CREATE TABLE IF NOT EXISTS health_evidence_service_links ( + evidence_id TEXT NOT NULL, + service_id TEXT NOT NULL, + PRIMARY KEY(evidence_id, service_id), + FOREIGN KEY(evidence_id) REFERENCES health_evidence(id) ON DELETE CASCADE, + FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_health_evidence_link_service + ON health_evidence_service_links(service_id, evidence_id); + + CREATE TABLE IF NOT EXISTS provider_status ( + provider TEXT PRIMARY KEY, + status TEXT NOT NULL, + observed_at TEXT NOT NULL, + valid_until TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}' + ); + """ + ) + self._migrate_columns(db) + self._backfill_health_evidence(db) + + @staticmethod + def _migrate_columns(db: sqlite3.Connection) -> None: + columns = {row["name"] for row in db.execute("PRAGMA table_info(services)").fetchall()} + additions = { + "updated_at": "TEXT", + "description": "TEXT", + "tags": "TEXT NOT NULL DEFAULT '[]'", + "group": "TEXT", + "environment": "TEXT", + "owner": "TEXT", + "enabled": "INTEGER NOT NULL DEFAULT 1", + "monitoring_enabled": "INTEGER NOT NULL DEFAULT 1", + "monitoring_state": "TEXT NOT NULL DEFAULT 'active_probe'", + "probe_source": "TEXT NOT NULL DEFAULT 'manual'", + "probe_reachability": "TEXT NOT NULL DEFAULT 'verified'", + "probe_confidence": "TEXT NOT NULL DEFAULT 'confirmed'", + "runtime_state": "TEXT", + "runtime_health": "TEXT", + "application": "TEXT", + "domain": "TEXT", + "component_role": "TEXT NOT NULL DEFAULT 'unknown'", + "application_locked": "INTEGER NOT NULL DEFAULT 0", + "domain_locked": "INTEGER NOT NULL DEFAULT 0", + "component_role_locked": "INTEGER NOT NULL DEFAULT 0", + "criticality": "TEXT NOT NULL DEFAULT 'medium'", + "category": "TEXT NOT NULL DEFAULT 'unknown'", + "visibility": "TEXT NOT NULL DEFAULT 'visible'", + "lifecycle": "TEXT NOT NULL DEFAULT 'active'", + "source_type": "TEXT", + "source_ref": "TEXT", + "first_seen_at": "TEXT", + "last_seen_at": "TEXT", + "last_discovery_status": "TEXT", + "container_image": "TEXT", + "compose_project": "TEXT", + "layout_x": "REAL", + "layout_y": "REAL", + "layout_pinned": "INTEGER NOT NULL DEFAULT 0", + "interval_seconds": "INTEGER", + "timeout_seconds": "REAL", + "retries": "INTEGER", + "failure_threshold": "INTEGER NOT NULL DEFAULT 1", + "recovery_threshold": "INTEGER NOT NULL DEFAULT 1", + "expected_status_min": "INTEGER", + "expected_status_max": "INTEGER", + "availability_target": "REAL", + "maintenance_enabled": "INTEGER NOT NULL DEFAULT 0", + "maintenance_reason": "TEXT", + "state": "TEXT NOT NULL DEFAULT 'unknown'", + "failure_streak": "INTEGER NOT NULL DEFAULT 0", + "recovery_streak": "INTEGER NOT NULL DEFAULT 0", + "flapping": "INTEGER NOT NULL DEFAULT 0", + "flap_count": "INTEGER NOT NULL DEFAULT 0", + "last_state_change_at": "TEXT", + } + for name, definition in additions.items(): + if name not in columns: + quoted = f'"{name}"' if name == "group" else name + db.execute(f"ALTER TABLE services ADD COLUMN {quoted} {definition}") + db.execute("UPDATE services SET updated_at = created_at WHERE updated_at IS NULL") + + probe_columns = {row["name"] for row in db.execute("PRAGMA table_info(probe_results)").fetchall()} + for name, definition in {"trigger": "TEXT NOT NULL DEFAULT 'manual'", "metadata": "TEXT NOT NULL DEFAULT '{}'"}.items(): + if name not in probe_columns: + db.execute(f"ALTER TABLE probe_results ADD COLUMN {name} {definition}") + + # v1.0.1 derived health directly from the latest probe. Backfill the + # durable v1.1 state column so an in-place migration does not make an + # existing healthy/down service appear unknown until the next cycle. + db.execute( + """ + UPDATE services + SET state = COALESCE( + (SELECT latest.status FROM probe_results latest + WHERE latest.service_id = services.id + ORDER BY latest.checked_at DESC, latest.rowid DESC LIMIT 1), + state + ), + last_state_change_at = COALESCE( + last_state_change_at, + (SELECT latest.checked_at FROM probe_results latest + WHERE latest.service_id = services.id + ORDER BY latest.checked_at DESC, latest.rowid DESC LIMIT 1) + ) + WHERE state = 'unknown' + AND EXISTS (SELECT 1 FROM probe_results existing WHERE existing.service_id = services.id) + """ + ) + + demo_columns = {row["name"] for row in db.execute("PRAGMA table_info(demo_sessions)").fetchall()} + for name in ("snapshot_events", "snapshot_incidents", "snapshot_discovery", "snapshot_health_evidence"): + if name not in demo_columns: + default = "'{}'" if name in {"snapshot_discovery", "snapshot_health_evidence"} else "'[]'" + db.execute(f"ALTER TABLE demo_sessions ADD COLUMN {name} TEXT NOT NULL DEFAULT {default}") + + @staticmethod + def _backfill_health_evidence(db: sqlite3.Connection) -> None: + """Preserve legitimate recent v1.5 observations during the v1.6 migration. + + This is a one-time projection of existing probe/runtime facts, not a + fabricated monitoring result. Historic observations stay expired and + visible in provenance; only their original interval makes them current. + AppOps rows are deliberately excluded because pre-v1.6 persisted an + application-scoped status as component runtime health. + """ + + if db.execute("SELECT 1 FROM health_evidence LIMIT 1").fetchone(): + return + latest = db.execute( + """ + SELECT p.* FROM probe_results p + WHERE p.rowid = ( + SELECT candidate.rowid FROM probe_results candidate + WHERE candidate.service_id = p.service_id + ORDER BY candidate.checked_at DESC, candidate.rowid DESC LIMIT 1 + ) + """ + ).fetchall() + intervals = { + row["id"]: max(60, int(row["interval_seconds"] or 60) * 2) + for row in db.execute("SELECT id, interval_seconds FROM services").fetchall() + } + for row in latest: + observed = datetime.fromisoformat(row["checked_at"]) + ttl = timedelta(seconds=intervals.get(row["service_id"], 120)) + evidence_id = f"migration-probe-{row['id']}" + db.execute( + """ + INSERT INTO health_evidence ( + id, subject_type, subject_id, service_id, provider, provider_ref, status, scope, + confidence, observed_at, valid_until, received_at, summary, metadata, active + ) VALUES (?, 'service', ?, ?, 'active_probe', ?, ?, 'component', 1.0, ?, ?, ?, ?, ?, 1) + """, + ( + evidence_id, + row["service_id"], + row["service_id"], + row["id"], + row["status"], + observed.isoformat(), + (observed + ttl).isoformat(), + observed.isoformat(), + "Migrated latest active probe result", + row["metadata"] or "{}", + ), + ) + db.execute( + "INSERT OR IGNORE INTO health_evidence_service_links (evidence_id, service_id) VALUES (?, ?)", + (evidence_id, row["service_id"]), + ) + + runtime_rows = db.execute( + """ + SELECT id, runtime_state, runtime_health, updated_at, monitoring_state + FROM services + WHERE COALESCE(monitoring_state, '') != 'appops_observation' + AND (runtime_state IS NOT NULL OR runtime_health IS NOT NULL) + """ + ).fetchall() + for row in runtime_rows: + observed = datetime.fromisoformat(row["updated_at"] or _now().isoformat()) + runtime_state = str(row["runtime_state"] or "").casefold() + runtime_health = str(row["runtime_health"] or "").casefold() + if runtime_health in {"unhealthy", "down"} or runtime_state in {"exited", "dead", "stopped"}: + status = HealthStatus.DOWN + elif runtime_health == "degraded" or runtime_state == "restarting": + status = HealthStatus.DEGRADED + elif runtime_health == "healthy": + status = HealthStatus.HEALTHY + else: + continue + evidence_id = f"migration-runtime-{row['id']}" + db.execute( + """ + INSERT INTO health_evidence ( + id, subject_type, subject_id, service_id, provider, provider_ref, status, scope, + confidence, observed_at, valid_until, received_at, summary, metadata, active + ) VALUES (?, 'service', ?, ?, 'docker', 'legacy-runtime', ?, 'component', 0.7, ?, ?, ?, ?, ?, 1) + """, + ( + evidence_id, + row["id"], + row["id"], + status.value, + observed.isoformat(), + (observed + timedelta(minutes=10)).isoformat(), + observed.isoformat(), + "Migrated Docker runtime observation", + json.dumps({"runtime_state": row["runtime_state"], "runtime_health": row["runtime_health"]}), + ), + ) + db.execute( + "INSERT OR IGNORE INTO health_evidence_service_links (evidence_id, service_id) VALUES (?, ?)", + (evidence_id, row["id"]), + ) + + @staticmethod + def _json_list(value: object, fallback: list[str] | None = None) -> list[str]: + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + try: + parsed = json.loads(value) + return [str(item) for item in parsed] if isinstance(parsed, list) else (fallback or []) + except json.JSONDecodeError: + return fallback or [] + return fallback or [] + + @staticmethod + def _json_object(value: object) -> dict[str, object]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + @classmethod + def _row_to_service(cls, row: sqlite3.Row | dict[str, Any]) -> Service: + created_at = datetime.fromisoformat(str(row["created_at"])) + raw_updated = row.get("updated_at") if isinstance(row, dict) else row["updated_at"] + raw_state = row.get("state", "unknown") if isinstance(row, dict) else row["state"] + try: + state = HealthStatus(raw_state or "unknown") + except ValueError: + state = HealthStatus.UNKNOWN + return Service( + id=row["id"], + name=row["name"], + kind=row["kind"], + url=row["url"], + host=row["host"], + port=row["port"], + depends_on=cls._json_list(row["depends_on"]), + description=row.get("description") if isinstance(row, dict) else row["description"], + tags=cls._json_list(row.get("tags", "[]") if isinstance(row, dict) else row["tags"]), + group=row.get("group") if isinstance(row, dict) else row["group"], + environment=row.get("environment") if isinstance(row, dict) else row["environment"], + owner=row.get("owner") if isinstance(row, dict) else row["owner"], + enabled=_bool(row.get("enabled", 1) if isinstance(row, dict) else row["enabled"], True), + monitoring_enabled=_bool( + row.get("monitoring_enabled", 1) if isinstance(row, dict) else row["monitoring_enabled"], + True, + ), + monitoring_state=(row.get("monitoring_state", "active_probe") if isinstance(row, dict) else row["monitoring_state"]) or "active_probe", + probe_source=(row.get("probe_source", "manual") if isinstance(row, dict) else row["probe_source"]) or "manual", + probe_reachability=(row.get("probe_reachability", "verified") if isinstance(row, dict) else row["probe_reachability"]) or "verified", + probe_confidence=(row.get("probe_confidence", "confirmed") if isinstance(row, dict) else row["probe_confidence"]) or "confirmed", + runtime_state=row.get("runtime_state") if isinstance(row, dict) else row["runtime_state"], + runtime_health=row.get("runtime_health") if isinstance(row, dict) else row["runtime_health"], + application=row.get("application") if isinstance(row, dict) else row["application"], + domain=row.get("domain") if isinstance(row, dict) else row["domain"], + component_role=(row.get("component_role", "unknown") if isinstance(row, dict) else row["component_role"]) or "unknown", + application_locked=_bool(row.get("application_locked", 0) if isinstance(row, dict) else row["application_locked"]), + domain_locked=_bool(row.get("domain_locked", 0) if isinstance(row, dict) else row["domain_locked"]), + component_role_locked=_bool(row.get("component_role_locked", 0) if isinstance(row, dict) else row["component_role_locked"]), + criticality=row.get("criticality", "medium") if isinstance(row, dict) else row["criticality"], + category=row.get("category", "unknown") if isinstance(row, dict) else row["category"], + visibility=row.get("visibility", "visible") if isinstance(row, dict) else row["visibility"], + lifecycle=row.get("lifecycle", "active") if isinstance(row, dict) else row["lifecycle"], + source_type=row.get("source_type") if isinstance(row, dict) else row["source_type"], + source_ref=row.get("source_ref") if isinstance(row, dict) else row["source_ref"], + first_seen_at=( + datetime.fromisoformat(row["first_seen_at"]) + if (row.get("first_seen_at") if isinstance(row, dict) else row["first_seen_at"]) + else None + ), + last_seen_at=( + datetime.fromisoformat(row["last_seen_at"]) + if (row.get("last_seen_at") if isinstance(row, dict) else row["last_seen_at"]) + else None + ), + last_discovery_status=( + row.get("last_discovery_status") + if isinstance(row, dict) + else row["last_discovery_status"] + ), + container_image=row.get("container_image") if isinstance(row, dict) else row["container_image"], + compose_project=row.get("compose_project") if isinstance(row, dict) else row["compose_project"], + layout_x=row.get("layout_x") if isinstance(row, dict) else row["layout_x"], + layout_y=row.get("layout_y") if isinstance(row, dict) else row["layout_y"], + layout_pinned=_bool( + row.get("layout_pinned", 0) if isinstance(row, dict) else row["layout_pinned"] + ), + interval_seconds=row.get("interval_seconds") if isinstance(row, dict) else row["interval_seconds"], + timeout_seconds=row.get("timeout_seconds") if isinstance(row, dict) else row["timeout_seconds"], + retries=row.get("retries") if isinstance(row, dict) else row["retries"], + failure_threshold=(row.get("failure_threshold", 1) if isinstance(row, dict) else row["failure_threshold"]) or 1, + recovery_threshold=(row.get("recovery_threshold", 1) if isinstance(row, dict) else row["recovery_threshold"]) or 1, + expected_status_min=row.get("expected_status_min") if isinstance(row, dict) else row["expected_status_min"], + expected_status_max=row.get("expected_status_max") if isinstance(row, dict) else row["expected_status_max"], + availability_target=row.get("availability_target") if isinstance(row, dict) else row["availability_target"], + maintenance_enabled=_bool(row.get("maintenance_enabled", 0) if isinstance(row, dict) else row["maintenance_enabled"]), + maintenance_reason=row.get("maintenance_reason") if isinstance(row, dict) else row["maintenance_reason"], + state=state, + failure_streak=(row.get("failure_streak", 0) if isinstance(row, dict) else row["failure_streak"]) or 0, + recovery_streak=(row.get("recovery_streak", 0) if isinstance(row, dict) else row["recovery_streak"]) or 0, + flapping=_bool(row.get("flapping", 0) if isinstance(row, dict) else row["flapping"]), + flap_count=(row.get("flap_count", 0) if isinstance(row, dict) else row["flap_count"]) or 0, + last_state_change_at=( + datetime.fromisoformat(row["last_state_change_at"]) + if (row.get("last_state_change_at") if isinstance(row, dict) else row["last_state_change_at"]) + else None + ), + created_at=created_at, + updated_at=datetime.fromisoformat(str(raw_updated)) if raw_updated else created_at, + ) + + @classmethod + def _row_to_probe(cls, row: sqlite3.Row | dict[str, Any]) -> ProbeResult: + return ProbeResult( + id=row["id"], + service_id=row["service_id"], + status=HealthStatus(row["status"]), + latency_ms=row["latency_ms"], + detail=row["detail"], + checked_at=datetime.fromisoformat(row["checked_at"]), + trigger=(row.get("trigger", "manual") if isinstance(row, dict) else row["trigger"]) or "manual", + metadata=cls._json_object(row.get("metadata", "{}") if isinstance(row, dict) else row["metadata"]), + ) + + @classmethod + def _row_to_event(cls, row: sqlite3.Row | dict[str, Any]) -> EventRecord: + raw_from = row["from_status"] + raw_to = row["to_status"] + return EventRecord( + id=row["id"], + event_type=row["event_type"], + service_id=row["service_id"], + incident_id=row["incident_id"], + from_status=HealthStatus(raw_from) if raw_from else None, + to_status=HealthStatus(raw_to) if raw_to else None, + message=row["message"], + metadata=cls._json_object(row["metadata"]), + created_at=datetime.fromisoformat(row["created_at"]), + ) + + @classmethod + def _row_to_incident(cls, row: sqlite3.Row | dict[str, Any], services: dict[str, Service] | None = None) -> IncidentRecord: + services = services or {} + root = services.get(row["root_service_id"]) + affected_ids = cls._json_list(row["affected_service_ids"]) + return IncidentRecord( + id=row["id"], + status=IncidentStatus(row["status"]), + root_service_id=row["root_service_id"], + root_service_name=root.name if root else row["root_service_id"], + started_at=datetime.fromisoformat(row["started_at"]), + detected_at=datetime.fromisoformat(row["detected_at"]), + acknowledged_at=datetime.fromisoformat(row["acknowledged_at"]) if row["acknowledged_at"] else None, + resolved_at=datetime.fromisoformat(row["resolved_at"]) if row["resolved_at"] else None, + title=row["title"], + root_cause_reason=row["root_cause_reason"], + affected_service_ids=affected_ids, + affected_service_names=[services[item].name for item in affected_ids if item in services], + severity=row["severity"], + latest_update_at=datetime.fromisoformat(row["latest_update_at"]), + ) + + @staticmethod + def _service_params(service: Service) -> dict[str, Any]: + return { + "id": service.id, + "name": service.name, + "kind": service.kind.value, + "url": service.url, + "host": service.host, + "port": service.port, + "depends_on": json.dumps(service.depends_on), + "description": service.description, + "tags": json.dumps(service.tags), + "group": service.group, + "environment": service.environment, + "owner": service.owner, + "enabled": int(service.enabled), + "monitoring_enabled": int(service.monitoring_enabled), + "monitoring_state": service.monitoring_state.value, + "probe_source": service.probe_source.value, + "probe_reachability": service.probe_reachability.value, + "probe_confidence": service.probe_confidence.value, + "runtime_state": service.runtime_state, + "runtime_health": service.runtime_health, + "application": service.application, + "domain": service.domain, + "component_role": service.component_role.value, + "application_locked": int(service.application_locked), + "domain_locked": int(service.domain_locked), + "component_role_locked": int(service.component_role_locked), + "criticality": service.criticality.value, + "category": service.category.value, + "visibility": service.visibility.value, + "lifecycle": service.lifecycle.value, + "source_type": service.source_type, + "source_ref": service.source_ref, + "first_seen_at": _iso(service.first_seen_at), + "last_seen_at": _iso(service.last_seen_at), + "last_discovery_status": service.last_discovery_status, + "container_image": service.container_image, + "compose_project": service.compose_project, + "layout_x": service.layout_x, + "layout_y": service.layout_y, + "layout_pinned": int(service.layout_pinned), + "interval_seconds": service.interval_seconds, + "timeout_seconds": service.timeout_seconds, + "retries": service.retries, + "failure_threshold": service.failure_threshold, + "recovery_threshold": service.recovery_threshold, + "expected_status_min": service.expected_status_min, + "expected_status_max": service.expected_status_max, + "availability_target": service.availability_target, + "maintenance_enabled": int(service.maintenance_enabled), + "maintenance_reason": service.maintenance_reason, + "state": service.state.value, + "failure_streak": service.failure_streak, + "recovery_streak": service.recovery_streak, + "flapping": int(service.flapping), + "flap_count": service.flap_count, + "last_state_change_at": _iso(service.last_state_change_at), + "created_at": service.created_at.isoformat(), + "updated_at": service.updated_at.isoformat(), + } + + @classmethod + def _insert_service(cls, db: sqlite3.Connection, service: Service) -> None: + params = cls._service_params(service) + db.execute( + f"INSERT INTO services ({SERVICE_COLUMNS}) VALUES (" + ":id, :name, :kind, :url, :host, :port, :depends_on, :description, :tags, :group, " + ":environment, :owner, :enabled, :monitoring_enabled, :monitoring_state, :probe_source, :probe_reachability, :probe_confidence, :runtime_state, :runtime_health, :application, :domain, :component_role, :application_locked, :domain_locked, :component_role_locked, :criticality, :category, :visibility, :lifecycle, " + ":source_type, :source_ref, :first_seen_at, :last_seen_at, :last_discovery_status, :container_image, " + ":compose_project, :layout_x, :layout_y, :layout_pinned, " + ":interval_seconds, :timeout_seconds, :retries, :failure_threshold, :recovery_threshold, " + ":expected_status_min, :expected_status_max, :availability_target, :maintenance_enabled, " + ":maintenance_reason, :state, :failure_streak, :recovery_streak, :flapping, :flap_count, " + ":last_state_change_at, :created_at, :updated_at)", + params, + ) + + @classmethod + def _upsert_service(cls, db: sqlite3.Connection, service: Service) -> None: + params = cls._service_params(service) + db.execute( + f"INSERT INTO services ({SERVICE_COLUMNS}) VALUES (" + ":id, :name, :kind, :url, :host, :port, :depends_on, :description, :tags, :group, " + ":environment, :owner, :enabled, :monitoring_enabled, :monitoring_state, :probe_source, :probe_reachability, :probe_confidence, :runtime_state, :runtime_health, :application, :domain, :component_role, :application_locked, :domain_locked, :component_role_locked, :criticality, :category, :visibility, :lifecycle, " + ":source_type, :source_ref, :first_seen_at, :last_seen_at, :last_discovery_status, :container_image, " + ":compose_project, :layout_x, :layout_y, :layout_pinned, " + ":interval_seconds, :timeout_seconds, :retries, :failure_threshold, :recovery_threshold, " + ":expected_status_min, :expected_status_max, :availability_target, :maintenance_enabled, " + ":maintenance_reason, :state, :failure_streak, :recovery_streak, :flapping, :flap_count, " + ":last_state_change_at, :created_at, :updated_at) " + "ON CONFLICT(id) DO UPDATE SET " + "name=excluded.name, kind=excluded.kind, url=excluded.url, host=excluded.host, port=excluded.port, " + "depends_on=excluded.depends_on, description=excluded.description, tags=excluded.tags, " + '"group"=excluded."group", environment=excluded.environment, owner=excluded.owner, ' + "enabled=excluded.enabled, monitoring_enabled=excluded.monitoring_enabled, monitoring_state=excluded.monitoring_state, " + "probe_source=excluded.probe_source, probe_reachability=excluded.probe_reachability, probe_confidence=excluded.probe_confidence, " + "runtime_state=excluded.runtime_state, runtime_health=excluded.runtime_health, application=excluded.application, domain=excluded.domain, component_role=excluded.component_role, " + "application_locked=excluded.application_locked, domain_locked=excluded.domain_locked, component_role_locked=excluded.component_role_locked, criticality=excluded.criticality, " + "category=excluded.category, visibility=excluded.visibility, lifecycle=excluded.lifecycle, " + "source_type=excluded.source_type, source_ref=excluded.source_ref, first_seen_at=excluded.first_seen_at, " + "last_seen_at=excluded.last_seen_at, last_discovery_status=excluded.last_discovery_status, " + "container_image=excluded.container_image, compose_project=excluded.compose_project, " + "layout_x=excluded.layout_x, layout_y=excluded.layout_y, layout_pinned=excluded.layout_pinned, " + "interval_seconds=excluded.interval_seconds, " + "timeout_seconds=excluded.timeout_seconds, retries=excluded.retries, failure_threshold=excluded.failure_threshold, " + "recovery_threshold=excluded.recovery_threshold, expected_status_min=excluded.expected_status_min, " + "expected_status_max=excluded.expected_status_max, availability_target=excluded.availability_target, " + "maintenance_enabled=excluded.maintenance_enabled, maintenance_reason=excluded.maintenance_reason, " + "state=excluded.state, failure_streak=excluded.failure_streak, recovery_streak=excluded.recovery_streak, " + "flapping=excluded.flapping, flap_count=excluded.flap_count, last_state_change_at=excluded.last_state_change_at, " + "created_at=excluded.created_at, updated_at=excluded.updated_at", + params, + ) + + def list_services(self) -> list[Service]: + with self._connect() as db: + rows = db.execute(f"SELECT {SERVICE_COLUMNS} FROM services ORDER BY name COLLATE NOCASE").fetchall() + return [self._row_to_service(row) for row in rows] + + def get_service(self, service_id: str) -> Service | None: + with self._connect() as db: + row = db.execute(f"SELECT {SERVICE_COLUMNS} FROM services WHERE id = ?", (service_id,)).fetchone() + return self._row_to_service(row) if row else None + + def get_service_by_name(self, name: str) -> Service | None: + with self._connect() as db: + row = db.execute( + f"SELECT {SERVICE_COLUMNS} FROM services WHERE name = ? COLLATE NOCASE", (name,) + ).fetchone() + return self._row_to_service(row) if row else None + + def create_service(self, payload: ServiceCreate) -> Service: + now = _now() + service = Service(id=str(uuid4()), created_at=now, updated_at=now, **payload.model_dump()) + with self._connect() as db: + self._insert_service(db, service) + self._insert_event_db(db, "topology.changed", None, None, None, None, f"Service {service.name} created", {}) + event_bus.publish({"event_type": "topology.changed", "message": f"Service {service.name} created"}) + return service + + def update_service(self, service_id: str, payload: ServiceUpdate) -> Service | None: + existing = self.get_service(service_id) + if existing is None: + return None + updated = Service( + id=service_id, + created_at=existing.created_at, + updated_at=_now(), + state=existing.state, + failure_streak=existing.failure_streak, + recovery_streak=existing.recovery_streak, + flapping=existing.flapping, + flap_count=existing.flap_count, + last_state_change_at=existing.last_state_change_at, + **payload.model_dump(), + ) + with self._connect() as db: + self._upsert_service(db, updated) + self._insert_event_db(db, "topology.changed", service_id, None, None, None, f"Service {updated.name} updated", {}) + event_bus.publish({"event_type": "topology.changed", "service_id": service_id, "message": "Service updated"}) + return updated + + def delete_service(self, service_id: str) -> bool: + with self._connect() as db: + row = db.execute("SELECT name FROM services WHERE id = ?", (service_id,)).fetchone() + if row is None: + return False + db.execute("DELETE FROM services WHERE id = ?", (service_id,)) + rows = db.execute("SELECT id, depends_on FROM services").fetchall() + for item in rows: + dependencies = self._json_list(item["depends_on"]) + if service_id in dependencies: + db.execute( + "UPDATE services SET depends_on = ?, updated_at = ? WHERE id = ?", + (json.dumps([value for value in dependencies if value != service_id]), _now().isoformat(), item["id"]), + ) + db.execute("UPDATE incidents SET affected_service_ids = replace(affected_service_ids, ?, '') WHERE status != 'resolved'", (service_id,)) + self._insert_event_db(db, "topology.changed", service_id, None, None, None, f"Service {row['name']} deleted", {}) + event_bus.publish({"event_type": "topology.changed", "service_id": service_id, "message": "Service deleted"}) + return True + + def import_config_services(self, definitions: list[ConfigService], mode: str) -> ConfigImportResult: + if mode not in {"merge", "replace"}: + raise ValueError("Import mode must be merge or replace") + + with self._connect() as db: + db.execute("BEGIN IMMEDIATE") + rows = db.execute(f"SELECT {SERVICE_COLUMNS} FROM services").fetchall() + existing = {row["name"].casefold(): self._row_to_service(row) for row in rows} + if mode == "replace": + db.execute("DELETE FROM incidents") + db.execute("DELETE FROM events") + db.execute("DELETE FROM services") + existing = {} + + ids_by_name = {name: service.id for name, service in existing.items()} + for definition in definitions: + ids_by_name.setdefault(definition.name.casefold(), str(uuid4())) + for definition in definitions: + missing = [item for item in definition.depends_on if item.casefold() not in ids_by_name] + if missing: + raise ValueError( + f"Service {definition.name} references unknown dependencies: {', '.join(missing)}" + ) + + created = 0 + updated = 0 + now = _now() + for definition in definitions: + key = definition.name.casefold() + current = existing.get(key) + payload = definition.model_dump() + payload["depends_on"] = [ids_by_name[item.casefold()] for item in definition.depends_on] + if current is None: + service = Service(id=ids_by_name[key], created_at=now, updated_at=now, **payload) + created += 1 + else: + service = Service( + id=current.id, + created_at=current.created_at, + updated_at=now, + state=current.state, + failure_streak=current.failure_streak, + recovery_streak=current.recovery_streak, + flapping=current.flapping, + flap_count=current.flap_count, + last_state_change_at=current.last_state_change_at, + **payload, + ) + updated += 1 + if current is None: + self._insert_service(db, service) + else: + self._upsert_service(db, service) + self._insert_event_db(db, "topology.changed", None, None, None, None, "Configuration imported", {"mode": mode}) + event_bus.publish({"event_type": "topology.changed", "message": "Configuration imported"}) + return ConfigImportResult(created=created, updated=updated, total=len(definitions)) + + def replace_all_services(self, definitions: list[ServiceCreate]) -> list[Service]: + with self._connect() as db: + db.execute("DELETE FROM services") + return [self.create_service(item) for item in definitions] + + @staticmethod + def _row_to_health_evidence(row: sqlite3.Row) -> HealthEvidence: + return with_freshness( + HealthEvidence( + id=row["id"], + subject_type=row["subject_type"], + subject_id=row["subject_id"], + service_id=row["service_id"], + provider=row["provider"], + provider_ref=row["provider_ref"], + status=row["status"], + scope=row["scope"], + confidence=float(row["confidence"]), + observed_at=datetime.fromisoformat(row["observed_at"]), + valid_until=datetime.fromisoformat(row["valid_until"]), + received_at=datetime.fromisoformat(row["received_at"]), + summary=row["summary"] or "", + metadata=Store._json_object(row["metadata"]), + active=_bool(row["active"], True), + superseded_by=row["superseded_by"], + ) + ) + + def record_health_evidence( + self, + evidence: HealthEvidence, + *, + service_ids: list[str] | None = None, + ) -> HealthEvidence: + """Store a current provider claim and retain superseded provenance. + + Repeated discovery cycles renew the same active claim instead of + growing unbounded identical rows. A changed claim deactivates only + the prior claim from that provider/subject; other providers remain + independently auditable and can surface a conflict. + """ + + links = sorted({*(service_ids or []), *({evidence.service_id} if evidence.service_id else set())}) + with self._connect() as db: + prior = db.execute( + """ + SELECT * FROM health_evidence + WHERE subject_type = ? AND subject_id = ? AND provider = ? + AND COALESCE(provider_ref, '') = COALESCE(?, '') AND scope = ? AND active = 1 + ORDER BY observed_at DESC, rowid DESC LIMIT 1 + """, + ( + evidence.subject_type, + evidence.subject_id, + evidence.provider.value, + evidence.provider_ref, + evidence.scope.value, + ), + ).fetchone() + saved = evidence + materially_changed = prior is None or any( + ( + prior[field] != value + for field, value in ( + ("status", evidence.status.value), + ("confidence", evidence.confidence), + ("summary", evidence.summary), + ("metadata", json.dumps(evidence.metadata, sort_keys=True)), + ) + ) + ) + if prior is not None and prior["status"] == evidence.status.value: + saved = evidence.model_copy(update={"id": prior["id"], "active": True, "superseded_by": None}) + db.execute( + """ + UPDATE health_evidence SET service_id = ?, confidence = ?, observed_at = ?, valid_until = ?, + received_at = ?, summary = ?, metadata = ?, active = 1, superseded_by = NULL + WHERE id = ? + """, + ( + saved.service_id, + saved.confidence, + saved.observed_at.isoformat(), + saved.valid_until.isoformat(), + saved.received_at.isoformat(), + saved.summary, + json.dumps(saved.metadata, sort_keys=True), + saved.id, + ), + ) + else: + if prior is not None: + db.execute("UPDATE health_evidence SET active = 0, superseded_by = ? WHERE id = ?", (evidence.id, prior["id"])) + db.execute( + """ + INSERT INTO health_evidence ( + id, subject_type, subject_id, service_id, provider, provider_ref, status, scope, + confidence, observed_at, valid_until, received_at, summary, metadata, active, superseded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + saved.id, + saved.subject_type, + saved.subject_id, + saved.service_id, + saved.provider.value, + saved.provider_ref, + saved.status.value, + saved.scope.value, + saved.confidence, + saved.observed_at.isoformat(), + saved.valid_until.isoformat(), + saved.received_at.isoformat(), + saved.summary, + json.dumps(saved.metadata, sort_keys=True), + int(saved.active), + saved.superseded_by, + ), + ) + for service_id in links: + db.execute( + "INSERT OR IGNORE INTO health_evidence_service_links (evidence_id, service_id) VALUES (?, ?)", + (saved.id, service_id), + ) + if materially_changed: + event_bus.publish( + { + "event_type": "health.evidence_changed", + "service_id": evidence.service_id, + "provider": evidence.provider.value, + "status": evidence.status.value, + "message": "Health evidence changed", + } + ) + return saved + + def health_evidence_for_service(self, service_id: str, *, limit: int = 100) -> list[HealthEvidence]: + with self._connect() as db: + rows = db.execute( + """ + SELECT DISTINCT e.* FROM health_evidence e + LEFT JOIN health_evidence_service_links link ON link.evidence_id = e.id + WHERE e.service_id = ? OR link.service_id = ? + ORDER BY e.observed_at DESC, e.rowid DESC LIMIT ? + """, + (service_id, service_id, limit), + ).fetchall() + return [self._row_to_health_evidence(row) for row in rows] + + def health_evidence_for_services( + self, service_ids: list[str], *, limit_per_service: int = 100 + ) -> dict[str, list[HealthEvidence]]: + """Fetch linked evidence for a topology in one bounded query. + + A semantic topology can contain many applications. Resolving each + application with a separate evidence query creates an avoidable N+1 + SQLite scan, so this method projects the evidence links once and then + groups them in memory. + """ + + ids = sorted(set(service_ids)) + result = {service_id: [] for service_id in ids} + if not ids: + return result + placeholders = ", ".join("?" for _ in ids) + with self._connect() as db: + rows = db.execute( + f""" + SELECT e.*, e.service_id AS direct_service_id, link.service_id AS linked_service_id + FROM health_evidence e + LEFT JOIN health_evidence_service_links link ON link.evidence_id = e.id + WHERE e.service_id IN ({placeholders}) OR link.service_id IN ({placeholders}) + ORDER BY e.observed_at DESC, e.rowid DESC + """, + [*ids, *ids], + ).fetchall() + seen: dict[str, set[str]] = {service_id: set() for service_id in ids} + for row in rows: + evidence = self._row_to_health_evidence(row) + for service_id in {row["direct_service_id"], row["linked_service_id"]}: + if ( + service_id in result + and evidence.id not in seen[service_id] + and len(result[service_id]) < limit_per_service + ): + result[service_id].append(evidence) + seen[service_id].add(evidence.id) + return result + + def application_evidence_links(self, provider: EvidenceProvider) -> dict[str, set[str]]: + """Return persisted application-claim links keyed by provider reference.""" + + with self._connect() as db: + rows = db.execute( + """ + SELECT e.provider_ref, e.subject_id, link.service_id + FROM health_evidence e + JOIN health_evidence_service_links link ON link.evidence_id = e.id + WHERE e.provider = ? AND e.scope = ? + """, + (provider.value, EvidenceScope.APPLICATION.value), + ).fetchall() + result: dict[str, set[str]] = {} + for row in rows: + key = row["provider_ref"] or row["subject_id"] + if key: + result.setdefault(key, set()).add(row["service_id"]) + return result + + def health_resolution(self, service_id: str) -> HealthResolution: + return resolve_health(self.health_evidence_for_service(service_id)) + + def set_provider_status(self, value: ProviderStatus) -> ProviderStatus: + with self._connect() as db: + previous = db.execute( + "SELECT status, detail, metadata FROM provider_status WHERE provider = ?", + (value.provider.value,), + ).fetchone() + db.execute( + """ + INSERT INTO provider_status (provider, status, observed_at, valid_until, detail, metadata) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(provider) DO UPDATE SET status=excluded.status, observed_at=excluded.observed_at, + valid_until=excluded.valid_until, detail=excluded.detail, metadata=excluded.metadata + """, + ( + value.provider.value, + value.status.value, + value.observed_at.isoformat(), + value.valid_until.isoformat(), + value.detail, + json.dumps(value.metadata, sort_keys=True), + ), + ) + if previous is None or any( + ( + previous[field] != expected + for field, expected in ( + ("status", value.status.value), + ("detail", value.detail), + ("metadata", json.dumps(value.metadata, sort_keys=True)), + ) + ) + ): + event_bus.publish( + { + "event_type": "provider.status_changed", + "provider": value.provider.value, + "status": value.status.value, + "message": "Provider availability changed", + } + ) + return value + + def provider_statuses(self) -> list[ProviderStatus]: + with self._connect() as db: + rows = db.execute("SELECT * FROM provider_status ORDER BY provider").fetchall() + return [ + ProviderStatus( + provider=row["provider"], + status=row["status"], + observed_at=datetime.fromisoformat(row["observed_at"]), + valid_until=datetime.fromisoformat(row["valid_until"]), + detail=row["detail"] or "", + metadata=self._json_object(row["metadata"]), + ) + for row in rows + ] + + @staticmethod + def _legacy_health_from_row(row: sqlite3.Row) -> HealthStatus: + """Compatibility fallback for pre-v1.6 data without component evidence.""" + + runtime_state = str(row["runtime_state"] or "").casefold() + runtime_health = str(row["runtime_health"] or "").casefold() + if runtime_health == "unhealthy" or runtime_state in {"exited", "dead", "stopped"}: + return HealthStatus.DOWN + if runtime_state == "restarting": + return HealthStatus.DEGRADED + if not _bool(row["monitoring_enabled"], True): + if runtime_health == "healthy": + return HealthStatus.HEALTHY + if str(row["monitoring_state"] or "").casefold() in {"unmonitored", "observed"}: + return HealthStatus.UNMONITORED + return HealthStatus.UNKNOWN + try: + return HealthStatus(row["state"] or "unknown") + except ValueError: + return HealthStatus.UNKNOWN + + def service_health(self, service_id: str) -> HealthStatus: + with self._connect() as db: + row = db.execute( + "SELECT state, maintenance_enabled, enabled, monitoring_enabled, monitoring_state, runtime_state, runtime_health " + "FROM services WHERE id = ?", + (service_id,), + ).fetchone() + if row is None or not _bool(row["enabled"], True): + return HealthStatus.UNKNOWN + if _bool(row["maintenance_enabled"]): + return HealthStatus.MAINTENANCE + resolution = self.health_resolution(service_id) + if resolution.selected_evidence_id: + return resolution.status + # Once v1.6 evidence exists, an expired/stale claim must not silently + # keep a historical state alive. Legacy fallback is only for rows + # that predate evidence entirely. Old AppOps-only rows were always + # aggregate application observations, never component health. + if resolution.evidence: + return HealthStatus.UNKNOWN + if str(row["monitoring_state"] or "").casefold() == MonitoringState.APPOPS.value: + return HealthStatus.UNMONITORED + return self._legacy_health_from_row(row) + + def service_healths( + self, + service_ids: list[str], + *, + evidence_by_service: dict[str, list[HealthEvidence]] | None = None, + ) -> tuple[dict[str, HealthStatus], dict[str, HealthResolution]]: + """Resolve direct health for a service set without per-service SQL reads.""" + + ids = sorted(set(service_ids)) + if not ids: + return {}, {} + evidence_by_service = evidence_by_service or self.health_evidence_for_services(ids) + placeholders = ", ".join("?" for _ in ids) + with self._connect() as db: + rows = db.execute( + f""" + SELECT id, state, maintenance_enabled, enabled, monitoring_enabled, + monitoring_state, runtime_state, runtime_health + FROM services WHERE id IN ({placeholders}) + """, + ids, + ).fetchall() + status_by_id: dict[str, HealthStatus] = {} + resolutions: dict[str, HealthResolution] = {} + for row in rows: + service_id = row["id"] + resolution = resolve_health(evidence_by_service.get(service_id, [])) + resolutions[service_id] = resolution + if not _bool(row["enabled"], True): + status_by_id[service_id] = HealthStatus.UNKNOWN + elif _bool(row["maintenance_enabled"]): + status_by_id[service_id] = HealthStatus.MAINTENANCE + elif resolution.selected_evidence_id: + status_by_id[service_id] = resolution.status + elif resolution.evidence: + status_by_id[service_id] = HealthStatus.UNKNOWN + elif str(row["monitoring_state"] or "").casefold() == MonitoringState.APPOPS.value: + status_by_id[service_id] = HealthStatus.UNMONITORED + else: + status_by_id[service_id] = self._legacy_health_from_row(row) + return status_by_id, resolutions + + def set_maintenance(self, service_id: str, enabled: bool, reason: str | None = None) -> Service | None: + service = self.get_service(service_id) + if service is None: + return None + updated = service.model_copy( + update={ + "maintenance_enabled": enabled, + "maintenance_reason": reason if enabled else None, + "state": HealthStatus.MAINTENANCE if enabled else HealthStatus.UNKNOWN, + "failure_streak": 0, + "recovery_streak": 0, + "updated_at": _now(), + } + ) + with self._connect() as db: + self._upsert_service(db, updated) + self._insert_event_db( + db, + "service.maintenance_changed", + service_id, + None, + service.state.value, + HealthStatus.MAINTENANCE.value if enabled else HealthStatus.UNKNOWN.value, + f"Maintenance {'enabled' if enabled else 'disabled'} for {service.name}", + {"reason": reason} if reason else {}, + ) + event_bus.publish( + { + "event_type": "service.maintenance_changed", + "service_id": service_id, + "status": HealthStatus.MAINTENANCE.value if enabled else HealthStatus.UNKNOWN.value, + "message": f"Maintenance {'enabled' if enabled else 'disabled'}", + } + ) + from .incidents import sync_incidents + + sync_incidents(self) + return updated + + def _stable_state(self, row: sqlite3.Row, raw_status: HealthStatus) -> tuple[HealthStatus, int, int]: + if not _bool(row["enabled"], True) or not _bool(row["monitoring_enabled"], True): + monitoring_state = str(row["monitoring_state"] or "").casefold() + return (HealthStatus.UNMONITORED if monitoring_state in {"unmonitored", "observed"} else HealthStatus.UNKNOWN), 0, 0 + if _bool(row["maintenance_enabled"]): + return HealthStatus.MAINTENANCE, 0, 0 + previous = HealthStatus(row["state"] or "unknown") + failure_streak = int(row["failure_streak"] or 0) + recovery_streak = int(row["recovery_streak"] or 0) + failure_threshold = max(1, int(row["failure_threshold"] or 1)) + recovery_threshold = max(1, int(row["recovery_threshold"] or 1)) + if raw_status in {HealthStatus.DOWN, HealthStatus.DEGRADED}: + failure_streak += 1 + recovery_streak = 0 + if failure_streak < failure_threshold: + if previous == HealthStatus.UNKNOWN: + return HealthStatus.UNKNOWN, failure_streak, recovery_streak + if previous == HealthStatus.RECOVERING: + return HealthStatus.RECOVERING, failure_streak, recovery_streak + return previous, failure_streak, recovery_streak + return raw_status, failure_streak, recovery_streak + if raw_status == HealthStatus.HEALTHY: + recovery_streak += 1 + failure_streak = 0 + if previous in {HealthStatus.DOWN, HealthStatus.DEGRADED, HealthStatus.RECOVERING} and recovery_streak < recovery_threshold: + return HealthStatus.RECOVERING, failure_streak, recovery_streak + return HealthStatus.HEALTHY, failure_streak, recovery_streak + return HealthStatus.UNMONITORED if raw_status == HealthStatus.UNMONITORED else HealthStatus.UNKNOWN, 0, 0 + + def set_runtime_observation( + self, + service_id: str, + *, + runtime_state: str | None, + runtime_health: str | None, + monitoring_state: MonitoringState | str, + provider: EvidenceProvider = EvidenceProvider.DOCKER, + provider_ref: str | None = None, + confidence: float = 0.9, + ) -> Service | None: + """Persist Docker runtime truth without fabricating a network probe.""" + + service = self.get_service(service_id) + if service is None: + return None + state_kind = MonitoringState(monitoring_state) + normalized_state = (runtime_state or "").casefold() + normalized_health = (runtime_health or "").casefold() + if service.maintenance_enabled: + status = HealthStatus.MAINTENANCE + elif normalized_health in {"unhealthy", "down"} or normalized_state in {"exited", "dead", "stopped"}: + status = HealthStatus.DOWN + elif normalized_health == "degraded" or normalized_state == "restarting": + status = HealthStatus.DEGRADED + elif normalized_health == "healthy": + status = HealthStatus.HEALTHY + elif state_kind in { + MonitoringState.UNMONITORED, + MonitoringState.OBSERVED, + MonitoringState.APPOPS, + }: + status = HealthStatus.UNMONITORED + else: + status = service.state + updated = service.model_copy( + update={ + "runtime_state": runtime_state, + "runtime_health": runtime_health, + "monitoring_state": state_kind, + "state": status, + "failure_streak": 0 if status in {HealthStatus.UNMONITORED, HealthStatus.HEALTHY} else service.failure_streak, + "recovery_streak": 0 if status == HealthStatus.UNMONITORED else service.recovery_streak, + "last_state_change_at": _now() if service.state != status else service.last_state_change_at, + "updated_at": _now(), + } + ) + with self._connect() as db: + self._upsert_service(db, updated) + if service.state != status: + self._insert_event_db( + db, + "service.runtime_observed", + service_id, + None, + service.state.value, + status.value, + f"Docker runtime observation updated for {service.name}", + {"runtime_state": runtime_state, "runtime_health": runtime_health, "monitoring_state": state_kind.value}, + ) + if service.state != status: + event_bus.publish({"event_type": "service.runtime_observed", "service_id": service_id, "status": status.value, "message": "Docker runtime observation updated"}) + observed_at = _now() + self.record_health_evidence( + HealthEvidence( + id=str(uuid4()), + subject_type="service", + subject_id=service_id, + service_id=service_id, + provider=provider, + provider_ref=provider_ref or service.source_ref, + status=status, + scope=EvidenceScope.COMPONENT, + confidence=confidence, + observed_at=observed_at, + valid_until=observed_at + timedelta(seconds=max(60, int(service.interval_seconds or 60) * 2)), + received_at=observed_at, + summary="Docker runtime observation" if provider == EvidenceProvider.DOCKER else "Runtime observation", + metadata={"runtime_state": runtime_state, "runtime_health": runtime_health}, + ) + ) + return updated + + def save_probe(self, probe: ProbeResult) -> ProbeResult: + events: list[dict[str, Any]] = [] + with self._connect() as db: + row = db.execute("SELECT * FROM services WHERE id = ?", (probe.service_id,)).fetchone() + if row is None: + return probe + db.execute( + """ + INSERT INTO probe_results (id, service_id, status, latency_ms, detail, checked_at, trigger, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + probe.id, + probe.service_id, + probe.status.value, + probe.latency_ms, + probe.detail, + probe.checked_at.isoformat(), + probe.trigger, + json.dumps(probe.metadata), + ), + ) + previous = HealthStatus(row["state"] or "unknown") + next_state, failure_streak, recovery_streak = self._stable_state(row, probe.status) + now = probe.checked_at + state_changed = next_state != previous + last_state_change = now.isoformat() if state_changed else row["last_state_change_at"] + if state_changed: + event = { + "event_type": "service.state_changed", + "service_id": probe.service_id, + "from_status": previous.value, + "to_status": next_state.value, + "message": f"Service changed from {previous.value} to {next_state.value}", + "metadata": {"trigger": probe.trigger, "probe_id": probe.id}, + } + self._insert_event_db( + db, + event["event_type"], + probe.service_id, + None, + previous.value, + next_state.value, + event["message"], + event["metadata"], + created_at=now, + ) + events.append(event) + since = (now - timedelta(minutes=30)).isoformat() + flap_count = int( + db.execute( + "SELECT COUNT(*) FROM events WHERE service_id = ? AND event_type = 'service.state_changed' AND created_at >= ?", + (probe.service_id, since), + ).fetchone()[0] + ) + flapping = flap_count >= int(os.getenv("SERVICEMAP_FLAP_THRESHOLD", "6")) + if flapping and not _bool(row["flapping"]): + self._insert_event_db( + db, + "flapping.detected", + probe.service_id, + None, + None, + None, + f"Flapping detected: {flap_count} state changes in 30 minutes", + {"state_changes": flap_count, "window_minutes": 30}, + created_at=now, + ) + events.append({"event_type": "flapping.detected", "service_id": probe.service_id, "message": "Flapping detected"}) + db.execute( + """ + UPDATE services + SET state = ?, failure_streak = ?, recovery_streak = ?, flapping = ?, flap_count = ?, + last_state_change_at = ?, updated_at = ? + WHERE id = ? + """, + ( + next_state.value, + failure_streak, + recovery_streak, + int(flapping), + flap_count, + last_state_change, + now.isoformat(), + probe.service_id, + ), + ) + event_bus.publish( + { + "event_type": "probe.completed", + "service_id": probe.service_id, + "status": probe.status.value, + "trigger": probe.trigger, + "message": "Probe completed", + } + ) + for event in events: + event_bus.publish(event) + self.record_health_evidence( + HealthEvidence( + id=f"probe-{probe.id}", + subject_type="service", + subject_id=probe.service_id, + service_id=probe.service_id, + provider=EvidenceProvider.PROBE, + provider_ref="active-probe", + # The evidence mirrors the hysteresis-filtered service state, + # while the raw probe result remains intact in history. + status=next_state, + scope=EvidenceScope.COMPONENT, + confidence=1.0, + observed_at=probe.checked_at, + valid_until=probe.checked_at + timedelta(seconds=max(60, int(os.getenv("SERVICEMAP_PROBE_INTERVAL", "60")) * 2)), + received_at=_now(), + summary=probe.detail, + metadata={"probe_id": probe.id, "trigger": probe.trigger, "raw_status": probe.status.value, "latency_ms": probe.latency_ms, **probe.metadata}, + ) + ) + from .incidents import sync_incidents + + sync_incidents(self) + return probe + + def latest_probe(self, service_id: str) -> ProbeResult | None: + with self._connect() as db: + row = db.execute( + "SELECT * FROM probe_results WHERE service_id = ? ORDER BY checked_at DESC, rowid DESC LIMIT 1", + (service_id,), + ).fetchone() + return self._row_to_probe(row) if row else None + + def latest_probes(self, service_ids: list[str] | None = None) -> dict[str, ProbeResult]: + with self._connect() as db: + if service_ids: + placeholders = ",".join("?" for _ in service_ids) + rows = db.execute( + f""" + SELECT p.* FROM probe_results p + WHERE p.service_id IN ({placeholders}) + AND p.rowid = ( + SELECT candidate.rowid FROM probe_results candidate + WHERE candidate.service_id = p.service_id + ORDER BY candidate.checked_at DESC, candidate.rowid DESC LIMIT 1 + ) + """, + service_ids, + ).fetchall() + else: + rows = db.execute( + """ + SELECT p.* FROM probe_results p + WHERE p.rowid = ( + SELECT candidate.rowid FROM probe_results candidate + WHERE candidate.service_id = p.service_id + ORDER BY candidate.checked_at DESC, candidate.rowid DESC LIMIT 1 + ) + """ + ).fetchall() + return {row["service_id"]: self._row_to_probe(row) for row in rows} + + def probe_history(self, service_id: str, limit: int = 50) -> list[ProbeResult]: + with self._connect() as db: + rows = db.execute( + "SELECT * FROM probe_results WHERE service_id = ? ORDER BY checked_at DESC, rowid DESC LIMIT ?", + (service_id, limit), + ).fetchall() + return [self._row_to_probe(row) for row in rows] + + def availability_summary(self, service_id: str, hours: int = 24) -> AvailabilitySummary: + hours = max(1, min(hours, 24 * 90)) + since = _now() - timedelta(hours=hours) + with self._connect() as db: + row = db.execute( + """ + SELECT COUNT(*) AS total, + SUM(CASE WHEN status = 'healthy' THEN 1 ELSE 0 END) AS healthy, + SUM(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END) AS degraded, + SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END) AS down_count, + AVG(latency_ms) AS avg_latency, MAX(checked_at) AS last_checked + FROM probe_results WHERE service_id = ? AND checked_at >= ? + """, + (service_id, since.isoformat()), + ).fetchone() + service_row = db.execute("SELECT availability_target FROM services WHERE id = ?", (service_id,)).fetchone() + total = int(row["total"] or 0) + healthy = int(row["healthy"] or 0) + degraded = int(row["degraded"] or 0) + down = int(row["down_count"] or 0) + uptime = round(((healthy + degraded) / total) * 100, 2) if total else None + target = service_row["availability_target"] if service_row else None + return AvailabilitySummary( + hours=hours, + total_checks=total, + healthy_checks=healthy, + degraded_checks=degraded, + down_checks=down, + uptime_percent=uptime, + average_latency_ms=round(float(row["avg_latency"]), 2) if row["avg_latency"] is not None else None, + last_checked_at=datetime.fromisoformat(row["last_checked"]) if row["last_checked"] else None, + target_percent=target, + meets_target=(uptime >= target) if uptime is not None and target is not None else None, + ) + + def availability_summaries(self, service_ids: list[str], hours: int = 24) -> dict[str, AvailabilitySummary]: + """Fetch table-ready availability in one grouped query, not N requests.""" + if not service_ids: + return {} + hours = max(1, min(hours, 24 * 90)) + since = _now() - timedelta(hours=hours) + placeholders = ",".join("?" for _ in service_ids) + with self._connect() as db: + rows = db.execute( + f""" + SELECT s.id, s.availability_target, COUNT(p.id) AS total, + SUM(CASE WHEN p.status = 'healthy' THEN 1 ELSE 0 END) AS healthy, + SUM(CASE WHEN p.status = 'degraded' THEN 1 ELSE 0 END) AS degraded, + SUM(CASE WHEN p.status = 'down' THEN 1 ELSE 0 END) AS down_count, + AVG(p.latency_ms) AS avg_latency, MAX(p.checked_at) AS last_checked + FROM services s + LEFT JOIN probe_results p ON p.service_id=s.id AND p.checked_at >= ? + WHERE s.id IN ({placeholders}) + GROUP BY s.id, s.availability_target + """, + [since.isoformat(), *service_ids], + ).fetchall() + result: dict[str, AvailabilitySummary] = {} + for row in rows: + total = int(row["total"] or 0) + healthy = int(row["healthy"] or 0) + degraded = int(row["degraded"] or 0) + target = row["availability_target"] + uptime = round(((healthy + degraded) / total) * 100, 2) if total else None + result[row["id"]] = AvailabilitySummary( + hours=hours, + total_checks=total, + healthy_checks=healthy, + degraded_checks=degraded, + down_checks=int(row["down_count"] or 0), + uptime_percent=uptime, + average_latency_ms=round(float(row["avg_latency"]), 2) if row["avg_latency"] is not None else None, + last_checked_at=datetime.fromisoformat(row["last_checked"]) if row["last_checked"] else None, + target_percent=target, + meets_target=(uptime >= target) if uptime is not None and target is not None else None, + ) + return result + + def next_probe_at(self, service: Service, global_interval: int) -> datetime | None: + if not service.enabled or not service.monitoring_enabled: + return None + last = self.latest_probe(service.id) + if last is None: + return _now() + return last.checked_at + timedelta(seconds=service.interval_seconds or global_interval) + + def prune_probes(self, retention_days: int, event_retention_days: int | None = None) -> int: + cutoff = _now() - timedelta(days=retention_days) + event_cutoff = _now() - timedelta(days=event_retention_days or retention_days) + with self._connect() as db: + cursor = db.execute("DELETE FROM probe_results WHERE checked_at < ?", (cutoff.isoformat(),)) + db.execute("DELETE FROM events WHERE created_at < ? AND event_type NOT LIKE 'incident.%'", (event_cutoff.isoformat(),)) + return cursor.rowcount + + def _insert_event_db( + self, + db: sqlite3.Connection, + event_type: str, + service_id: str | None, + incident_id: str | None, + from_status: str | None, + to_status: str | None, + message: str, + metadata: dict[str, object], + created_at: datetime | None = None, + ) -> str: + event_id = str(uuid4()) + db.execute( + """ + INSERT INTO events (id, event_type, service_id, incident_id, from_status, to_status, message, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (event_id, event_type, service_id, incident_id, from_status, to_status, message, json.dumps(metadata), (created_at or _now()).isoformat()), + ) + return event_id + + def add_event( + self, + event_type: str, + message: str, + *, + service_id: str | None = None, + incident_id: str | None = None, + from_status: HealthStatus | None = None, + to_status: HealthStatus | None = None, + metadata: dict[str, object] | None = None, + ) -> EventRecord: + created_at = _now() + with self._connect() as db: + event_id = self._insert_event_db( + db, + event_type, + service_id, + incident_id, + from_status.value if from_status else None, + to_status.value if to_status else None, + message, + metadata or {}, + created_at, + ) + row = db.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + result = self._row_to_event(row) + event_bus.publish(result.model_dump(mode="json")) + return result + + def list_events(self, limit: int = 50, service_id: str | None = None) -> list[EventRecord]: + with self._connect() as db: + if service_id: + rows = db.execute("SELECT * FROM events WHERE service_id = ? ORDER BY created_at DESC LIMIT ?", (service_id, limit)).fetchall() + else: + rows = db.execute("SELECT * FROM events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return [self._row_to_event(row) for row in rows] + + def demo_active(self) -> bool: + with self._connect() as db: + return db.execute("SELECT 1 FROM demo_sessions WHERE id = 1").fetchone() is not None + + @staticmethod + def _snapshot_rows(db: sqlite3.Connection) -> tuple[str, str, str, str]: + services = [dict(row) for row in db.execute("SELECT * FROM services ORDER BY id").fetchall()] + probes = [dict(row) for row in db.execute("SELECT * FROM probe_results ORDER BY id").fetchall()] + events = [dict(row) for row in db.execute("SELECT * FROM events ORDER BY id").fetchall()] + incidents = [dict(row) for row in db.execute("SELECT * FROM incidents ORDER BY id").fetchall()] + return tuple(json.dumps(item, separators=(",", ":")) for item in (services, probes, events, incidents)) # type: ignore[return-value] + + @staticmethod + def _snapshot_discovery_rows(db: sqlite3.Connection) -> str: + tables = ( + "discovery_runs", "discovery_sources", "service_sources", "discovery_observations", + "relation_evidence", "topology_suggestions", "topology_overrides", "topology_change_events", + ) + existing = {row["name"] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + snapshot: dict[str, list[dict[str, object]]] = {} + for table in tables: + if table in existing: + snapshot[table] = [dict(row) for row in db.execute(f"SELECT * FROM {table}").fetchall()] + return json.dumps(snapshot, separators=(",", ":")) + + @staticmethod + def _snapshot_health_evidence_rows(db: sqlite3.Connection) -> str: + return json.dumps( + { + "health_evidence": [dict(row) for row in db.execute("SELECT * FROM health_evidence ORDER BY id").fetchall()], + "health_evidence_service_links": [ + dict(row) for row in db.execute("SELECT * FROM health_evidence_service_links ORDER BY evidence_id, service_id").fetchall() + ], + "provider_status": [dict(row) for row in db.execute("SELECT * FROM provider_status ORDER BY provider").fetchall()], + }, + separators=(",", ":"), + ) + + @classmethod + def _raw_service_params(cls, raw: dict[str, Any]) -> dict[str, Any]: + defaults: dict[str, Any] = { + "description": None, "tags": "[]", "group": None, "environment": None, "owner": None, + "enabled": 1, "monitoring_enabled": 1, "monitoring_state": "active_probe", "probe_source": "manual", + "probe_reachability": "verified", "probe_confidence": "confirmed", "runtime_state": None, "runtime_health": None, + "application": None, "criticality": "medium", "category": "unknown", + "visibility": "visible", "lifecycle": "active", "source_type": None, "source_ref": None, + "first_seen_at": None, "last_seen_at": None, "last_discovery_status": None, + "container_image": None, "compose_project": None, "layout_x": None, "layout_y": None, + "layout_pinned": 0, "interval_seconds": None, "timeout_seconds": None, "retries": None, + "failure_threshold": 1, "recovery_threshold": 1, "expected_status_min": None, + "expected_status_max": None, "availability_target": None, "maintenance_enabled": 0, + "maintenance_reason": None, "state": "unknown", "failure_streak": 0, "recovery_streak": 0, + "flapping": 0, "flap_count": 0, "last_state_change_at": None, "updated_at": raw.get("created_at"), + "depends_on": "[]", + } + params = {**defaults, **raw} + params["depends_on"] = json.dumps(params["depends_on"]) if isinstance(params["depends_on"], list) else params["depends_on"] + params["tags"] = json.dumps(params["tags"]) if isinstance(params["tags"], list) else params["tags"] + params["updated_at"] = params["updated_at"] or params["created_at"] + params["metadata"] = params.get("metadata", "{}") + params["trigger"] = params.get("trigger", "manual") + return params + + @classmethod + def _insert_raw_service(cls, db: sqlite3.Connection, raw: dict[str, Any]) -> None: + params = cls._raw_service_params(raw) + cls._insert_service_row(db, params) + + @staticmethod + def _insert_service_row(db: sqlite3.Connection, params: dict[str, Any]) -> None: + db.execute( + f"INSERT INTO services ({SERVICE_COLUMNS}) VALUES (" + ":id, :name, :kind, :url, :host, :port, :depends_on, :description, :tags, :group, " + ":environment, :owner, :enabled, :monitoring_enabled, :monitoring_state, :probe_source, :probe_reachability, :probe_confidence, :runtime_state, :runtime_health, :application, :domain, :component_role, :application_locked, :domain_locked, :component_role_locked, :criticality, :category, :visibility, :lifecycle, " + ":source_type, :source_ref, :first_seen_at, :last_seen_at, :last_discovery_status, :container_image, " + ":compose_project, :layout_x, :layout_y, :layout_pinned, " + ":interval_seconds, :timeout_seconds, :retries, :failure_threshold, :recovery_threshold, " + ":expected_status_min, :expected_status_max, :availability_target, :maintenance_enabled, " + ":maintenance_reason, :state, :failure_streak, :recovery_streak, :flapping, :flap_count, " + ":last_state_change_at, :created_at, :updated_at)", + params, + ) + + @classmethod + def _restore_rows(cls, db: sqlite3.Connection, snapshot_services: str, snapshot_probes: str, snapshot_events: str, snapshot_incidents: str, snapshot_discovery: str = "{}", snapshot_health_evidence: str = "{}") -> None: + services = json.loads(snapshot_services) + probes = json.loads(snapshot_probes) + events = json.loads(snapshot_events or "[]") + incidents = json.loads(snapshot_incidents or "[]") + db.execute("DELETE FROM incidents") + db.execute("DELETE FROM events") + db.execute("DELETE FROM services") + for raw in services: + cls._insert_raw_service(db, raw) + for raw in probes: + params = { + "id": raw["id"], + "service_id": raw["service_id"], + "status": raw["status"], + "latency_ms": raw.get("latency_ms"), + "detail": raw["detail"], + "checked_at": raw["checked_at"], + "trigger": raw.get("trigger", "manual"), + "metadata": raw.get("metadata", "{}"), + } + db.execute( + "INSERT INTO probe_results (id, service_id, status, latency_ms, detail, checked_at, trigger, metadata) VALUES (:id, :service_id, :status, :latency_ms, :detail, :checked_at, :trigger, :metadata)", + params, + ) + for raw in incidents: + db.execute( + "INSERT INTO incidents (id, status, root_service_id, started_at, detected_at, acknowledged_at, resolved_at, title, root_cause_reason, affected_service_ids, severity, latest_update_at) VALUES (:id, :status, :root_service_id, :started_at, :detected_at, :acknowledged_at, :resolved_at, :title, :root_cause_reason, :affected_service_ids, :severity, :latest_update_at)", + raw, + ) + for raw in events: + db.execute( + "INSERT INTO events (id, event_type, service_id, incident_id, from_status, to_status, message, metadata, created_at) VALUES (:id, :event_type, :service_id, :incident_id, :from_status, :to_status, :message, :metadata, :created_at)", + {**raw, "metadata": raw.get("metadata", "{}")}, + ) + + cls._restore_discovery_rows(db, snapshot_discovery) + cls._restore_health_evidence_rows(db, snapshot_health_evidence) + + @staticmethod + def _restore_health_evidence_rows(db: sqlite3.Connection, snapshot: str) -> None: + try: + items = json.loads(snapshot or "{}") + except json.JSONDecodeError: + items = {} + if not isinstance(items, dict): + return + db.execute("DELETE FROM health_evidence") + db.execute("DELETE FROM provider_status") + for table in ("health_evidence", "health_evidence_service_links", "provider_status"): + rows = items.get(table, []) + if not isinstance(rows, list): + continue + for raw in rows: + if not isinstance(raw, dict) or not raw: + continue + columns = list(raw) + quoted = ", ".join(f'"{column}"' for column in columns) + placeholders = ", ".join("?" for _ in columns) + db.execute(f"INSERT INTO {table} ({quoted}) VALUES ({placeholders})", tuple(raw[column] for column in columns)) + + @staticmethod + def _restore_discovery_rows(db: sqlite3.Connection, snapshot: str) -> None: + try: + tables = json.loads(snapshot or "{}") + except json.JSONDecodeError: + tables = {} + if not isinstance(tables, dict) or not tables: + return + order = ( + "topology_change_events", "topology_overrides", "relation_evidence", "service_sources", + "discovery_observations", "topology_suggestions", "discovery_sources", "discovery_runs", + ) + existing = {row["name"] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + for table in order: + if table in existing: + db.execute(f"DELETE FROM {table}") + for table in reversed(order): + rows = tables.get(table, []) + if table not in existing or not isinstance(rows, list): + continue + for raw in rows: + if not isinstance(raw, dict) or not raw: + continue + columns = list(raw) + quoted = ", ".join(f'"{column}"' for column in columns) + placeholders = ", ".join("?" for _ in columns) + db.execute(f"INSERT INTO {table} ({quoted}) VALUES ({placeholders})", tuple(raw[column] for column in columns)) + + @classmethod + def _insert_demo_rows(cls, db: sqlite3.Connection, services: list[Service], probes: list[ProbeResult]) -> None: + db.execute("DELETE FROM incidents") + db.execute("DELETE FROM events") + db.execute("DELETE FROM health_evidence") + db.execute("DELETE FROM provider_status") + db.execute("DELETE FROM services") + latest: dict[str, ProbeResult] = {} + for probe in probes: + if probe.service_id not in latest or probe.checked_at > latest[probe.service_id].checked_at: + latest[probe.service_id] = probe + for service in services: + seeded = service.model_copy(update={"state": latest.get(service.id, ProbeResult(id="", service_id=service.id, status=HealthStatus.UNKNOWN, latency_ms=None, detail="", checked_at=_now())).status}) + cls._insert_service_row(db, cls._service_params(seeded)) + for probe in probes: + db.execute( + "INSERT INTO probe_results (id, service_id, status, latency_ms, detail, checked_at, trigger, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (probe.id, probe.service_id, probe.status.value, probe.latency_ms, probe.detail, probe.checked_at.isoformat(), probe.trigger, json.dumps(probe.metadata)), + ) + for probe in probes: + service = next((item for item in services if item.id == probe.service_id), None) + interval = service.interval_seconds if service and service.interval_seconds else 60 + evidence_id = f"demo-probe-{probe.id}" + db.execute( + """ + INSERT INTO health_evidence ( + id, subject_type, subject_id, service_id, provider, provider_ref, status, scope, + confidence, observed_at, valid_until, received_at, summary, metadata, active + ) VALUES (?, 'service', ?, ?, 'active_probe', 'active-probe', ?, 'component', 1.0, ?, ?, ?, ?, ?, 1) + ON CONFLICT(id) DO NOTHING + """, + ( + evidence_id, + probe.service_id, + probe.service_id, + probe.status.value, + probe.checked_at.isoformat(), + (probe.checked_at + timedelta(seconds=max(60, interval * 2))).isoformat(), + probe.checked_at.isoformat(), + probe.detail, + json.dumps({"probe_id": probe.id, "trigger": probe.trigger, **probe.metadata}, sort_keys=True), + ), + ) + db.execute( + "INSERT OR IGNORE INTO health_evidence_service_links (evidence_id, service_id) VALUES (?, ?)", + (evidence_id, probe.service_id), + ) + + def activate_demo(self, services: list[Service], probes: list[ProbeResult]) -> ConfigImportResult: + with self._connect() as db: + db.execute("BEGIN IMMEDIATE") + session = db.execute("SELECT * FROM demo_sessions WHERE id = 1").fetchone() + if session is None: + snapshot_services, snapshot_probes, snapshot_events, snapshot_incidents = self._snapshot_rows(db) + snapshot_discovery = self._snapshot_discovery_rows(db) + snapshot_health_evidence = self._snapshot_health_evidence_rows(db) + started_at = _now().isoformat() + else: + snapshot_services = session["snapshot_services"] + snapshot_probes = session["snapshot_probes"] + snapshot_events = session["snapshot_events"] or "[]" + snapshot_incidents = session["snapshot_incidents"] or "[]" + snapshot_discovery = session["snapshot_discovery"] or "{}" + snapshot_health_evidence = session["snapshot_health_evidence"] or "{}" + started_at = session["started_at"] + self._insert_demo_rows(db, services, probes) + db.execute( + """ + INSERT INTO demo_sessions (id, snapshot_services, snapshot_probes, snapshot_events, snapshot_incidents, snapshot_discovery, snapshot_health_evidence, demo_service_ids, started_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET snapshot_services=excluded.snapshot_services, snapshot_probes=excluded.snapshot_probes, + snapshot_events=excluded.snapshot_events, snapshot_incidents=excluded.snapshot_incidents, + snapshot_discovery=excluded.snapshot_discovery, + snapshot_health_evidence=excluded.snapshot_health_evidence, + demo_service_ids=excluded.demo_service_ids, started_at=excluded.started_at + """, + (snapshot_services, snapshot_probes, snapshot_events, snapshot_incidents, snapshot_discovery, snapshot_health_evidence, json.dumps([service.id for service in services]), started_at), + ) + event_bus.publish({"event_type": "topology.changed", "message": "Synthetic demo loaded"}) + return ConfigImportResult(created=len(services), updated=0, total=len(services)) + + def exit_demo(self) -> bool: + with self._connect() as db: + db.execute("BEGIN IMMEDIATE") + session = db.execute("SELECT * FROM demo_sessions WHERE id = 1").fetchone() + if session is None: + return False + self._restore_rows( + db, + session["snapshot_services"], + session["snapshot_probes"], + session["snapshot_events"], + session["snapshot_incidents"], + session["snapshot_discovery"] or "{}", + session["snapshot_health_evidence"] or "{}", + ) + db.execute("DELETE FROM demo_sessions WHERE id = 1") + event_bus.publish({"event_type": "topology.changed", "message": "Synthetic demo exited"}) + return True + + def list_incidents(self, active_only: bool = False, limit: int = 100) -> list[IncidentRecord]: + services = {service.id: service for service in self.list_services()} + with self._connect() as db: + query = "SELECT * FROM incidents" + params: tuple[Any, ...] = () + if active_only: + query += " WHERE status != 'resolved'" + query += " ORDER BY detected_at DESC LIMIT ?" + params += (limit,) + rows = db.execute(query, params).fetchall() + return [self._row_to_incident(row, services) for row in rows] + + def get_incident(self, incident_id: str) -> IncidentRecord | None: + services = {service.id: service for service in self.list_services()} + with self._connect() as db: + row = db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,)).fetchone() + return self._row_to_incident(row, services) if row else None + + def incident_timeline(self, incident_id: str, limit: int = 200) -> list[IncidentEvent]: + with self._connect() as db: + rows = db.execute( + "SELECT id, incident_id, event_type, message, created_at, service_id FROM events WHERE incident_id = ? ORDER BY created_at ASC LIMIT ?", + (incident_id, limit), + ).fetchall() + return [IncidentEvent(**dict(row)) for row in rows] + + def upsert_incident(self, incident: IncidentRecord) -> IncidentRecord: + with self._connect() as db: + db.execute( + """ + INSERT INTO incidents (id, status, root_service_id, started_at, detected_at, acknowledged_at, resolved_at, + title, root_cause_reason, affected_service_ids, severity, latest_update_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET status=excluded.status, acknowledged_at=excluded.acknowledged_at, + resolved_at=excluded.resolved_at, title=excluded.title, root_cause_reason=excluded.root_cause_reason, + affected_service_ids=excluded.affected_service_ids, severity=excluded.severity, latest_update_at=excluded.latest_update_at + """, + (incident.id, incident.status.value, incident.root_service_id, incident.started_at.isoformat(), incident.detected_at.isoformat(), _iso(incident.acknowledged_at), _iso(incident.resolved_at), incident.title, incident.root_cause_reason, json.dumps(incident.affected_service_ids), incident.severity.value, incident.latest_update_at.isoformat()), + ) + return incident + + def append_incident_event(self, incident_id: str, event_type: str, message: str, service_id: str | None = None) -> IncidentEvent: + created_at = _now() + with self._connect() as db: + event_id = self._insert_event_db(db, event_type, service_id, incident_id, None, None, message, {}, created_at) + payload = {"event_type": event_type, "incident_id": incident_id, "service_id": service_id, "message": message, "created_at": created_at.isoformat()} + event_bus.publish(payload) + return IncidentEvent(id=event_id, incident_id=incident_id, event_type=event_type, message=message, created_at=created_at, service_id=service_id) + + def acknowledge_incident(self, incident_id: str) -> IncidentRecord | None: + incident = self.get_incident(incident_id) + if incident is None: + return None + if incident.status == IncidentStatus.RESOLVED: + raise ValueError("Resolved incidents cannot be acknowledged") + now = _now() + updated = incident.model_copy(update={"status": IncidentStatus.ACKNOWLEDGED, "acknowledged_at": now, "latest_update_at": now}) + self.upsert_incident(updated) + self.append_incident_event(incident_id, "incident.acknowledged", "Incident acknowledged", incident.root_service_id) + return updated diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..e359738 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "servicemap-backend" +version = "1.6.2" +description = "ServiceMap API, topology intelligence and probe engine" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116,<1", + "uvicorn[standard]>=0.35,<1", + "httpx>=0.28,<1", + "pydantic>=2.11,<3", + "pyyaml>=6.0,<7", + "docker>=7.1,<8", + "PyJWT[crypto]>=2.10,<3", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4,<9", + "ruff>=0.12,<1", +] + +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 diff --git a/backend/requirements-dev.lock b/backend/requirements-dev.lock new file mode 100644 index 0000000..a8bfcaf --- /dev/null +++ b/backend/requirements-dev.lock @@ -0,0 +1,106 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --extra=dev --output-file=backend/requirements-dev.lock --strip-extras backend/pyproject.toml +# +annotated-doc==0.0.5 + # via fastapi +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via + # httpx + # starlette + # watchfiles +certifi==2026.7.22 + # via + # httpcore + # httpx + # requests +cffi==2.1.1 + # via cryptography +charset-normalizer==3.5.1 + # via requests +click==8.5.0 + # via uvicorn +colorama==0.4.6 + # via pytest +cryptography==50.0.1 + # via pyjwt +docker==7.2.0 + # via servicemap-backend (backend/pyproject.toml) +fastapi==0.141.1 + # via servicemap-backend (backend/pyproject.toml) +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.28.1 + # via servicemap-backend (backend/pyproject.toml) +idna==3.19 + # via + # anyio + # httpx + # requests +iniconfig==2.3.0 + # via pytest +packaging==26.3 + # via pytest +pluggy==1.6.0 + # via pytest +pycparser==3.0 + # via cffi +pydantic==2.13.5 + # via + # fastapi + # servicemap-backend (backend/pyproject.toml) +pydantic-core==2.46.5 + # via pydantic +pygments==2.21.0 + # via pytest +pyjwt==2.14.0 + # via + # pyjwt + # servicemap-backend (backend/pyproject.toml) +pytest==8.4.2 + # via servicemap-backend (backend/pyproject.toml) +python-dotenv==1.2.3 + # via uvicorn +pywin32==312 ; sys_platform == "win32" + # via docker +pyyaml==6.0.3 + # via + # servicemap-backend (backend/pyproject.toml) + # uvicorn +requests==2.34.2 + # via docker +ruff==0.16.7 + # via servicemap-backend (backend/pyproject.toml) +starlette==1.6.0 + # via fastapi +typing-extensions==4.16.0 + # via + # anyio + # fastapi + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.4 + # via + # fastapi + # pydantic +urllib3==2.7.0 + # via + # docker + # requests +uvicorn==0.52.4 + # via servicemap-backend (backend/pyproject.toml) +watchfiles==1.2.0 + # via uvicorn +websockets==17.1 + # via uvicorn diff --git a/backend/requirements.lock b/backend/requirements.lock new file mode 100644 index 0000000..3efc68e --- /dev/null +++ b/backend/requirements.lock @@ -0,0 +1,92 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --output-file=backend/requirements.lock --strip-extras backend/pyproject.toml +# +annotated-doc==0.0.5 + # via fastapi +annotated-types==0.8.0 + # via pydantic +anyio==4.15.1 + # via + # httpx + # starlette + # watchfiles +certifi==2026.7.22 + # via + # httpcore + # httpx + # requests +cffi==2.1.1 + # via cryptography +charset-normalizer==3.5.1 + # via requests +click==8.5.0 + # via uvicorn +cryptography==50.0.1 + # via pyjwt +docker==7.2.0 + # via servicemap-backend (backend/pyproject.toml) +fastapi==0.141.1 + # via servicemap-backend (backend/pyproject.toml) +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.28.1 + # via servicemap-backend (backend/pyproject.toml) +idna==3.19 + # via + # anyio + # httpx + # requests +pycparser==3.0 + # via cffi +pydantic==2.13.5 + # via + # fastapi + # servicemap-backend (backend/pyproject.toml) +pydantic-core==2.46.5 + # via pydantic +pyjwt==2.14.0 + # via + # pyjwt + # servicemap-backend (backend/pyproject.toml) +python-dotenv==1.2.3 + # via uvicorn +pywin32==312 ; sys_platform == "win32" + # via docker +pyyaml==6.0.3 + # via + # servicemap-backend (backend/pyproject.toml) + # uvicorn +requests==2.34.2 + # via docker +starlette==1.6.0 + # via fastapi +typing-extensions==4.16.0 + # via + # anyio + # fastapi + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.4 + # via + # fastapi + # pydantic +urllib3==2.7.0 + # via + # docker + # requests +uvicorn==0.52.4 + # via servicemap-backend (backend/pyproject.toml) +watchfiles==1.2.0 + # via uvicorn +websockets==17.1 + # via uvicorn diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..403e390 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,182 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from app import main as main_module +from app.discovery import DiscoveryManager +from app.discovery_store import DiscoveryStore +from app.models import DiscoveryConfidence, RelationEvidence, RelationType +from app.store import Store + + +def test_discovery_routes_reject_request_controlled_targets(tmp_path: Path, monkeypatch) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + main_module.discovery_store = DiscoveryStore(main_module.store) + main_module.discovery_manager = DiscoveryManager(main_module.store, main_module.discovery_store) + monkeypatch.setenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock") + monkeypatch.setenv("SERVICEMAP_APPOPS_URL", "http://appops.internal:1216") + client = TestClient(main_module.app) + + assert client.post( + "/api/v1/docker/discover", json={"docker_host": "tcp://attacker.internal:2375"} + ).status_code == 403 + assert client.post( + "/api/v1/discovery/run", json={"docker_host": "tcp://attacker.internal:2375"} + ).status_code == 403 + assert client.post( + "/api/v1/discovery/run", json={"appops_url": "http://attacker.internal"} + ).status_code == 403 + + +def test_service_api_crud_cycle_guard_and_export(tmp_path: Path) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + client = TestClient(main_module.app) + + database_response = client.post( + "/api/v1/services", + json={"name": "Database", "kind": "tcp", "host": "db.local", "port": 5432, "depends_on": []}, + ) + assert database_response.status_code == 201 + database = database_response.json() + + api_response = client.post( + "/api/v1/services", + json={ + "name": "API", + "kind": "http", + "url": "https://api.local", + "depends_on": [database["id"]], + }, + ) + assert api_response.status_code == 201 + api_service = api_response.json() + + cycle_response = client.put( + f"/api/v1/services/{database['id']}", + json={ + "name": "Database", + "kind": "tcp", + "host": "db.local", + "port": 5432, + "depends_on": [api_service["id"]], + }, + ) + assert cycle_response.status_code == 422 + assert "cycle" in cycle_response.json()["detail"].lower() + + listing = client.get("/api/v1/services") + assert listing.status_code == 200 + assert len(listing.json()) == 2 + + exported = client.get("/api/v1/config?format=yaml") + assert exported.status_code == 200 + assert "Database" in exported.text + assert "API" in exported.text + + deleted = client.delete(f"/api/v1/services/{database['id']}") + assert deleted.status_code == 204 + api_snapshot = client.get(f"/api/v1/services/{api_service['id']}").json() + assert api_snapshot["service"]["depends_on"] == [] + + +def test_overview_counts_only_visible_services(tmp_path: Path) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + client = TestClient(main_module.app) + for name, visibility in (("Shown", "visible"), ("Retired", "hidden")): + response = client.post( + "/api/v1/services", + json={"name": name, "kind": "tcp", "host": f"{name.lower()}.local", "port": 9000, "visibility": visibility}, + ) + assert response.status_code == 201 + + assert len(client.get("/api/v1/services").json()) == 1 + assert client.get("/api/v1/overview").json()["total_services"] == 1 + + +def test_config_import_endpoint_loads_validated_graph(tmp_path: Path) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + client = TestClient(main_module.app) + content = """ +version: 1 +services: + - name: Redis + kind: tcp + host: redis.local + port: 6379 + depends_on: [] + - name: Worker + kind: tcp + host: worker.local + port: 9000 + depends_on: [Redis] +""" + + response = client.post( + "/api/v1/config/import", + json={"content": content, "format": "yaml", "mode": "replace"}, + ) + + assert response.status_code == 200 + assert response.json() == {"created": 2, "updated": 0, "total": 2} + assert len(client.get("/api/v1/services").json()) == 2 + + +def test_service_api_rejects_invalid_endpoint_and_dependency_references(tmp_path: Path) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + client = TestClient(main_module.app) + + invalid_url = client.post( + "/api/v1/services", + json={"name": "Invalid", "kind": "http", "url": "ftp://example.test"}, + ) + assert invalid_url.status_code == 422 + + missing_dependency = client.post( + "/api/v1/services", + json={ + "name": "API", + "kind": "http", + "url": "https://api.local", + "depends_on": ["missing-service-id"], + }, + ) + assert missing_dependency.status_code == 422 + assert "unknown dependency" in missing_dependency.json()["detail"].lower() + + +def test_semantic_topology_and_logical_relations_are_backend_derived(tmp_path: Path) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + main_module.discovery_store = DiscoveryStore(main_module.store) + main_module.discovery_manager = DiscoveryManager(main_module.store, main_module.discovery_store) + client = TestClient(main_module.app) + database = client.post("/api/v1/services", json={ + "name": "PostgreSQL", "kind": "tcp", "host": "db.local", "port": 5432, + "application": "Zabbix", "domain": "Monitoring", "component_role": "database", + }).json() + api = client.post("/api/v1/services", json={ + "name": "Zabbix API", "kind": "http", "url": "https://zabbix.local", + "application": "Zabbix", "domain": "Monitoring", "component_role": "api", + "depends_on": [database["id"]], + }).json() + now = main_module.store.get_service(database["id"]).created_at + main_module.discovery_store.save_evidence(RelationEvidence( + id="compose-edge", source_service_id=api["id"], target_service_id=database["id"], + relation_type=RelationType.DEPENDS_ON, evidence_type="compose", + confidence=DiscoveryConfidence.HIGH, explanation="Compose dependency", first_seen_at=now, last_seen_at=now, + )) + main_module.discovery_store.save_evidence(RelationEvidence( + id="env-edge", source_service_id=api["id"], target_service_id=database["id"], + relation_type=RelationType.DEPENDS_ON, evidence_type="environment", + confidence=DiscoveryConfidence.MEDIUM, explanation="Environment dependency", first_seen_at=now, last_seen_at=now, + )) + + semantic = client.get("/api/v1/topology/semantic") + relations = client.get("/api/v1/topology/relations") + services = client.get("/api/v1/services") + + assert semantic.status_code == 200 + assert semantic.json()["domains"][0]["name"] == "Monitoring" + assert semantic.json()["applications"][0]["component_count"] == 2 + assert relations.json()[0]["confidence"] == "confirmed" + assert len(relations.json()[0]["evidence"]) == 2 + assert services.json()[0]["availability_24h"]["hours"] == 24 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..a3832ea --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,69 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from app import auth +from app import main as main_module +from app.store import Store + + +def test_disabled_auth_preserves_v15_api_access(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("SERVICEMAP_AUTH_MODE", "disabled") + main_module.store = Store(str(tmp_path / "servicemap.db")) + client = TestClient(main_module.app) + + response = client.post( + "/api/v1/services", + json={"name": "API", "kind": "http", "url": "https://api.local"}, + ) + + assert response.status_code == 201 + + +def test_oidc_mode_fails_closed_but_health_stays_available(monkeypatch) -> None: + monkeypatch.setenv("SERVICEMAP_AUTH_MODE", "oidc") + monkeypatch.delenv("SERVICEMAP_OIDC_ISSUER", raising=False) + monkeypatch.delenv("SERVICEMAP_OIDC_AUDIENCE", raising=False) + client = TestClient(main_module.app) + + assert client.get("/health").status_code == 200 + assert client.get("/api/v1/services").status_code == 503 + + +def test_oidc_roles_enforce_read_operator_and_admin_boundaries(tmp_path: Path, monkeypatch) -> None: + main_module.store = Store(str(tmp_path / "servicemap.db")) + config = auth.AuthConfig(mode="oidc", issuer="https://id.example", audience="servicemap") + monkeypatch.setattr(auth.AuthConfig, "from_environment", classmethod(lambda cls: config)) + client = TestClient(main_module.app) + + monkeypatch.setattr(auth, "verify_bearer", lambda token, _: {"viewer"}) + assert ( + client.get("/api/v1/services", headers={"Authorization": "Bearer viewer"}).status_code + == 200 + ) + assert ( + client.post( + "/api/v1/services", + headers={"Authorization": "Bearer viewer"}, + json={"name": "Denied", "kind": "http", "url": "https://denied.local"}, + ).status_code + == 403 + ) + + monkeypatch.setattr(auth, "verify_bearer", lambda token, _: {"operator", "viewer"}) + assert ( + client.post( + "/api/v1/services", + headers={"Authorization": "Bearer operator"}, + json={"name": "Allowed", "kind": "http", "url": "https://allowed.local"}, + ).status_code + == 201 + ) + assert ( + client.post( + "/api/v1/config/import", + headers={"Authorization": "Bearer operator"}, + json={"content": "version: 1\nservices: []", "format": "yaml", "mode": "merge"}, + ).status_code + == 403 + ) diff --git a/backend/tests/test_config_io.py b/backend/tests/test_config_io.py new file mode 100644 index 0000000..157ba45 --- /dev/null +++ b/backend/tests/test_config_io.py @@ -0,0 +1,133 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from app.config_io import export_document, import_document, parse_document, serialize_document +from app.models import ServiceCreate, ServiceKind +from app.store import Store + +CONFIG = """ +version: 1 +services: + - name: Database + kind: tcp + host: db.local + port: 5432 + depends_on: [] + - name: API + kind: http + url: https://api.local + depends_on: [Database] +""" + + +def test_yaml_import_export_round_trip(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + result = import_document(store, parse_document(CONFIG, "yaml"), "replace") + + assert result.created == 2 + services = store.list_services() + assert len(services) == 2 + api = store.get_service_by_name("API") + database = store.get_service_by_name("Database") + assert api is not None and database is not None + assert api.depends_on == [database.id] + + exported = export_document(store) + yaml_text = serialize_document(exported, "yaml") + reparsed = parse_document(yaml_text, "yaml") + assert [service.name for service in reparsed.services] == [service.name for service in exported.services] + + +def test_merge_can_reference_existing_service_not_in_document(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service( + ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432) + ) + document = parse_document( + """ +version: 1 +services: + - name: API + kind: http + url: https://api.local + depends_on: [Database] +""", + "yaml", + ) + + result = import_document(store, document, "merge") + + assert result.created == 1 + assert result.updated == 0 + api = store.get_service_by_name("API") + assert api is not None + assert api.depends_on == [database.id] + + +def test_import_rejects_missing_dependency_and_cycles(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + missing = parse_document( + """ +version: 1 +services: + - name: API + kind: http + url: https://api.local + depends_on: [Database] +""", + "yaml", + ) + with pytest.raises(ValueError, match="unknown dependencies"): + import_document(store, missing, "replace") + + cyclic = """ +version: 1 +services: + - name: A + kind: http + url: https://a.local + depends_on: [B] + - name: B + kind: http + url: https://b.local + depends_on: [A] +""" + with pytest.raises(ValueError, match="cycle"): + parse_document(cyclic, "yaml") + + with pytest.raises(ValueError, match="less than or equal to 1"): + parse_document("version: 2\nservices: []", "yaml") + + +def test_replace_import_rolls_back_on_mid_transaction_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = Store(str(tmp_path / "servicemap.db")) + original = store.create_service( + ServiceCreate(name="Original", kind=ServiceKind.HTTP, url="https://original.local") + ) + document = parse_document( + """ +version: 1 +services: + - name: First + kind: http + url: https://first.local + depends_on: [] + - name: Second + kind: http + url: https://second.local + depends_on: [] +""", + "yaml", + ) + monkeypatch.setattr("app.store.uuid4", lambda: "duplicate-id") + + with pytest.raises(sqlite3.IntegrityError): + import_document(store, document, "replace") + + remaining = store.list_services() + assert [(service.id, service.name) for service in remaining] == [(original.id, "Original")] diff --git a/backend/tests/test_demo.py b/backend/tests/test_demo.py new file mode 100644 index 0000000..3406ec3 --- /dev/null +++ b/backend/tests/test_demo.py @@ -0,0 +1,186 @@ +from datetime import UTC, datetime +from pathlib import Path + +from fastapi.testclient import TestClient + +from app import main as main_module +from app.demo import DEMO_ROOT_CAUSE_NAME, build_demo_records +from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind +from app.store import Store + + +def _client(tmp_path: Path) -> tuple[Store, TestClient]: + store = Store(str(tmp_path / "servicemap.db")) + main_module.store = store + return store, TestClient(main_module.app) + + +def _assert_acyclic(graph: dict[str, set[str]]) -> None: + visiting: set[str] = set() + visited: set[str] = set() + + def visit(service_id: str) -> None: + assert service_id not in visiting + if service_id in visited: + return + visiting.add(service_id) + for dependency_id in graph[service_id]: + visit(dependency_id) + visiting.remove(service_id) + visited.add(service_id) + + for service_id in graph: + visit(service_id) + + +def test_demo_seeds_topology_health_history_availability_and_incident(tmp_path: Path) -> None: + store, client = _client(tmp_path) + + response = client.post("/api/v1/demo") + assert response.status_code == 200 + result = response.json() + assert result["active"] is True + assert result["synthetic"] is True + assert result["scenario_name"] == "Northstar Platform" + assert result["total"] == 9 + + snapshots = client.get("/api/v1/services").json() + assert len(snapshots) == 9 + by_name = {item["service"]["name"]: item for item in snapshots} + assert set(by_name) == { + "Public Endpoint", + "Reverse Proxy", + "Web Frontend", + "Auth Service", + "API Gateway", + "PostgreSQL", + "Redis", + "Background Worker", + "Object Storage", + } + ids = {item["service"]["id"] for item in snapshots} + assert all(set(item["service"]["depends_on"]).issubset(ids) for item in snapshots) + assert by_name[DEMO_ROOT_CAUSE_NAME]["latest_probe"]["status"] == HealthStatus.DOWN + assert by_name[DEMO_ROOT_CAUSE_NAME]["effective_status"] == HealthStatus.DOWN + assert all(item["effective_status"] != HealthStatus.UNKNOWN for item in snapshots) + + semantic = client.get("/api/v1/topology/semantic").json() + assert {item["name"] for item in semantic["domains"]} == {"Edge", "Application", "Data"} + assert {item["name"] for item in semantic["applications"]} == { + "Public Gateway", + "Northstar App", + "Data Platform", + "Worker Platform", + } + + incidents = client.get("/api/v1/incidents").json() + assert incidents["active"] is True + assert incidents["candidates"][0]["service_name"] == DEMO_ROOT_CAUSE_NAME + assert set(incidents["candidates"][0]["affected_service_names"]) == { + "API Gateway", + "Web Frontend", + "Reverse Proxy", + "Public Endpoint", + "Background Worker", + } + + postgres_id = by_name[DEMO_ROOT_CAUSE_NAME]["service"]["id"] + history = client.get(f"/api/v1/services/{postgres_id}/history?limit=50").json() + assert len(history) == 24 + availability = client.get(f"/api/v1/services/{postgres_id}/availability").json() + assert availability["total_checks"] == 24 + assert availability["uptime_percent"] < 100 + assert availability["down_checks"] == 2 + assert store.demo_active() is True + + +def test_demo_reset_is_deterministic_and_exit_restores_topology_and_probes(tmp_path: Path) -> None: + store, client = _client(tmp_path) + original = store.create_service( + ServiceCreate(name="Existing API", kind=ServiceKind.HTTP, url="https://existing.test") + ) + original_probe = ProbeResult( + id="existing-probe", + service_id=original.id, + status=HealthStatus.HEALTHY, + latency_ms=12.5, + detail="existing data", + checked_at=datetime.now(UTC), + ) + store.save_probe(original_probe) + original_service_json = client.get(f"/api/v1/services/{original.id}").json() + + first = client.post("/api/v1/demo").json() + first_services = client.get("/api/v1/services").json() + first_statuses = { + item["service"]["name"]: (item["latest_probe"]["status"], item["effective_status"]) + for item in first_services + } + assert first["restored"] is False + assert client.get(f"/api/v1/services/{original.id}").status_code == 404 + + reset = client.post("/api/v1/demo/reset").json() + reset_services = client.get("/api/v1/services").json() + reset_statuses = { + item["service"]["name"]: (item["latest_probe"]["status"], item["effective_status"]) + for item in reset_services + } + assert reset["active"] is True + assert reset["root_cause_service_name"] == DEMO_ROOT_CAUSE_NAME + assert reset_statuses == first_statuses + assert len(reset_services) == 9 + + exited = client.post("/api/v1/demo/exit").json() + assert exited["active"] is False + assert exited["restored"] is True + assert client.get("/api/v1/demo").json() == { + "active": False, + "synthetic": False, + "scenario_name": None, + "service_count": 0, + "root_cause_service_id": None, + "root_cause_service_name": None, + "affected_service_ids": [], + "affected_service_names": [], + "affected_count": 0, + } + assert client.get(f"/api/v1/services/{original.id}").json() == original_service_json + assert store.probe_history(original.id)[0].id == original_probe.id + + with store._connect() as db: # regression assertion for FK-clean restoration + orphan_count = db.execute( + "SELECT COUNT(*) FROM probe_results p LEFT JOIN services s ON s.id = p.service_id WHERE s.id IS NULL" + ).fetchone()[0] + assert orphan_count == 0 + orphan_evidence_count = db.execute( + "SELECT COUNT(*) FROM health_evidence e LEFT JOIN services s ON s.id = e.service_id " + "WHERE e.service_id IS NOT NULL AND s.id IS NULL" + ).fetchone()[0] + orphan_link_count = db.execute( + "SELECT COUNT(*) FROM health_evidence_service_links link LEFT JOIN health_evidence e " + "ON e.id = link.evidence_id WHERE e.id IS NULL" + ).fetchone()[0] + assert orphan_evidence_count == 0 + assert orphan_link_count == 0 + + +def test_demo_probe_endpoints_remain_external_service_free(tmp_path: Path) -> None: + _, client = _client(tmp_path) + client.post("/api/v1/demo") + snapshots = client.get("/api/v1/services").json() + postgres_id = next(item["service"]["id"] for item in snapshots if item["service"]["name"] == DEMO_ROOT_CAUSE_NAME) + + assert client.post(f"/api/v1/services/{postgres_id}/probe").status_code == 409 + assert client.post("/api/v1/probe-all").json() == {"requested": 0, "completed": 0, "results": []} + + +def test_demo_records_have_valid_graph_and_stable_ids() -> None: + fixed_now = datetime(2026, 9, 12, 10, 0, tzinfo=UTC) + services_a, probes_a = build_demo_records(fixed_now) + services_b, probes_b = build_demo_records(fixed_now) + + assert [service.model_dump(mode="json") for service in services_a] == [service.model_dump(mode="json") for service in services_b] + assert [probe.model_dump(mode="json") for probe in probes_a] == [probe.model_dump(mode="json") for probe in probes_b] + assert len(probes_a) == 9 * 24 + graph = {service.id: set(service.depends_on) for service in services_a} + _assert_acyclic(graph) diff --git a/backend/tests/test_discovery_v120.py b/backend/tests/test_discovery_v120.py new file mode 100644 index 0000000..c141d77 --- /dev/null +++ b/backend/tests/test_discovery_v120.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import ClassVar + +import pytest + +from app import docker_discovery +from app.demo import build_demo_records +from app.discovery import DiscoveryManager +from app.discovery_store import DiscoveryStore +from app.models import ( + Criticality, + DiscoveryConfidence, + DiscoveryLifecycle, + DiscoveryReconcileRequest, + DiscoveryRunRequest, + DiscoveryRunStatus, + DockerCandidate, + DockerPort, + DockerSuggestion, + ProbeEndpointCandidate, + ProbeEndpointSource, + ProbeReachability, + RelationEvidence, + RelationType, + ServiceCreate, + ServiceKind, + ServiceUpdate, + SuggestionStatus, + TopologySuggestion, +) +from app.store import Store + + +class _Image: + def __init__(self, tag: str) -> None: + self.tags = [tag] + + +class _Container: + def __init__(self, name: str, container_id: str, image: str, service: str, *, env: list[str] | None = None) -> None: + self.name = name + self.id = container_id + self.status = "running" + self.image = _Image(image) + self.attrs = { + "State": {"Status": "running", "Health": {"Status": "healthy"}}, + "Config": { + "Image": image, + "Env": env or [], + "Healthcheck": {"Test": ["CMD", "true"]}, + "Labels": { + "com.docker.compose.project": "northstar", + "com.docker.compose.service": service, + "com.docker.compose.depends_on": "postgres:service_healthy:false" if service == "api" else "", + }, + }, + "NetworkSettings": { + "Networks": {"northstar_default": {}}, + "Ports": {"8000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18080"}]} if service == "api" else {"5432/tcp": [{"HostIp": "127.0.0.1", "HostPort": "15432"}]}, + }, + } + + +class _DockerClient: + containers: ClassVar[_DockerClient] + current: ClassVar[list[_Container]] = [] + + def __init__(self, **_: object) -> None: + self.containers = self + + def ping(self) -> bool: + return True + + def list(self, all: bool) -> list[_Container]: + assert all is False + return self.current + + def close(self) -> None: + return None + + +def _manager(tmp_path: Path) -> DiscoveryManager: + store = Store(str(tmp_path / "servicemap.db")) + return DiscoveryManager(store, DiscoveryStore(store)) + + +def test_malformed_multi_host_environment_does_not_abort_discovery() -> None: + evidence = docker_discovery.safe_environment_evidence( + ["UPSTREAM_HOSTS=chimera:6967,localhost:1221,127.0.0.1:1221"] + ) + + assert evidence == ["UPSTREAM_HOSTS hostname=chimera"] + assert docker_discovery.safe_environment_evidence(["BIND_ADDRESS=http://[invalid:8000"]) == [] + + +def test_internal_http_candidate_is_imported_without_an_unsafe_probe_target(tmp_path: Path) -> None: + manager = _manager(tmp_path) + candidate = DockerCandidate( + container_id="a" * 64, + name="internal-web", + image="example/web:1", + state="running", + networks=[], + ports=[DockerPort(container_port="80/tcp")], + stable_source_ref="docker:name:internal-web", + suggestions=[ + DockerSuggestion( + name="internal-web", + kind=ServiceKind.HTTP, + host="internal-web", + port=80, + ) + ], + ) + + payload = manager._payload_for_candidate(candidate) + + assert payload.kind == ServiceKind.HTTP + assert payload.monitoring_enabled is False + assert payload.url is None + assert payload.host is None + assert payload.port is None + + published = candidate.model_copy( + update={ + "name": "published-web", + "suggestions": [ + DockerSuggestion( + name="published-web", + kind=ServiceKind.HTTP, + url="http://127.0.0.1:9494", + port=9494, + ) + ], + "probe_candidates": [ + ProbeEndpointCandidate( + source=ProbeEndpointSource.PUBLISHED_PORT, + kind=ServiceKind.HTTP, + url="http://127.0.0.1:9494", + reachability=ProbeReachability.VERIFIED, + primary=True, + ) + ], + } + ) + published_payload = manager._payload_for_candidate(published) + + assert published_payload.url == "http://127.0.0.1:9494" + assert published_payload.host is None + assert published_payload.port is None + + +def test_new_discovery_run_closes_stale_active_run(tmp_path: Path) -> None: + manager = _manager(tmp_path) + first = manager.discovery_store.start_run() + second = manager.discovery_store.start_run() + + assert manager.discovery_store.get_run(first.id).status == DiscoveryRunStatus.FAILED + assert manager.discovery_store.get_run(second.id).status == DiscoveryRunStatus.DISCOVERING + + +def test_repeated_suggestion_moves_to_latest_discovery_run(tmp_path: Path) -> None: + manager = _manager(tmp_path) + first = manager.discovery_store.start_run() + now = datetime.now(UTC) + suggestion = TopologySuggestion( + id="suggestion-first", + run_id=first.id, + suggestion_type="service", + proposed_name="example", + confidence=DiscoveryConfidence.HIGH, + created_at=now, + updated_at=now, + ) + saved_first = manager.discovery_store.save_suggestion(suggestion, "same-fingerprint") + second = manager.discovery_store.start_run() + saved_second = manager.discovery_store.save_suggestion( + suggestion.model_copy( + update={ + "id": "suggestion-second", + "run_id": second.id, + "status": SuggestionStatus.OPEN, + "updated_at": datetime.now(UTC), + } + ), + "same-fingerprint", + ) + + assert saved_first.id == saved_second.id == "suggestion-first" + assert saved_second.run_id == second.id + + +def test_discovery_sanitizes_environment_and_reconciles_high_confidence_edges( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _DockerClient.current = [ + _Container( + "api", + "a" * 64, + "northstar/api:1", + "api", + env=["DATABASE_URL=postgres://user:supersecret@postgres:5432/app", "API_TOKEN=never-store"], + ), + _Container("postgres", "b" * 64, "postgres:17", "postgres"), + ] + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs)) + manager = _manager(tmp_path) + + result = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + api = next(item for item in result.candidates if item.name == "api") + assert api.stable_source_ref == "docker:compose:northstar:api" + assert any("hostname=postgres:5432" in item for item in api.environment_evidence) + assert all("supersecret" not in item and "user" not in item for item in api.environment_evidence) + assert all("API_TOKEN" not in item for item in api.environment_evidence) + assert any(item.confidence == DiscoveryConfidence.HIGH for item in result.suggestions if item.suggestion_type == "relation") + + reconciled = manager.reconcile(DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=True)) + assert reconciled.created == 2 + assert reconciled.accepted_relations >= 1 + services = manager.store.list_services() + api_service = next(item for item in services if item.source_ref == "docker:compose:northstar:api") + postgres_service = next(item for item in services if item.source_ref == "docker:compose:northstar:postgres") + assert postgres_service.id in api_service.depends_on + + edge = next(item for item in manager.discovery_store.list_evidence() if item.source_service_id == api_service.id and item.target_service_id == postgres_service.id) + manager.edge_action(edge.id, "reject") + repeated = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=repeated.run.id, apply_high_confidence=True)) + api_after_rejection = next(item for item in manager.store.list_services() if item.id == api_service.id) + assert postgres_service.id not in api_after_rejection.depends_on + assert manager.discovery_store.get_evidence(edge.id) is not None + assert not manager.discovery_store.get_evidence(edge.id).active + + serialized = (tmp_path / "servicemap.db").read_bytes() + assert b"supersecret" not in serialized + assert b"API_TOKEN" not in serialized + + +def test_stable_identity_survives_container_recreation_without_duplicate_service( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")] + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs)) + manager = _manager(tmp_path) + first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True)) + + _DockerClient.current = [_Container("api", "c" * 64, "northstar/api:2", "api")] + second = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + assert second.run.diff.changed == 1 + manager.reconcile(DiscoveryReconcileRequest(run_id=second.run.id, apply_high_confidence=True)) + services = [item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api"] + assert len(services) == 1 + assert services[0].container_image == "northstar/api:2" + + +def test_missing_container_is_retained_and_becomes_stale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")] + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs)) + manager = _manager(tmp_path) + first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True)) + + _DockerClient.current = [] + missing = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + assert missing.run.diff.missing == 1 + reconciled = manager.reconcile(DiscoveryReconcileRequest(run_id=missing.run.id)) + assert reconciled.missing == 1 + service = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api") + assert service.lifecycle == DiscoveryLifecycle.MISSING + assert service.id in {item.id for item in manager.store.list_services()} + + +def test_missing_identity_progresses_to_stale_then_retired_without_delete(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")] + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs)) + manager = _manager(tmp_path) + first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True)) + _DockerClient.current = [] + lifecycle: list[DiscoveryLifecycle] = [] + for _ in range(3): + run = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=run.run.id)) + lifecycle.append(next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api").lifecycle) + assert lifecycle == [DiscoveryLifecycle.MISSING, DiscoveryLifecycle.STALE, DiscoveryLifecycle.RETIRED] + assert len(manager.store.list_services()) == 1 + + +def test_low_confidence_network_hint_is_evidence_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _DockerClient.current = [ + _Container("frontend", "a" * 64, "northstar/frontend:1", "frontend"), + _Container("backend", "b" * 64, "northstar/backend:1", "backend"), + ] + for item in _DockerClient.current: + item.attrs["Config"]["Labels"]["com.docker.compose.depends_on"] = "" + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs)) + manager = _manager(tmp_path) + run = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=run.run.id, apply_high_confidence=True)) + services = manager.store.list_services() + assert all(not service.depends_on for service in services) + assert any(item.confidence == DiscoveryConfidence.LOW for item in run.suggestions if item.suggestion_type == "relation") + + +def test_manual_group_override_survives_rediscovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")] + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs)) + manager = _manager(tmp_path) + first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True)) + before = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api") + payload = ServiceUpdate.model_validate(before.model_dump()) + payload.group = "Manual ownership" + after = manager.store.update_service(before.id, payload) + assert after is not None + manager.record_service_override(before, after) + _DockerClient.current = [_Container("api", "c" * 64, "northstar/api:2", "api")] + second = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375")) + manager.reconcile(DiscoveryReconcileRequest(run_id=second.run.id, apply_high_confidence=True)) + updated = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api") + assert updated.group == "Manual ownership" + assert updated.container_image == "northstar/api:2" + + +def test_demo_exit_restores_discovery_evidence(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + discovery = DiscoveryStore(store) + database = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db", port=5432, category="database", criticality=Criticality.HIGH)) + application = store.create_service(ServiceCreate(name="Application", kind=ServiceKind.HTTP, url="https://app.local", depends_on=[database.id])) + source = discovery.save_source(source_type="docker", source_id="docker:compose:stack:app", source_name="app", lifecycle=DiscoveryLifecycle.ACTIVE, metadata={"image": "app:1"}) + discovery.attach_source(application.id, source.id) + now = datetime.now(UTC) + discovery.save_evidence(RelationEvidence(id="evidence-1", source_service_id=application.id, target_service_id=database.id, relation_type=RelationType.DEPENDS_ON, evidence_type="manual", confidence=DiscoveryConfidence.CONFIRMED, explanation="test evidence", first_seen_at=now, last_seen_at=now)) + demo_services, demo_probes = build_demo_records() + store.activate_demo(demo_services, demo_probes) + assert store.demo_active() + assert {item.name for item in store.list_services()} == {item.name for item in demo_services} + assert store.exit_demo() + restored = store.list_services() + assert {item.name for item in restored} == {"Database", "Application"} + assert discovery.get_evidence("evidence-1") is not None diff --git a/backend/tests/test_docker_discovery.py b/backend/tests/test_docker_discovery.py new file mode 100644 index 0000000..8dccf8b --- /dev/null +++ b/backend/tests/test_docker_discovery.py @@ -0,0 +1,135 @@ +from datetime import UTC, datetime +from typing import ClassVar + +import pytest + +from app import docker_discovery +from app.models import Service, ServiceKind + + +class _FakeImage: + tags: ClassVar[list[str]] = ["example/service:1.0"] + + +class _FakeContainer: + id = "abcdef1234567890" + name = "example-service" + status = "running" + image = _FakeImage() + attrs: ClassVar[dict[str, object]] = { + "State": {"Status": "running", "Health": {"Status": "healthy"}}, + "Config": {"Image": "example/service:1.0"}, + "NetworkSettings": { + "Networks": {"default": {}, "monitoring": {}}, + "Ports": { + "8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": 1253}], + "5432/tcp": None, + }, + }, + } + + +class _FakeClient: + def __init__(self, **_: object) -> None: + self.containers = self + self.closed = False + + def ping(self) -> bool: + return True + + def list(self, all: bool) -> list[_FakeContainer]: + assert all is True + return [_FakeContainer()] + + def close(self) -> None: + self.closed = True + + +def test_discovery_normalizes_ports_networks_and_health(monkeypatch: pytest.MonkeyPatch) -> None: + client = _FakeClient() + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client) + + result = docker_discovery.discover_containers("tcp://docker.local:2375") + + assert result.docker_host == "tcp://docker.local:2375" + assert result.candidates[0].name == "example-service" + assert result.candidates[0].health == "healthy" + assert result.candidates[0].networks == ["default", "monitoring"] + assert {(port.container_port, port.host_port) for port in result.candidates[0].ports} == { + ("8000/tcp", 1253), + ("5432/tcp", None), + } + assert client.closed is True + + +def test_discovery_filters_sensitive_label_keys_but_keeps_discovery_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + token = "do-not-persist-this-token" + labels = { + "servicemap.api-token": token, + "servicemap.password": "do-not-persist-this-password", + "servicemap.name": "Example API", + "servicemap.probe": "http", + "servicemap.port": "8000", + "com.docker.compose.project": "app-stack", + "com.docker.compose.service": "web", + "com.docker.compose.depends_on": "db", + "com.docker.compose.postgres-password": "do-not-persist-this-compose-password", + "org.opencontainers.image.title": "Safe image title", + "other.secret": "not forwarded", + } + attrs = { + **_FakeContainer.attrs, + "Config": {**_FakeContainer.attrs["Config"], "Labels": labels}, + } + monkeypatch.setattr(_FakeContainer, "attrs", attrs) + client = _FakeClient() + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client) + + result = docker_discovery.discover_containers("tcp://docker.local:2375") + + candidate = result.candidates[0] + serialized = str(candidate.model_dump(mode="json")) + assert token not in serialized + assert "do-not-persist-this-password" not in serialized + assert "do-not-persist-this-compose-password" not in serialized + assert "other.secret" not in candidate.labels + assert candidate.labels == { + "servicemap.name": "Example API", + "servicemap.probe": "http", + "servicemap.port": "8000", + "com.docker.compose.project": "app-stack", + "com.docker.compose.service": "web", + "com.docker.compose.depends_on": "db", + "org.opencontainers.image.title": "Safe image title", + } + assert candidate.stable_source_ref == "docker:compose:app-stack:web" + assert candidate.suggestions[0].kind == ServiceKind.HTTP + + +def test_discovery_rejects_untrusted_scheme() -> None: + with pytest.raises(ValueError, match="must use"): + docker_discovery.discover_containers("file:///var/run/docker.sock") + + +def test_discovery_marks_imported_and_carries_review_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + client = _FakeClient() + monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client) + existing = Service( + id="svc-1", + name="example-service", + kind=ServiceKind.HTTP, + url="http://example-service:8000", + source_type="docker", + source_ref="docker:abcdef123456@example/service:1.0", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + result = docker_discovery.discover_containers("tcp://docker.local:2375", [existing]) + + candidate = result.candidates[0] + assert candidate.import_state == "already imported" + assert candidate.suggestions[0].source_type == "docker" + assert candidate.suggestions[0].source_ref == "docker:abcdef123456@example/service:1.0" diff --git a/backend/tests/test_docker_provider.py b/backend/tests/test_docker_provider.py new file mode 100644 index 0000000..30c7916 --- /dev/null +++ b/backend/tests/test_docker_provider.py @@ -0,0 +1,244 @@ +import httpx +import pytest + +from app import docker_provider + + +def test_broker_mode_never_falls_back_to_a_socket_or_request_provided_host(monkeypatch) -> None: + monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker") + monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_URL", raising=False) + monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", raising=False) + socket_used = False + + def socket_client(*args, **kwargs): + nonlocal socket_used + socket_used = True + raise AssertionError("broker mode must not create a socket client") + + monkeypatch.setattr(docker_provider.docker, "DockerClient", socket_client) + + with pytest.raises(docker_provider.DockerProviderError, match="Broker mode requires"): + docker_provider.docker_client_for_inventory("https://attacker.invalid") + + assert socket_used is False + + +def test_broker_destination_is_deployment_configuration_only(tmp_path, monkeypatch) -> None: + token_file = tmp_path / "broker-token" + token_file.write_text("test-token", encoding="utf-8") + monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker") + monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_URL", "https://broker.internal") + monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", str(token_file)) + + client = docker_provider.docker_client_for_inventory("https://attacker.invalid") + + assert isinstance(client, docker_provider.BrokerDockerClient) + assert client.base_url == "https://broker.internal" + client.close() + + +def _mocked_broker_client( + monkeypatch, handler, *, base_url="https://broker.internal", token="opaque-token" +): + original_client = httpx.Client + transport = httpx.MockTransport(handler) + options = {} + + def client_factory(**kwargs): + options.update(kwargs) + return original_client(transport=transport, **kwargs) + + monkeypatch.setattr(docker_provider.httpx, "Client", client_factory) + client = docker_provider.BrokerDockerClient(base_url, token) + return client, options + + +def test_inventory_uses_authenticated_fixed_get_and_health_check_has_no_token(monkeypatch) -> None: + requests = [] + + def handler(request): + requests.append(request) + if request.url.path == "/healthz": + return httpx.Response(200) + return httpx.Response(200, json={"items": []}) + + client, options = _mocked_broker_client(monkeypatch, handler) + try: + assert client.ping() is True + assert client.containers.list() == [] + finally: + client.close() + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/healthz"), + ("GET", "/v1/containers"), + ] + assert "authorization" not in requests[0].headers + assert requests[1].headers["authorization"] == "Bearer opaque-token" + assert options["follow_redirects"] is False + assert options["trust_env"] is False + assert 0 < options["timeout"].connect <= 5 + assert 0 < options["timeout"].read <= 5 + + +def test_broker_rejects_other_authenticated_paths_without_requesting_them(monkeypatch) -> None: + requests = [] + client, _ = _mocked_broker_client( + monkeypatch, + lambda request: requests.append(request) or httpx.Response(200, json={"items": []}), + ) + try: + with pytest.raises(docker_provider.DockerProviderError, match="unsupported"): + client._get("/v1/containers/other") + finally: + client.close() + + assert requests == [] + + +@pytest.mark.parametrize( + "base_url", + [ + "ftp://broker.internal", + "https://user:password@broker.internal", + "https://broker.internal?token=secret", + "https://broker.internal/#fragment", + "https:///missing-host", + ], +) +def test_broker_rejects_unsafe_destinations(base_url) -> None: + with pytest.raises(docker_provider.DockerProviderError): + docker_provider.BrokerDockerClient(base_url, "opaque-token") + + +def test_broker_rejects_header_injection_in_token() -> None: + with pytest.raises(docker_provider.DockerProviderError): + docker_provider.BrokerDockerClient("https://broker.internal", "token\r\nX-Leak: yes") + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"items": "not-a-list"}, + {"items": [{"containerRef": "one"}, "malformed-row"]}, + {"items": [{"containerRef": "one", "name": "one", "state": "running"}]}, + ], +) +def test_broker_rejects_partial_or_malformed_inventory(payload, monkeypatch) -> None: + client, _ = _mocked_broker_client( + monkeypatch, + lambda request: httpx.Response(200, json=payload), + ) + try: + with pytest.raises(docker_provider.DockerProviderError, match="invalid"): + client.containers.list() + finally: + client.close() + + +def test_broker_rejects_non_json_inventory_response(monkeypatch) -> None: + client, _ = _mocked_broker_client( + monkeypatch, + lambda request: httpx.Response( + 200, content=b'{"items":[]}', headers={"content-type": "text/plain"} + ), + ) + try: + with pytest.raises(docker_provider.DockerProviderError, match="invalid"): + client.containers.list() + finally: + client.close() + + +def test_broker_rejects_oversized_inventory_response(monkeypatch) -> None: + monkeypatch.setattr(docker_provider, "_BROKER_MAX_RESPONSE_BYTES", 4) + client, _ = _mocked_broker_client( + monkeypatch, + lambda request: httpx.Response(200, content=b'{"items":[]}'), + ) + try: + with pytest.raises(docker_provider.DockerProviderError, match="invalid"): + client.containers.list() + finally: + client.close() + + +def test_broker_container_preserves_discovery_fields_without_exposing_environment( + monkeypatch, +) -> None: + item = { + "containerRef": "abcdef1234567890", + "name": "/example-service", + "image": "example/service:1.0", + "state": "running", + "health": "healthy", + "healthcheckPresent": True, + "labels": {"servicemap.enabled": "true"}, + "networks": ["default"], + "ports": { + "8000/tcp": [{"hostIp": "0.0.0.0", "hostPort": "1253"}], + "5432/tcp": None, + }, + } + client, _ = _mocked_broker_client( + monkeypatch, + lambda request: httpx.Response(200, json={"items": [item]}), + ) + try: + [container] = client.containers.list() + finally: + client.close() + + assert container.id == item["containerRef"] + assert container.name == "example-service" + assert container.image.tags == [item["image"]] + assert container.attrs["State"] == {"Status": "running", "Health": {"Status": "healthy"}} + assert container.attrs["Config"]["Labels"] == item["labels"] + assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True} + assert container.attrs["Config"]["Env"] == [] + assert container.attrs["NetworkSettings"]["Networks"] == {"default": {}} + assert container.attrs["NetworkSettings"]["Ports"] == { + "8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1253"}], + "5432/tcp": [], + } + + +def test_broker_health_status_proves_healthcheck_when_old_schema_omits_flag(monkeypatch) -> None: + item = { + "containerRef": "abcdef1234567890", + "name": "example-service", + "image": "example/service:1.0", + "state": "running", + "health": "healthy", + "labels": {}, + "networks": [], + "ports": {}, + } + client, _ = _mocked_broker_client( + monkeypatch, lambda request: httpx.Response(200, json={"items": [item]}) + ) + try: + [container] = client.containers.list() + finally: + client.close() + + assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True} + + +def test_broker_error_messages_do_not_expose_destination_or_credential(monkeypatch) -> None: + def handler(request): + raise httpx.ConnectError( + "failed with opaque-token at https://broker.internal", request=request + ) + + client, _ = _mocked_broker_client(monkeypatch, handler) + try: + with pytest.raises(docker_provider.DockerProviderError) as caught: + client.containers.list() + finally: + client.close() + + assert "Docker broker request failed" in str(caught.value) + assert "opaque-token" not in str(caught.value) + assert "broker.internal" not in str(caught.value) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..9a7c63f --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,99 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from app.health import dependency_status, effective_status, validate_dependencies +from app.incidents import incident_overview +from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate +from app.store import Store + + +def _probe(service_id: str, status: HealthStatus) -> ProbeResult: + return ProbeResult( + id=f"probe-{service_id}-{status.value}", + service_id=service_id, + status=status, + latency_ms=1.0, + detail=status.value, + checked_at=datetime.now(UTC), + ) + + +def test_down_dependency_degrades_healthy_service(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service( + ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) + ) + api = store.create_service( + ServiceCreate( + name="API", + kind=ServiceKind.HTTP, + url="https://example.test", + depends_on=[database.id], + ) + ) + store.save_probe(_probe(database.id, HealthStatus.DOWN)) + store.save_probe(_probe(api.id, HealthStatus.HEALTHY)) + + assert dependency_status(api, store) == HealthStatus.DOWN + assert effective_status(api, store) == HealthStatus.DEGRADED + + +def test_service_without_dependencies_has_healthy_dependency_state(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + ServiceCreate(name="Standalone", kind=ServiceKind.HTTP, url="https://example.test") + ) + + assert dependency_status(service, store) == HealthStatus.HEALTHY + assert effective_status(service, store) == HealthStatus.UNKNOWN + + +def test_cycle_validation_rejects_recursive_dependencies(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service( + ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) + ) + api = store.create_service( + ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]) + ) + + with pytest.raises(ValueError, match="cycle"): + validate_dependencies([api.id], store, service_id=database.id) + + validate_dependencies([database.id], store, service_id=api.id) + updated = store.update_service( + api.id, + ServiceUpdate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]), + ) + assert updated is not None + + +def test_incident_overview_finds_root_cause_and_multihop_impact(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service( + ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) + ) + api = store.create_service( + ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]) + ) + web = store.create_service( + ServiceCreate(name="Web", kind=ServiceKind.HTTP, url="https://web.test", depends_on=[api.id]) + ) + store.save_probe(_probe(database.id, HealthStatus.DOWN)) + store.save_probe(_probe(api.id, HealthStatus.HEALTHY)) + store.save_probe(_probe(web.id, HealthStatus.HEALTHY)) + + assert effective_status(api, store) == HealthStatus.DEGRADED + assert dependency_status(web, store) == HealthStatus.DEGRADED + assert effective_status(web, store) == HealthStatus.DEGRADED + + overview = incident_overview(store) + + assert overview.active is True + assert overview.down_service_ids == [database.id] + assert set(overview.degraded_service_ids) == {api.id, web.id} + assert overview.candidates[0].service_id == database.id + assert api.id in overview.candidates[0].affected_service_ids + assert web.id in overview.candidates[0].affected_service_ids diff --git a/backend/tests/test_probes.py b/backend/tests/test_probes.py new file mode 100644 index 0000000..fdc5ff9 --- /dev/null +++ b/backend/tests/test_probes.py @@ -0,0 +1,156 @@ +import asyncio +from datetime import UTC, datetime +from typing import Self + +import pytest + +from app import probes +from app.models import HealthStatus, ProbeResult, Service, ServiceKind + + +def _service() -> Service: + now = datetime.now(UTC) + return Service( + id="service-1", + name="API", + kind=ServiceKind.HTTP, + url="https://api.local", + created_at=now, + updated_at=now, + ) + + +def _tcp_service() -> Service: + now = datetime.now(UTC) + return Service( + id="tcp-service-1", + name="TCP service", + kind=ServiceKind.TCP, + host="tcp.local", + port=5432, + created_at=now, + updated_at=now, + ) + + +@pytest.mark.parametrize( + ("status_code", "expected"), + [ + (200, HealthStatus.HEALTHY), + (302, HealthStatus.HEALTHY), + (404, HealthStatus.DEGRADED), + (503, HealthStatus.DOWN), + ], +) +def test_http_status_classification( + monkeypatch: pytest.MonkeyPatch, + status_code: int, + expected: HealthStatus, +) -> None: + class FakeResponse: + def __init__(self, code: int) -> None: + self.status_code = code + + class FakeClient: + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def get(self, url: str) -> FakeResponse: + assert url == "https://api.local" + return FakeResponse(status_code) + + monkeypatch.setattr(probes.httpx, "AsyncClient", lambda **kwargs: FakeClient()) + + status, detail = asyncio.run(probes._probe_once(_service(), 1.0)) + + assert status == expected + assert detail == f"HTTP {status_code}" + + +def test_retry_latency_reports_final_attempt_not_backoff( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = 0 + + async def fake_probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("temporary failure") + return HealthStatus.HEALTHY, "HTTP 200" + + timings = iter([10.0, 10.05, 20.0, 20.02]) + monkeypatch.setattr(probes, "_probe_once", fake_probe_once) + monkeypatch.setattr(probes, "perf_counter", lambda: next(timings)) + + result = asyncio.run(probes.probe_service(_service(), timeout_seconds=1.0, retries=1)) + + assert calls == 2 + assert result.status == HealthStatus.HEALTHY + assert result.latency_ms == pytest.approx(20.0) + assert result.detail == "HTTP 200 after 2 attempts" + + +def test_tcp_probe_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeWriter: + def close(self) -> None: + return None + + async def wait_closed(self) -> None: + return None + + async def fake_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]: + assert (host, port) == ("tcp.local", 5432) + return object(), FakeWriter() + + monkeypatch.setattr(probes.asyncio, "open_connection", fake_open_connection) + status, detail = asyncio.run(probes._probe_once(_tcp_service(), 1.0)) + assert status == HealthStatus.HEALTHY + assert detail == "TCP tcp.local:5432 reachable" + + async def failing_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]: + raise ConnectionRefusedError("refused") + + monkeypatch.setattr(probes.asyncio, "open_connection", failing_open_connection) + result = asyncio.run(probes.probe_service(_tcp_service(), timeout_seconds=1.0, retries=0)) + assert result.status == HealthStatus.DOWN + assert "ConnectionRefusedError" in result.detail + + +def test_probe_many_respects_concurrency_boundary(monkeypatch: pytest.MonkeyPatch) -> None: + services = [ + _service().model_copy(update={"id": f"service-{index}", "name": f"API-{index}"}) + for index in range(5) + ] + active = 0 + maximum = 0 + + async def fake_probe_service( + service: Service, + *, + timeout_seconds: float, + retries: int, + ) -> ProbeResult: + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + await asyncio.sleep(0) + active -= 1 + return ProbeResult( + id=f"probe-{service.id}", + service_id=service.id, + status=HealthStatus.HEALTHY, + latency_ms=1.0, + detail="ok", + checked_at=datetime.now(UTC), + ) + + monkeypatch.setattr(probes, "probe_service", fake_probe_service) + + results = asyncio.run(probes.probe_many(services, concurrency=2)) + + assert len(results) == len(services) + assert maximum <= 2 diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..0c7bceb --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -0,0 +1,69 @@ +import asyncio +from contextlib import suppress + +from app.scheduler import scheduled_probe_loop + + +class EmptyStore: + def __init__(self) -> None: + self.prune_calls = 0 + + def list_services(self) -> list[object]: + return [] + + def prune_probes(self, retention_days: int) -> int: + assert retention_days == 30 + self.prune_calls += 1 + return 0 + + +class SyntheticStore(EmptyStore): + def demo_active(self) -> bool: + return True + + def list_services(self) -> list[object]: + raise AssertionError("synthetic demo must not start live probes") + + +def test_scheduler_runs_first_cycle_before_initial_sleep() -> None: + async def scenario() -> None: + store = EmptyStore() + task = asyncio.create_task( + scheduled_probe_loop( + store, # type: ignore[arg-type] + interval_seconds=3600, + timeout_seconds=1.0, + retries=0, + concurrency=1, + retention_days=30, + ) + ) + await asyncio.sleep(0) + assert store.prune_calls == 1 + task.cancel() + with suppress(asyncio.CancelledError): + await task + + asyncio.run(scenario()) + + +def test_scheduler_pauses_live_probes_for_synthetic_demo() -> None: + async def scenario() -> None: + store = SyntheticStore() + task = asyncio.create_task( + scheduled_probe_loop( + store, # type: ignore[arg-type] + interval_seconds=3600, + timeout_seconds=1.0, + retries=0, + concurrency=1, + retention_days=30, + ) + ) + await asyncio.sleep(0) + assert store.prune_calls == 1 + task.cancel() + with suppress(asyncio.CancelledError): + await task + + asyncio.run(scenario()) diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py new file mode 100644 index 0000000..7c9d581 --- /dev/null +++ b/backend/tests/test_store.py @@ -0,0 +1,107 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate +from app.store import Store + + +def test_service_round_trip_and_update(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + created = store.create_service( + ServiceCreate(name="Example API", kind=ServiceKind.HTTP, url="https://example.test") + ) + + loaded = store.get_service(created.id) + assert loaded is not None + assert loaded.name == "Example API" + assert loaded.kind == ServiceKind.HTTP + assert loaded.url == "https://example.test" + + updated = store.update_service( + created.id, + ServiceUpdate(name="Example TCP", kind=ServiceKind.TCP, host="127.0.0.1", port=443), + ) + assert updated is not None + assert updated.kind == ServiceKind.TCP + assert updated.host == "127.0.0.1" + assert updated.port == 443 + assert updated.updated_at >= created.updated_at + + +def test_delete_service_cleans_downstream_dependency(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service( + ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432) + ) + api = store.create_service( + ServiceCreate( + name="API", + kind=ServiceKind.HTTP, + url="https://example.test", + depends_on=[database.id], + ) + ) + + assert store.delete_service(database.id) is True + loaded_api = store.get_service(api.id) + assert loaded_api is not None + assert loaded_api.depends_on == [] + assert store.delete_service(database.id) is False + + +def test_stale_probe_after_service_delete_is_ignored(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + ServiceCreate(name="Temporary", kind=ServiceKind.HTTP, url="https://example.test") + ) + assert store.delete_service(service.id) is True + + probe = ProbeResult( + id="stale-probe", + service_id=service.id, + status=HealthStatus.HEALTHY, + latency_ms=1.0, + detail="stale", + checked_at=datetime.now(UTC), + ) + assert store.save_probe(probe) == probe + assert store.probe_history(service.id) == [] + + +def test_availability_summary_and_retention(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test") + ) + now = datetime.now(UTC) + for index, probe_status in enumerate((HealthStatus.HEALTHY, HealthStatus.DEGRADED, HealthStatus.DOWN)): + store.save_probe( + ProbeResult( + id=f"probe-{index}", + service_id=service.id, + status=probe_status, + latency_ms=float(10 + index), + detail=probe_status.value, + checked_at=now - timedelta(minutes=index), + ) + ) + store.save_probe( + ProbeResult( + id="old-probe", + service_id=service.id, + status=HealthStatus.DOWN, + latency_ms=99.0, + detail="old", + checked_at=now - timedelta(days=40), + ) + ) + + summary = store.availability_summary(service.id, hours=24) + assert summary.total_checks == 3 + assert summary.healthy_checks == 1 + assert summary.degraded_checks == 1 + assert summary.down_checks == 1 + assert summary.uptime_percent == 66.67 + assert summary.average_latency_ms == 11.0 + + assert store.prune_probes(30) == 1 diff --git a/backend/tests/test_v110_features.py b/backend/tests/test_v110_features.py new file mode 100644 index 0000000..9dd123c --- /dev/null +++ b/backend/tests/test_v110_features.py @@ -0,0 +1,159 @@ +import json +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import uuid4 + +from app.config_io import export_document, import_document, parse_document +from app.incidents import sync_incidents +from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind +from app.store import Store + + +def _probe(service_id: str, status: HealthStatus, number: int) -> ProbeResult: + return ProbeResult( + id=f"probe-{service_id}-{number}", + service_id=service_id, + status=status, + latency_ms=4.0, + detail=status.value, + checked_at=datetime.now(UTC) + timedelta(microseconds=number), + ) + + +def test_v110_schema_migrates_v101_database_without_reset(tmp_path: Path) -> None: + path = tmp_path / "servicemap.db" + with sqlite3.connect(path) as db: + db.executescript( + """ + CREATE TABLE services ( + id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, + url TEXT, host TEXT, port INTEGER, depends_on TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE probe_results ( + id TEXT PRIMARY KEY, service_id TEXT NOT NULL, status TEXT NOT NULL, + latency_ms REAL, detail TEXT NOT NULL, checked_at TEXT NOT NULL + ); + CREATE TABLE demo_sessions ( + id INTEGER PRIMARY KEY CHECK (id = 1), snapshot_services TEXT NOT NULL, + snapshot_probes TEXT NOT NULL, demo_service_ids TEXT NOT NULL, started_at TEXT NOT NULL + ); + """ + ) + created = datetime.now(UTC).isoformat() + db.execute( + "INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("legacy", "Legacy API", "http", "https://legacy.local", None, None, "[]", created, created), + ) + db.execute( + "INSERT INTO probe_results VALUES (?, ?, ?, ?, ?, ?)", + ("legacy-probe", "legacy", "healthy", 12.0, "legacy", created), + ) + + store = Store(str(path)) + service = store.get_service("legacy") + assert service is not None + assert service.name == "Legacy API" + assert store.service_health("legacy") == HealthStatus.HEALTHY + assert store.latest_probe("legacy") is not None + assert store.path == path + + +def test_failure_and_recovery_thresholds_are_explicit(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + ServiceCreate( + name="Threshold API", + kind=ServiceKind.HTTP, + url="https://threshold.local", + failure_threshold=2, + recovery_threshold=2, + ) + ) + + store.save_probe(_probe(service.id, HealthStatus.DOWN, 1)) + assert store.service_health(service.id) == HealthStatus.UNKNOWN + store.save_probe(_probe(service.id, HealthStatus.DOWN, 2)) + assert store.service_health(service.id) == HealthStatus.DOWN + store.save_probe(_probe(service.id, HealthStatus.HEALTHY, 3)) + assert store.service_health(service.id) == HealthStatus.RECOVERING + store.save_probe(_probe(service.id, HealthStatus.HEALTHY, 4)) + assert store.service_health(service.id) == HealthStatus.HEALTHY + + +def test_incident_journal_acknowledges_resolves_and_does_not_duplicate(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432)) + api = store.create_service(ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local", depends_on=[database.id])) + + store.save_probe(_probe(database.id, HealthStatus.DOWN, 1)) + store.save_probe(_probe(database.id, HealthStatus.DOWN, 2)) + store.save_probe(_probe(api.id, HealthStatus.HEALTHY, 1)) + active = store.list_incidents(active_only=True) + assert len(active) == 1 + assert active[0].root_service_id == database.id + assert api.id in active[0].affected_service_ids + assert len([event for event in store.list_events(100) if event.event_type == "incident.opened"]) == 1 + + acknowledged = store.acknowledge_incident(active[0].id) + assert acknowledged is not None + assert acknowledged.status.value == "acknowledged" + store.save_probe(_probe(database.id, HealthStatus.HEALTHY, 3)) + resolved = store.get_incident(active[0].id) + assert resolved is not None + assert resolved.status.value == "resolved" + assert resolved.resolved_at is not None + assert any(event.event_type == "incident.resolved" for event in store.list_events(100)) + + +def test_incident_reconciliation_resolves_existing_duplicate_active_roots(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service(ServiceCreate(name="Duplicate root", kind=ServiceKind.TCP, host="db.local", port=5432)) + store.save_probe(_probe(service.id, HealthStatus.DOWN, 1)) + store.save_probe(_probe(service.id, HealthStatus.DOWN, 2)) + original = store.list_incidents(active_only=True)[0] + duplicate = original.model_copy(update={"id": str(uuid4()), "detected_at": original.detected_at + timedelta(seconds=1)}) + store.upsert_incident(duplicate) + + sync_incidents(store) + + active = store.list_incidents(active_only=True) + assert len(active) == 1 + assert active[0].root_service_id == service.id + assert store.get_incident(duplicate.id).status.value == "resolved" + assert any(event.event_type == "incident.duplicate_resolved" for event in store.incident_timeline(duplicate.id)) + + +def test_maintenance_suppresses_incident_and_config_v2_round_trips(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + document = parse_document( + """ +version: 2 +services: + - name: DNS edge + kind: dns + host: edge.internal + group: Edge + environment: staging + tags: [dns, edge] + criticality: high + interval_seconds: 15 + depends_on: [] +""", + "yaml", + ) + result = import_document(store, document, "replace") + assert result.created == 1 + service = store.get_service_by_name("DNS edge") + assert service is not None + assert service.group == "Edge" + assert service.interval_seconds == 15 + assert parse_document(json.dumps(export_document(store).model_dump(mode="json")), "json").version == 2 + + updated = store.set_maintenance(service.id, True, "planned work") + assert updated is not None + assert store.service_health(service.id) == HealthStatus.MAINTENANCE + store.save_probe(_probe(service.id, HealthStatus.DOWN, 1)) + sync_incidents(store) + assert store.list_incidents(active_only=True) == [] diff --git a/backend/tests/test_v130_probe_reachability.py b/backend/tests/test_v130_probe_reachability.py new file mode 100644 index 0000000..1d0f9d5 --- /dev/null +++ b/backend/tests/test_v130_probe_reachability.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +from pathlib import Path + +from app.discovery import DiscoveryManager +from app.discovery_store import DiscoveryStore +from app.docker_discovery import _kind_and_endpoint, _preflight_candidate +from app.models import ( + DiscoveryCategory, + DiscoveryConfidence, + DiscoveryRunStatus, + DockerCandidate, + DockerPort, + DockerSuggestion, + HealthStatus, + MonitoringState, + ProbeEndpointCandidate, + ProbeEndpointSource, + ProbeReachability, + ProbeResult, + ServiceCreate, + ServiceKind, +) +from app.store import Store + + +def _manager(tmp_path: Path) -> DiscoveryManager: + store = Store(str(tmp_path / "servicemap.db")) + return DiscoveryManager(store, DiscoveryStore(store)) + + +def _candidate( + name: str, + reference: str, + *, + project: str | None = None, + compose_service: str | None = None, + labels: dict[str, str] | None = None, + networks: list[str] | None = None, +) -> DockerCandidate: + return DockerCandidate( + container_id=reference[-12:], + name=name, + image="example/service:1", + state="running", + networks=networks or [], + ports=[], + labels=labels or {}, + stable_source_ref=reference, + compose_project=project, + compose_service=compose_service, + category=DiscoveryCategory.APPLICATION, + ) + + +def test_internal_docker_dns_is_evidence_not_an_active_probe() -> None: + kind, url, host, port, evidence = _kind_and_endpoint( + "postgres", + [DockerPort(container_port="5432/tcp")], + {}, + ) + + assert kind == ServiceKind.TCP + assert url is None + assert host == "postgres" + assert port == 5432 + assert "internal container endpoint candidate" in evidence + + +def test_published_port_requires_an_explicit_probe_host() -> None: + ports = [DockerPort(container_port="8080/tcp", host_ip="0.0.0.0", host_port=1226)] + labels = {"servicemap.probe": "http"} + _, unsafe_url, unsafe_host, _, _ = _kind_and_endpoint("app", ports, labels) + kind, url, host, port, evidence = _kind_and_endpoint("app", ports, labels, "monitoring-host") + + assert unsafe_url is None + assert unsafe_host == "app" + assert kind == ServiceKind.HTTP + assert url == "http://monitoring-host:1226" + assert host is None and port is None + assert "host-published endpoint" in evidence + + +def test_running_container_without_reachable_endpoint_is_runtime_observed(tmp_path: Path) -> None: + manager = _manager(tmp_path) + candidate = _candidate("worker", "docker:name:worker") + candidate.suggestions = [DockerSuggestion(name="worker", kind=ServiceKind.TCP, host="worker", port=9000)] + candidate.probe_candidates = [ + ProbeEndpointCandidate( + source=ProbeEndpointSource.CONTAINER_INTERNAL, + kind=ServiceKind.TCP, + host="worker", + port=9000, + reachability=ProbeReachability.INTERNAL_ONLY, + ) + ] + + payload = manager._payload_for_candidate(candidate) + + assert payload.monitoring_enabled is False + assert payload.monitoring_state.value == "observed" + assert payload.probe_reachability == ProbeReachability.INTERNAL_ONLY + + +def test_docker_healthcheck_observation_does_not_enable_network_probe(tmp_path: Path) -> None: + manager = _manager(tmp_path) + candidate = _candidate("database", "docker:name:database") + candidate.healthcheck_present = True + candidate.probe_candidates = [ + ProbeEndpointCandidate( + source=ProbeEndpointSource.DOCKER_HEALTHCHECK, + kind=ServiceKind.TCP, + reachability=ProbeReachability.VERIFIED, + confidence=DiscoveryConfidence.HIGH, + ) + ] + + payload = manager._payload_for_candidate(candidate) + + assert payload.monitoring_enabled is False + assert payload.monitoring_state.value == "docker_health" + assert payload.probe_source == ProbeEndpointSource.DOCKER_HEALTHCHECK + + +def test_appops_application_health_is_provenance_not_component_health(tmp_path: Path) -> None: + manager = _manager(tmp_path) + candidate = _candidate("application", "docker:name:application") + candidate.healthcheck_present = True + candidate.appops_health = "healthy" + candidate.appops_app_id = "northstar" + candidate.appops_discovery_confidence = 0.9 + + payload = manager._payload_for_candidate(candidate) + + assert payload.monitoring_enabled is False + assert payload.monitoring_state == MonitoringState.DOCKER_HEALTH + assert payload.probe_source == ProbeEndpointSource.DOCKER_HEALTHCHECK + assert payload.runtime_health is None + + service = manager.store.create_service(payload) + manager._record_candidate_evidence(candidate, service) + resolution = manager.store.health_resolution(service.id) + + assert resolution.selected_evidence_id is None + assert resolution.evidence[0].scope.value == "application" + assert "not used as component" in resolution.explanation + + +def test_compose_reference_prefers_same_project_over_duplicate_stack_name(tmp_path: Path) -> None: + manager = _manager(tmp_path) + run = manager.discovery_store.start_run() + source = _candidate( + "api-a", + "docker:compose:stack-a:api", + project="stack-a", + compose_service="api", + labels={"com.docker.compose.depends_on": "postgres:service_healthy:false"}, + ) + postgres_a = _candidate("postgres-a", "docker:compose:stack-a:postgres", project="stack-a", compose_service="postgres") + postgres_b = _candidate("postgres-b", "docker:compose:stack-b:postgres", project="stack-b", compose_service="postgres") + + suggestions = manager._relation_suggestions(run, [source, postgres_a, postgres_b], {}) + + assert len(suggestions) == 1 + assert suggestions[0].target_ref == postgres_a.stable_source_ref + assert suggestions[0].confidence == DiscoveryConfidence.HIGH + + +def test_ambiguous_global_name_stays_review_only(tmp_path: Path) -> None: + manager = _manager(tmp_path) + run = manager.discovery_store.start_run() + source = _candidate( + "api", + "docker:name:api", + labels={"servicemap.depends_on": "redis"}, + ) + redis_a = _candidate("redis-a", "docker:compose:a:redis", project="a", compose_service="redis") + redis_b = _candidate("redis-b", "docker:compose:b:redis", project="b", compose_service="redis") + + suggestions = manager._relation_suggestions(run, [source, redis_a, redis_b], {}) + + assert len(suggestions) == 1 + assert suggestions[0].target_ref is None + assert suggestions[0].confidence == DiscoveryConfidence.LOW + assert "Manual review required" in " ".join(suggestions[0].evidence) + + +def test_reachability_repair_is_dry_run_first_and_preserves_probe_history(tmp_path: Path) -> None: + manager = _manager(tmp_path) + candidate = _candidate("legacy-db", "docker:name:legacy-db") + candidate.suggestions = [DockerSuggestion(name="legacy-db", kind=ServiceKind.TCP, host="legacy-db", port=5432)] + candidate.probe_candidates = [ + ProbeEndpointCandidate( + source=ProbeEndpointSource.CONTAINER_INTERNAL, + kind=ServiceKind.TCP, + host="legacy-db", + port=5432, + reachability=ProbeReachability.INTERNAL_ONLY, + ) + ] + legacy = manager.store.create_service( + ServiceCreate( + name="legacy-db", + kind=ServiceKind.TCP, + host="legacy-db", + port=5432, + source_type="docker", + source_ref=candidate.stable_source_ref, + ) + ) + manager.store.save_probe( + ProbeResult( + id="legacy-probe", + service_id=legacy.id, + status=HealthStatus.DOWN, + latency_ms=None, + detail="legacy unreachable internal DNS target", + checked_at=legacy.created_at, + ) + ) + run = manager.discovery_store.start_run() + source = manager.discovery_store.save_source( + source_type="docker", + source_id=candidate.stable_source_ref or "", + source_name=candidate.name, + lifecycle=candidate.lifecycle, + metadata={"candidate": candidate.model_dump(mode="json")}, + ) + manager.discovery_store.save_observation( + run_id=run.id, + source=source, + source_name=candidate.name, + lifecycle=candidate.lifecycle, + change_type="changed", + metadata={"candidate": candidate.model_dump(mode="json")}, + ) + manager.discovery_store.finish_run(run.id, status=DiscoveryRunStatus.COMPLETED, source_counts={}, diff=run.diff) + + preview = manager.repair_probe_reachability(run_id=run.id, dry_run=True) + assert preview.changed == 1 + assert manager.store.get_service(legacy.id).monitoring_enabled is True # type: ignore[union-attr] + + applied = manager.repair_probe_reachability(run_id=run.id, dry_run=False) + updated = manager.store.get_service(legacy.id) + assert applied.disabled_unsafe == 1 + assert updated is not None and updated.monitoring_enabled is False + assert updated.monitoring_state.value == "observed" + assert len(manager.store.probe_history(legacy.id)) == 1 + + +def test_container_http_port_is_not_misclassified_by_a_custom_host_port() -> None: + kind, url, host, port, evidence = _kind_and_endpoint( + "adminer", + [DockerPort(container_port="8080/tcp", host_ip="0.0.0.0", host_port=8070)], + {}, + "127.0.0.1", + ) + + assert kind == ServiceKind.HTTP + assert url == "http://127.0.0.1:8070" + assert host is None and port is None + assert "HTTP-like container port" in evidence + + +def test_published_preflight_requires_a_real_tcp_connection(monkeypatch) -> None: + candidate = ProbeEndpointCandidate( + source=ProbeEndpointSource.PUBLISHED_PORT, + kind=ServiceKind.TCP, + host="127.0.0.1", + port=3306, + ) + + class _Connection: + def __enter__(self): + return self + + def __exit__(self, *_: object) -> None: + return None + + monkeypatch.setattr("app.docker_discovery.socket.create_connection", lambda *_args, **_kwargs: _Connection()) + verified = _preflight_candidate(candidate) + + assert verified.primary is True + assert verified.reachability == ProbeReachability.VERIFIED + assert verified.preflight_detail == "TCP connect succeeded" + + +def test_http_preflight_records_response_without_scanning_paths(monkeypatch) -> None: + candidate = ProbeEndpointCandidate( + source=ProbeEndpointSource.PUBLISHED_PORT, + kind=ServiceKind.HTTP, + url="http://127.0.0.1:8070", + ) + + class _Connection: + def __enter__(self): + return self + + def __exit__(self, *_: object) -> None: + return None + + class _Response: + status_code = 204 + + class _Client: + def __enter__(self): + return self + + def __exit__(self, *_: object) -> None: + return None + + def request(self, method: str, url: str) -> _Response: + assert method == "HEAD" + assert url == candidate.url + return _Response() + + monkeypatch.setattr("app.docker_discovery.socket.create_connection", lambda *_args, **_kwargs: _Connection()) + monkeypatch.setattr("app.docker_discovery.httpx.Client", lambda **_kwargs: _Client()) + verified = _preflight_candidate(candidate) + + assert verified.primary is True + assert verified.preflight_detail == "HTTP 204 responded" diff --git a/backend/tests/test_v140_semantic.py b/backend/tests/test_v140_semantic.py new file mode 100644 index 0000000..0d1123b --- /dev/null +++ b/backend/tests/test_v140_semantic.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +from app.discovery_store import DiscoveryStore +from app.models import ( + DiscoveryConfidence, + HealthStatus, + MonitoringState, + RelationEvidence, + RelationType, + Service, + ServiceCreate, + ServiceKind, + ServiceSnapshot, + TopologySuggestion, +) +from app.semantic import build_semantic_topology +from app.store import Store + + +def _service(identifier: str, name: str, *, application: str | None = None, category: str = "application") -> Service: + now = datetime.now(UTC) + return Service( + id=identifier, + name=name, + kind=ServiceKind.HTTP, + url=f"https://{identifier}.local", + application=application, + category=category, + created_at=now, + updated_at=now, + ) + + +def _snapshot(service: Service, direct: HealthStatus, effective: HealthStatus | None = None) -> ServiceSnapshot: + return ServiceSnapshot( + service=service, + direct_status=direct, + dependency_status=effective or direct, + effective_status=effective or direct, + ) + + +def test_semantic_topology_keeps_singletons_as_distinct_applications() -> None: + snapshots = [ + _snapshot(_service("one", "One", category="application"), HealthStatus.HEALTHY), + _snapshot(_service("two", "Two", category="application"), HealthStatus.HEALTHY), + ] + + topology = build_semantic_topology(snapshots, []) + + assert {app.name for app in topology.applications} == {"One", "Two"} + assert all(app.component_count == 1 for app in topology.applications) + + +def test_semantic_app_health_is_explainable_and_ignores_unmonitored_auxiliaries() -> None: + postgres = _service("postgres", "Zabbix PostgreSQL", application="Zabbix", category="database") + server = _service("server", "Zabbix Server", application="Zabbix", category="monitoring") + agent = _service("agent", "Zabbix Agent", application="Zabbix", category="monitoring") + agent.monitoring_enabled = False + agent.monitoring_state = MonitoringState.UNMONITORED + topology = build_semantic_topology([ + _snapshot(postgres, HealthStatus.DOWN), + _snapshot(server, HealthStatus.HEALTHY), + _snapshot(agent, HealthStatus.UNMONITORED), + ], [], root_service_ids={"postgres"}, affected_service_ids={"server"}) + + app = next(item for item in topology.applications if item.name == "Zabbix") + assert app.status == HealthStatus.DEGRADED + assert app.root_cause is True + assert "PostgreSQL" in app.status_explanation + assert app.monitored_components == 2 + assert app.unmonitored_components == 1 + components = {item.service_id: item for item in topology.components} + assert components["postgres"].root_cause is True + assert components["server"].root_cause is False + assert components["server"].affected is True + + +def test_logical_relation_merges_independent_evidence_and_improves_confidence(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + source = store.create_service(ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local")) + target = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db", port=5432)) + discovery = DiscoveryStore(store) + now = datetime.now(UTC) + for evidence_type, confidence, explanation in ( + ("compose", DiscoveryConfidence.MEDIUM, "Compose depends_on declares database"), + ("environment", DiscoveryConfidence.HIGH, "DATABASE_HOST resolves to database"), + ): + discovery.save_evidence(RelationEvidence( + id=f"evidence-{evidence_type}", + source_service_id=source.id, + target_service_id=target.id, + relation_type=RelationType.DEPENDS_ON, + evidence_type=evidence_type, + confidence=confidence, + explanation=explanation, + source_ref=f"docker:compose:stack:{evidence_type}", + first_seen_at=now, + last_seen_at=now, + )) + + relations = discovery.list_logical_relations() + + assert len(relations) == 1 + assert relations[0].confidence == DiscoveryConfidence.CONFIRMED + assert {item.evidence_type for item in relations[0].evidence} == {"compose", "environment"} + + +def test_repeated_suggestion_merges_changed_evidence_without_a_duplicate(tmp_path: Path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + discovery = DiscoveryStore(store) + run = discovery.start_run() + now = datetime.now(UTC) + base = { + "id": "suggest-rel", "run_id": run.id, "suggestion_type": "relation", "source_ref": "docker:api", + "target_ref": "docker:db", "relation_type": RelationType.DEPENDS_ON, + "confidence": DiscoveryConfidence.HIGH, "created_at": now, "updated_at": now, + } + discovery.save_suggestion(TopologySuggestion(**base, evidence=["Compose relationship"]), "canonical-relation") + second = {**base, "id": "suggest-rel-second"} + saved = discovery.save_suggestion(TopologySuggestion(**second, evidence=["Environment relationship"]), "canonical-relation") + + assert saved.evidence == ["Compose relationship", "Environment relationship"] + assert len(discovery.list_suggestions()) == 1 diff --git a/backend/tests/test_v160_evidence_changes.py b/backend/tests/test_v160_evidence_changes.py new file mode 100644 index 0000000..2184f06 --- /dev/null +++ b/backend/tests/test_v160_evidence_changes.py @@ -0,0 +1,132 @@ +from datetime import UTC, datetime, timedelta + +from app.discovery_store import DiscoveryStore +from app.evidence import resolve_health +from app.models import ( + EvidenceProvider, + EvidenceScope, + HealthEvidence, + HealthStatus, + ServiceCreate, + ServiceKind, +) +from app.store import Store + + +def _evidence( + evidence_id: str, + *, + provider: EvidenceProvider, + status: HealthStatus, + scope: EvidenceScope = EvidenceScope.COMPONENT, + service_id: str | None = "svc", + observed_at: datetime | None = None, +) -> HealthEvidence: + observed = observed_at or datetime.now(UTC) + return HealthEvidence( + id=evidence_id, + subject_type="service" if scope == EvidenceScope.COMPONENT else "application", + subject_id=service_id or "appops:demo", + service_id=service_id, + provider=provider, + provider_ref=provider.value, + status=status, + scope=scope, + confidence=0.8, + observed_at=observed, + valid_until=observed + timedelta(minutes=5), + received_at=observed, + summary="test evidence", + ) + + +def test_application_scope_evidence_never_becomes_component_direct_health(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local") + ) + store.record_health_evidence( + _evidence( + "appops-application", + provider=EvidenceProvider.APPOPS, + status=HealthStatus.DOWN, + scope=EvidenceScope.APPLICATION, + service_id=None, + ), + service_ids=[service.id], + ) + + resolution = store.health_resolution(service.id) + + assert resolution.selected_evidence_id is None + assert resolution.status == HealthStatus.UNKNOWN + assert "application-scoped" in resolution.explanation + + +def test_evidence_conflict_and_priority_are_visible(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local") + ) + store.record_health_evidence( + _evidence( + "docker-down", + provider=EvidenceProvider.DOCKER, + status=HealthStatus.DOWN, + service_id=service.id, + ) + ) + store.record_health_evidence( + _evidence( + "probe-healthy", + provider=EvidenceProvider.PROBE, + status=HealthStatus.HEALTHY, + service_id=service.id, + ) + ) + + resolution = store.health_resolution(service.id) + + assert resolution.status == HealthStatus.HEALTHY + assert resolution.selected_provider == EvidenceProvider.PROBE + assert resolution.conflict is True + + +def test_expired_evidence_is_explained_but_not_selected() -> None: + old = datetime.now(UTC) - timedelta(hours=2) + resolution = resolve_health( + [ + _evidence( + "expired", + provider=EvidenceProvider.DOCKER, + status=HealthStatus.DOWN, + observed_at=old, + ), + ] + ) + + assert resolution.selected_evidence_id is None + assert resolution.evidence[0].freshness.value == "expired" + + +def test_topology_snapshots_diff_canonical_service_and_dependency_changes(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + discovery = DiscoveryStore(store) + database = store.create_service( + ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432) + ) + first = discovery.capture_snapshot(run_id=None) + store.create_service( + ServiceCreate( + name="API", + kind=ServiceKind.HTTP, + url="https://api.local", + depends_on=[database.id], + ) + ) + second = discovery.capture_snapshot(run_id=None) + + diff = discovery.topology_diff(after_snapshot_id=second.id, before_snapshot_id=first.id) + + assert diff.summary["added"] == 2 # API service and its canonical dependency + assert {item.entity_type for item in diff.added} == {"service", "relationship"} diff --git a/backend/tests/test_v161_health_resolution.py b/backend/tests/test_v161_health_resolution.py new file mode 100644 index 0000000..b3712be --- /dev/null +++ b/backend/tests/test_v161_health_resolution.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app import observation as observation_module +from app import store as store_module +from app.docker_discovery import AppOpsInfo, AppOpsInventory, DockerHealthObservation +from app.evidence import freshness_for +from app.models import ( + EvidenceProvider, + EvidenceScope, + HealthEvidence, + HealthResolutionReason, + HealthStatus, + ServiceCreate, + ServiceKind, + ServiceSnapshot, +) +from app.observation import ObservationManager, observation_ttl +from app.semantic import build_semantic_topology +from app.store import Store + + +def _service(identifier: str, name: str, *, application: str | None = "Northstar", source_ref: str | None = None): + return ServiceCreate(name=name, kind=ServiceKind.HTTP, url=f"https://{identifier}.test", application=application, source_ref=source_ref) + + +def _application_evidence( + evidence_id: str, + *, + status: HealthStatus = HealthStatus.HEALTHY, + confidence: float = 0.9, + observed_at: datetime | None = None, + valid_for: timedelta = timedelta(minutes=3), + app_id: str = "northstar", +) -> HealthEvidence: + now = observed_at or datetime.now(UTC) + return HealthEvidence( + id=evidence_id, + subject_type="application", + subject_id=f"appops:{app_id}", + provider=EvidenceProvider.APPOPS, + provider_ref=app_id, + status=status, + scope=EvidenceScope.APPLICATION, + confidence=confidence, + observed_at=now, + valid_until=now + valid_for, + received_at=now, + summary="AppOps aggregate health", + ) + + +def _component_evidence(evidence_id: str, service_id: str, status: HealthStatus) -> HealthEvidence: + now = datetime.now(UTC) + return HealthEvidence( + id=evidence_id, + subject_type="service", + subject_id=service_id, + service_id=service_id, + provider=EvidenceProvider.DOCKER, + provider_ref=service_id, + status=status, + scope=EvidenceScope.COMPONENT, + confidence=0.95, + observed_at=now, + valid_until=now + timedelta(minutes=3), + received_at=now, + summary="Docker healthcheck", + ) + + +def _snapshot(service, *, direct: HealthStatus, effective: HealthStatus | None = None, evidence: list[HealthEvidence] | None = None) -> ServiceSnapshot: + return ServiceSnapshot( + service=service, + direct_status=direct, + dependency_status=effective or direct, + effective_status=effective or direct, + health_evidence=evidence or [], + ) + + +def test_appops_health_resolves_application_without_promoting_component_scope(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + api = store.create_service(_service("api", "Northstar API")) + worker = store.create_service(_service("worker", "Northstar Worker")) + aggregate = _application_evidence("appops-northstar") + store.record_health_evidence(aggregate, service_ids=[api.id, worker.id]) + + snapshots = [ + _snapshot(api, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(api.id)), + _snapshot(worker, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(worker.id)), + ] + topology = build_semantic_topology(snapshots, []) + application = topology.applications[0] + + assert application.status == HealthStatus.HEALTHY + assert application.health_resolution.reason_code == HealthResolutionReason.SELECTED_APPLICATION_EVIDENCE + assert application.health_resolution.confidence == 0.9 + assert all(component.direct_status == HealthStatus.UNKNOWN for component in topology.components) + assert all(component.unknown_reason is not None for component in topology.components) + + +def test_component_failure_overrides_healthy_aggregate_and_exposes_conflict(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + database = store.create_service(_service("db", "Northstar PostgreSQL")) + api = store.create_service(_service("api", "Northstar API")) + aggregate = _application_evidence("appops-northstar") + store.record_health_evidence(aggregate, service_ids=[database.id, api.id]) + store.record_health_evidence(_component_evidence("db-down", database.id, HealthStatus.DOWN)) + + topology = build_semantic_topology( + [ + _snapshot(database, direct=HealthStatus.DOWN, evidence=store.health_evidence_for_service(database.id)), + _snapshot(api, direct=HealthStatus.UNKNOWN, effective=HealthStatus.DEGRADED, evidence=store.health_evidence_for_service(api.id)), + ], + [], + ) + application = topology.applications[0] + + assert application.status in {HealthStatus.DOWN, HealthStatus.DEGRADED} + assert application.health_resolution.reason_code == HealthResolutionReason.COMPONENT_FAILURE + assert application.health_resolution.conflict is True + + +def test_application_and_domain_coverage_treat_partial_unknown_as_coverage_not_failure() -> None: + now = datetime.now(UTC) + from app.models import Service + + healthy = Service(id="healthy", **_service("healthy", "Known", application="App A").model_dump(), created_at=now, updated_at=now) + unknown = Service(id="unknown", **_service("unknown", "Unresolved", application="App A").model_dump(), created_at=now, updated_at=now) + topology = build_semantic_topology( + [_snapshot(healthy, direct=HealthStatus.HEALTHY), _snapshot(unknown, direct=HealthStatus.UNKNOWN)], + [], + ) + application = topology.applications[0] + domain = topology.domains[0] + + assert application.status == HealthStatus.HEALTHY + assert application.resolved_components == 1 + assert application.unresolved_components == 1 + assert application.component_coverage_percent == 50 + assert domain.status == HealthStatus.HEALTHY + assert domain.application_coverage_percent == 100 + + +def test_missing_stale_and_expired_application_evidence_have_structured_reasons() -> None: + now = datetime.now(UTC) + from app.models import Service + + service = Service(id="app", **_service("app", "No signal").model_dump(), created_at=now, updated_at=now) + no_evidence = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN)], []).applications[0] + assert no_evidence.health_resolution.reason_code == HealthResolutionReason.NO_APPLICATION_EVIDENCE + + stale_evidence = _application_evidence( + "stale", observed_at=now - timedelta(minutes=2, seconds=30), valid_for=timedelta(minutes=2) + ) + stale = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=[stale_evidence])], []).applications[0] + assert stale.health_resolution.reason_code == HealthResolutionReason.STALE_EVIDENCE + + expired_evidence = _application_evidence( + "expired", observed_at=now - timedelta(minutes=8), valid_for=timedelta(minutes=2) + ) + expired = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=[expired_evidence])], []).applications[0] + assert expired.health_resolution.reason_code == HealthResolutionReason.EXPIRED_EVIDENCE + + +def test_same_named_postgres_sources_do_not_share_application_or_appops_claim(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + first = store.create_service(_service("one", "PostgreSQL one", application="PostgreSQL", source_ref="docker:name:postgres-one")) + second = store.create_service(_service("two", "PostgreSQL two", application="PostgreSQL", source_ref="docker:name:postgres-two")) + store.record_health_evidence(_application_evidence("first", status=HealthStatus.HEALTHY, app_id="postgres-one"), service_ids=[first.id]) + store.record_health_evidence(_application_evidence("second", status=HealthStatus.DOWN, app_id="postgres-two"), service_ids=[second.id]) + + topology = build_semantic_topology( + [ + _snapshot(first, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(first.id)), + _snapshot(second, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(second.id)), + ], + [], + ) + assert len(topology.applications) == 2 + assert sorted(item.status for item in topology.applications) == [HealthStatus.DOWN, HealthStatus.HEALTHY] + + +def test_observation_refresh_renews_evidence_without_duplicates_and_uses_bounded_ttl(tmp_path, monkeypatch) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service(_service("api", "Northstar API", source_ref="docker:name:northstar-api")) + first_now = datetime(2026, 1, 1, tzinfo=UTC) + current = {"value": first_now} + monkeypatch.setattr(observation_module, "utc_now", lambda: current["value"]) + monkeypatch.setattr( + observation_module, + "appops_inventory", + lambda _: AppOpsInventory( + {"northstar-api": AppOpsInfo("northstar", None, None, "healthy", "Northstar", 0.9)}, + True, + "1 mapping loaded", + ), + ) + monkeypatch.setattr( + observation_module, + "observe_container_health", + lambda _: [DockerHealthObservation("abc", "northstar-api", "docker:name:northstar-api", "running", "healthy", True)], + ) + events: list[dict[str, object]] = [] + monkeypatch.setattr(store_module.event_bus, "publish", lambda event: events.append(event)) + manager = ObservationManager(store, interval_seconds=60) + first = manager.refresh() + events.clear() + current["value"] = first_now + timedelta(minutes=20) + second = manager.refresh() + + evidence = store.health_evidence_for_service(service.id) + active = [item for item in evidence if item.active] + assert first.docker_observations == second.docker_observations == 1 + assert observation_ttl(60) == timedelta(seconds=180) + assert len(active) == 2 + assert all(item.valid_until - item.observed_at == timedelta(seconds=180) for item in active) + assert all(freshness_for(item, now=current["value"]).value == "fresh" for item in active) + assert store.provider_statuses()[0].valid_until - store.provider_statuses()[0].observed_at == timedelta(seconds=180) + assert events == [] + + +def test_appops_warning_is_degraded_application_evidence_only(tmp_path, monkeypatch) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + _service("postgres", "PostgreSQL", source_ref="docker:name:postgresql15") + ) + monkeypatch.setattr( + observation_module, + "appops_inventory", + lambda _: AppOpsInventory( + {"postgresql15": AppOpsInfo("postgres", "database", "critical", "warning", "PostgreSQL", 1.0)}, + True, + "1 mapping loaded", + ), + ) + monkeypatch.setattr( + observation_module, + "observe_container_health", + lambda _: [ + DockerHealthObservation( + "abc", "postgresql15", "docker:name:postgresql15", "running", None, False + ) + ], + ) + + result = ObservationManager(store, interval_seconds=60).refresh() + evidence = store.health_evidence_for_service(service.id) + topology = build_semantic_topology( + [_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=evidence)], [] + ) + + assert result.appops_observations == 1 + assert len(evidence) == 1 + assert evidence[0].scope == EvidenceScope.APPLICATION + assert evidence[0].status == HealthStatus.DEGRADED + assert topology.applications[0].status == HealthStatus.DEGRADED + assert topology.components[0].direct_status == HealthStatus.UNKNOWN + + +def test_loaded_evidence_reports_expired_freshness(tmp_path) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service(_service("api", "Northstar API")) + old = datetime.now(UTC) - timedelta(minutes=10) + store.record_health_evidence( + _application_evidence("old-claim", observed_at=old, valid_for=timedelta(minutes=2)), + service_ids=[service.id], + ) + + assert store.health_evidence_for_service(service.id)[0].freshness.value == "expired" + assert store.health_evidence_for_services([service.id])[service.id][0].freshness.value == "expired" + + +def test_empty_broker_inventory_is_unavailable_with_existing_docker_services( + tmp_path, monkeypatch +) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service( + _service("api", "Northstar API", source_ref="docker:name:northstar-api") + ) + store.record_health_evidence( + _component_evidence("existing-docker-claim", service.id, HealthStatus.HEALTHY), + service_ids=[service.id], + ) + monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker") + monkeypatch.setattr( + observation_module, + "appops_inventory", + lambda _: AppOpsInventory({}, False, "AppOps unavailable"), + ) + monkeypatch.setattr(observation_module, "observe_container_health", lambda _: []) + + result = ObservationManager(store, interval_seconds=60).refresh() + + assert result.docker_observations == 0 + assert next( + item for item in store.provider_statuses() if item.provider == EvidenceProvider.DOCKER + ).status.value == "unavailable" + assert [item.id for item in store.health_evidence_for_service(service.id)] == [ + "existing-docker-claim" + ] + + +def test_single_provider_outage_ages_only_its_claim_and_recovers_without_duplicates(tmp_path, monkeypatch) -> None: + store = Store(str(tmp_path / "servicemap.db")) + service = store.create_service(_service("api", "Northstar API", source_ref="docker:name:northstar-api")) + now = datetime(2026, 1, 1, tzinfo=UTC) + current = {"value": now} + mode = {"available": True} + monkeypatch.setattr(observation_module, "utc_now", lambda: current["value"]) + monkeypatch.setattr( + observation_module, + "appops_inventory", + lambda _: ( + AppOpsInventory( + {"northstar-api": AppOpsInfo("northstar", None, None, "healthy", "Northstar", 0.9)}, + True, + "1 mapping loaded", + ) + if mode["available"] + else AppOpsInventory({}, False, "AppOps inventory unavailable: ConnectError") + ), + ) + monkeypatch.setattr( + observation_module, + "observe_container_health", + lambda _: [DockerHealthObservation("abc", "northstar-api", "docker:name:northstar-api", "running", "healthy", True)], + ) + manager = ObservationManager(store, interval_seconds=60) + manager.refresh() + mode["available"] = False + current["value"] += timedelta(seconds=60) + manager.refresh() + + evidence_during_outage = store.health_evidence_for_service(service.id) + assert any(item.provider == EvidenceProvider.APPOPS and freshness_for(item, now=current["value"]).value == "fresh" for item in evidence_during_outage) + assert any(item.provider == EvidenceProvider.DOCKER and freshness_for(item, now=current["value"]).value == "fresh" for item in evidence_during_outage) + assert next(item for item in store.provider_statuses() if item.provider == EvidenceProvider.APPOPS).status.value == "unavailable" + + mode["available"] = True + current["value"] += timedelta(seconds=60) + manager.refresh() + active = [item for item in store.health_evidence_for_service(service.id) if item.active] + assert len(active) == 2 + assert next(item for item in store.provider_statuses() if item.provider == EvidenceProvider.APPOPS).status.value == "healthy" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6836d56 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,66 @@ +name: servicemap + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: servicemap-backend + restart: unless-stopped + environment: + SERVICEMAP_DB: /data/servicemap.db + SERVICEMAP_PROBE_INTERVAL: ${SERVICEMAP_PROBE_INTERVAL:-60} + SERVICEMAP_PROBE_TIMEOUT: ${SERVICEMAP_PROBE_TIMEOUT:-5} + SERVICEMAP_PROBE_RETRIES: ${SERVICEMAP_PROBE_RETRIES:-1} + SERVICEMAP_PROBE_CONCURRENCY: ${SERVICEMAP_PROBE_CONCURRENCY:-10} + SERVICEMAP_RETENTION_DAYS: ${SERVICEMAP_RETENTION_DAYS:-30} + SERVICEMAP_EVENT_RETENTION_DAYS: ${SERVICEMAP_EVENT_RETENTION_DAYS:-90} + SERVICEMAP_TLS_WARNING_DAYS: ${SERVICEMAP_TLS_WARNING_DAYS:-30} + SERVICEMAP_FLAP_THRESHOLD: ${SERVICEMAP_FLAP_THRESHOLD:-6} + SERVICEMAP_OBSERVATION_INTERVAL_SECONDS: ${SERVICEMAP_OBSERVATION_INTERVAL_SECONDS:-60} + SERVICEMAP_PROBE_HOST: ${SERVICEMAP_PROBE_HOST:-} + volumes: + - ./data:/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"] + interval: 20s + timeout: 5s + start_period: 10s + retries: 5 + labels: + net.unraid.docker.managed: "dockerman" + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + container_name: servicemap + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${SERVICEMAP_PORT:-1253}:80" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null || exit 1"] + interval: 20s + timeout: 5s + start_period: 10s + retries: 5 + labels: + net.unraid.docker.managed: "dockerman" + net.unraid.docker.webui: "http://${SERVICEMAP_WEBUI_HOST:-127.0.0.1}:${SERVICEMAP_PORT:-1253}" + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..60a1b6f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,70 @@ +name: servicemap + +services: + servicemap: + build: + context: . + dockerfile: Dockerfile.unraid + container_name: servicemap + restart: unless-stopped + environment: + SERVICEMAP_DB: /data/servicemap.db + SERVICEMAP_STATIC_DIR: /app/static + SERVICEMAP_PROBE_INTERVAL: ${SERVICEMAP_PROBE_INTERVAL:-60} + SERVICEMAP_PROBE_TIMEOUT: ${SERVICEMAP_PROBE_TIMEOUT:-5} + SERVICEMAP_PROBE_RETRIES: ${SERVICEMAP_PROBE_RETRIES:-1} + SERVICEMAP_PROBE_CONCURRENCY: ${SERVICEMAP_PROBE_CONCURRENCY:-10} + SERVICEMAP_RETENTION_DAYS: ${SERVICEMAP_RETENTION_DAYS:-30} + SERVICEMAP_EVENT_RETENTION_DAYS: ${SERVICEMAP_EVENT_RETENTION_DAYS:-90} + SERVICEMAP_TLS_WARNING_DAYS: ${SERVICEMAP_TLS_WARNING_DAYS:-30} + SERVICEMAP_FLAP_THRESHOLD: ${SERVICEMAP_FLAP_THRESHOLD:-6} + SERVICEMAP_DISCOVERY_INTERVAL_SECONDS: ${SERVICEMAP_DISCOVERY_INTERVAL_SECONDS:-0} + SERVICEMAP_OBSERVATION_INTERVAL_SECONDS: ${SERVICEMAP_OBSERVATION_INTERVAL_SECONDS:-60} + SERVICEMAP_DOCKER_HOST: ${SERVICEMAP_DOCKER_HOST:-unix:///var/run/docker.sock} + # "broker" is opt-in and has no socket fallback. It requires a dedicated + # registered ServiceMap identity and token file before activation. + SERVICEMAP_DOCKER_PROVIDER: ${SERVICEMAP_DOCKER_PROVIDER:-socket} + SERVICEMAP_DOCKER_BROKER_URL: ${SERVICEMAP_DOCKER_BROKER_URL:-} + SERVICEMAP_DOCKER_BROKER_TOKEN_FILE: ${SERVICEMAP_DOCKER_BROKER_TOKEN_FILE:-} + SERVICEMAP_APPOPS_URL: ${SERVICEMAP_APPOPS_URL:-} + SERVICEMAP_PROBE_HOST: ${SERVICEMAP_PROBE_HOST:-} + # Disabled is backwards-compatible. OIDC mode is fail-closed and needs + # issuer, audience and explicit role-group mappings before activation. + SERVICEMAP_AUTH_MODE: ${SERVICEMAP_AUTH_MODE:-disabled} + SERVICEMAP_OIDC_ISSUER: ${SERVICEMAP_OIDC_ISSUER:-} + SERVICEMAP_OIDC_AUDIENCE: ${SERVICEMAP_OIDC_AUDIENCE:-} + SERVICEMAP_OIDC_ROLE_CLAIMS: ${SERVICEMAP_OIDC_ROLE_CLAIMS:-groups,roles} + SERVICEMAP_OIDC_VIEWER_GROUPS: ${SERVICEMAP_OIDC_VIEWER_GROUPS:-} + SERVICEMAP_OIDC_OPERATOR_GROUPS: ${SERVICEMAP_OIDC_OPERATOR_GROUPS:-} + SERVICEMAP_OIDC_ADMIN_GROUPS: ${SERVICEMAP_OIDC_ADMIN_GROUPS:-} + volumes: + - servicemap_data:/data + - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - "${SERVICEMAP_PORT:-1253}:8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"] + interval: 20s + timeout: 5s + start_period: 10s + retries: 5 + labels: + net.unraid.docker.managed: "dockerman" + net.unraid.docker.webui: "http://${SERVICEMAP_WEBUI_HOST:-127.0.0.1}:${SERVICEMAP_PORT:-1253}" + net.unraid.docker.icon: "http://${SERVICEMAP_WEBUI_HOST:-127.0.0.1}:${SERVICEMAP_PORT:-1253}/icons/servicemap-dockerman-256.png" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=32m + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + +volumes: + servicemap_data: + name: servicemap_data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..81a47c6 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,119 @@ +# ServiceMap architecture + +## Runtime shape + +ServiceMap remains deliberately small: one FastAPI process, one React single-page application and one SQLite database. Compose serves React through Nginx; the Unraid image bundles the same frontend and serves it from FastAPI. + +```text +Browser + │ REST + SSE + ▼ +React workspaces + │ + ▼ +FastAPI + ├── service inventory and dependency validation + ├── probe engine (HTTP, TCP, DNS, TLS) + ├── state machine and flapping observation + ├── health-evidence resolution and provider health + ├── incident correlation and event journal + ├── SSE fan-out bus + ├── atomic config import/export + ├── Docker/AppOps discovery and reconciliation + ├── evidence-backed canonical topology overrides + └── optional per-service scheduler + │ + ▼ + SQLite on /data +``` + +## Data ownership and migration + +`Store` owns SQLite access and opens short-lived connections with WAL mode, a 5-second busy timeout and foreign keys enabled. Startup creates missing discovery tables and adds versioned metadata in place. v1.6 adds health evidence, evidence-to-service links and provider-status rows; the one-time migration projects only legitimate existing latest probe/runtime facts with their original timestamps. Existing rows receive safe defaults; the migration never deletes or rewrites topology data. + +v1.6.1 adds no schema migration. Its health-only observation loop is distinct from discovery: every configured cadence it reads Docker runtime/healthcheck facts and AppOps application inventory, renews only source-scoped evidence/provider status, and skips demo state. It does not create services, suggestions, relationship evidence, snapshots, probe endpoints or operator overrides. The shared observation TTL is at least 150 seconds and three normal cycles, so a delayed provider cycle ages evidence rather than immediately turning the topology unknown. Provider failures are isolated; they update that provider's availability only and leave other current evidence intact. + +v1.6.2 keeps the same schema and evidence scopes. An AppOps `warning` observation is degraded application evidence; it cannot resolve a component. Evidence freshness is recomputed when persisted claims are read, so an expired claim cannot appear fresh in the raw inspector/API payload. The current MCP Hub Docker read broker cannot replace the production socket provider yet: its registration covers two unrelated projects, its ServiceMap-scoped inventory is empty, and its short-lived HMAC service-token contract rejects a raw bearer secret. Cutover requires a dedicated ServiceMap read identity, full bounded inventory parity and an explicit network path; broker mode remains fail-closed with no socket fallback. + +The HTTP discovery routes accept only the Docker host and AppOps URL selected in deployment configuration. A request body cannot turn the container into a general-purpose client for another Docker daemon or internal HTTP endpoint. The private-LAN deployment currently keeps OIDC disabled because it has no dedicated browser client or tested owner login path; mutation APIs therefore depend on the LAN perimeter. Broker mode must be deployed without the legacy Docker socket bind even though the current socket-mode Compose file retains it. + +The v1.4 semantic projection is deliberately not a parallel datastore. `semantic.py` derives Domain → Application → Component from canonical services, relations, runtime/monitoring state and incident analysis. Logical relations are grouped at query time so one graph edge retains all independent evidence records and their first/last-seen provenance. v1.6.1 resolves application health separately: fresh aggregate AppOps evidence can answer an application but never a component; direct component failures override weak aggregate health and conflicting claims remain visible. Semantic application identity uses locked operator grouping, Compose provenance and stable source references before a display name, preventing generic provider labels from collapsing unrelated services. Bulk evidence lookup serves the complete semantic projection in one bounded SQL query rather than an application-by-application scan. + +The service record stores descriptive metadata, group/environment/tags, criticality, source reference, application, runtime state/health, monitoring state, probe source/reachability/confidence, enablement, maintenance state, probe policy and state-machine counters. Probe rows remain immutable and retain `trigger` and bounded JSON `metadata` fields. `HealthEvidence` retains provider, subject, scope, confidence, observation/receipt/expiry timestamps, metadata and supersession. Application-scoped claims may be linked to component inspectors but cannot resolve a component's direct health. + +Indexes cover service/time probe history, global probe time, event time/service/incident and incident status/root lookups. Availability is calculated with SQL aggregates over a bounded time window rather than loading all rows into Python. Evidence timestamp renewals retain a stable row and do not publish SSE; material provider status, evidence status, confidence, summary or metadata changes do. + +## Health state and dependency semantics + +The direct state machine is backend-owned, then exposed through an evidence policy: + +```text +unknown ──success──> healthy +healthy ──failure threshold──> degraded/down +down/degraded ──success #1──> recovering +recovering ──recovery threshold──> healthy +any monitored state ──maintenance──> maintenance +``` + +The first failure can be held at the prior/unknown state when a failure threshold is greater than one. Repeated state changes in a bounded 30-minute query set the `flapping` observation and its count; flapping is not a competing health state. + +Effective health is calculated recursively from direct state and dependencies. A down dependency makes a healthy dependent degraded; the stored probe observation is never rewritten. Cycles and missing dependencies are rejected before writes. + +Maintenance and disabled monitoring do not produce a new incident. A running Docker component without a verified endpoint or Docker healthcheck is `unmonitored`, not `down`; this state cannot fabricate a root cause. Docker `unhealthy` and stopped/dead runtime observations are valid direct-down evidence without a synthetic network probe. The resolver selects fresh component-scoped evidence deterministically, exposes conflicts rather than collapsing them and excludes stale/expired claims. AppOps inventory health is application-scoped provenance, not a component probe. + +## Probe engine + +`probes.py` uses bounded async operations and one asyncio lock per service. Scheduler, probe-all and manual probe calls therefore coalesce at the service boundary rather than running duplicate network checks concurrently. + +- HTTP follows redirects, supports expected status ranges and records status/latency. +- TCP opens and closes a bounded connection. +- DNS resolves a hostname, returns at most 20 unique addresses and reports no-answer/timeout failures. +- TLS performs a direct hostname-verified handshake, reports expiry metadata and applies a configurable warning threshold (`SERVICEMAP_TLS_WARNING_DAYS`, default 30). + +Targets are explicitly typed. HTTP accepts only `http`/`https` URLs; TCP/TLS require host and port; DNS requires a host and does not accept arbitrary URI schemes. There is no `file://` or generic URL fetcher. + +Per-service interval, timeout and retry settings override the global defaults. Scheduled observations are marked `scheduled`; UI-triggered observations are marked `manual`. + +## Incidents and events + +`incidents.py` deterministically finds a down service with no effectively-down dependency, then traverses the reverse graph for blast radius. `sync_incidents()` reconciles candidates with the durable journal: + +- one active incident per root service; +- repeated probe cycles update impact instead of opening duplicates; +- acknowledgement changes `open` to `acknowledged` and persists a timestamp; +- recovery changes the record to `resolved` and retains `resolved_at`; +- state transitions, flapping, incident lifecycle and maintenance changes are journaled. + +Incident timeline entries are events tied to an incident. Raw probe rows are retained independently under the configured probe retention; non-incident events use `SERVICEMAP_EVENT_RETENTION_DAYS` (default 90). Incident lifecycle events are retained with the incident record. + +## SSE and frontend refresh + +The durable SQLite event journal is the source of truth. A small thread-safe bounded queue is allocated per SSE client and receives notifications for probe completion, state changes, topology changes and incident lifecycle changes. Slow clients lose only transient wakeups, not durable history. The stream emits keepalives and unregisters queues on disconnect/shutdown. + +React subscribes with SSE, debounces event refreshes and reconnects with backoff. It enables bounded fallback refresh only while the stream is unavailable; a healthy quiet stream does not trigger periodic full reloads. Authenticated clients use the same SSE protocol through `fetch` with a bearer header. The frontend never invents monitoring state optimistically; every node, counter and inspector value comes from the API. + +## Production discovery and reconciliation + +`DiscoveryStore` owns durable runs, source records, observations, relation evidence, suggestions, overrides, topology-change events and compact canonical topology snapshots. `DiscoveryManager` reads the configured Docker endpoint and optional read-only AppOps inventory, then separates observed candidates from inferred relationships and canonical services. Candidates include stable Compose/name identity, image, state, health, labels, networks, ports, Compose metadata, healthcheck presence and sanitized endpoint-host evidence. ServiceMap labels (`servicemap.*`), Compose `depends_on`, safe environment hostnames and AppOps mappings produce explainable suggestions. Numeric AppOps `discovery_confidence` gates classification strength, while its app health remains scoped provenance. Shared networks are low-confidence review hints only. + +Discovery runs do not mutate canonical topology. Reconciliation updates existing observed records and only imports new services or accepts high-confidence dependency relationships when the user explicitly requests it. Compose references resolve only inside the same project; environment references use project/network/global-unique scope and ambiguous matches remain review suggestions. Manual overrides and locked edges are preserved across later runs. Missing Docker identities move through `missing`, `stale` and `retired` without deleting probe history. Health propagation uses canonical dependency edges only; evidence-only or rejected relationships cannot affect health. + +Published ports become active candidates only when `SERVICEMAP_PROBE_HOST` is explicitly configured. Internal Docker names are retained as evidence with `internal_only` reachability, never sent to the network probe engine by default. The reachability-repair endpoint provides a dry-run report before it disables unsafe legacy candidates or selects a verified published endpoint; it does not delete history and resolves false incidents with an endpoint-superseded event reason. + +After reconciliation, a revision-hashed snapshot is captured only when canonical services or dependency edges change. The Changes workspace compares two snapshots and makes a service/relationship change actionable without auto-applying anything. The production UI starts with the canonical topology, exposes discovery diffs and evidence, supports individual accept/reject decisions, and offers an explicit edit mode for pinned node layout and manual dependency edges. Hidden/ignored lifecycle records remain available through the `include_hidden` API for reconciliation and audit context. ELK route sections are passed to `LivingEdge`, so its directed SVG path follows the actual orthogonal layout. + +## Configuration + +Config v1 is still accepted. Config v2 serializes the service metadata and policy fields by human-readable service name. Parsing validates endpoint fields, duplicate names, duplicate/self edges and document-contained cycles. Import then validates the projected merge or replace graph and commits it in one `BEGIN IMMEDIATE` transaction. + +## Demo safety + +The Northstar demo is built from deterministic UUIDs and synthetic probe records. Entering demo stores JSON snapshots of services, probes, events, incidents, discovery evidence, health evidence and provider state in the durable `demo_sessions` row. Reset replaces only the synthetic workspace while preserving the original snapshot. Exit restores the snapshot and deletes the session. Scheduled and manual live probes and discovery reconciliation are blocked while the demo is active. + +## Deployment trust boundary + +The Unraid target uses `/data` on the named `servicemap_data` volume, port mapping `1253:8000`, a healthcheck on `/health`, `no-new-privileges`, dropped capabilities, a read-only root filesystem and bounded `/tmp` tmpfs. Managed validation gates exact-SHA autoredeploy. Socket discovery remains a high-trust Docker API boundary even with a read-only bind. The opt-in broker provider requires a dedicated short-lived ServiceMap token, registration-scoped read capability and shared network, and never falls back to the socket. AppOps access is read-only enrichment. + +## Optional authorization + +`SERVICEMAP_AUTH_MODE=disabled` preserves the deployed v1.5 behavior. In `oidc` mode FastAPI validates issuer/audience/signed bearer JWTs against OIDC discovery and JWKS; it maps explicit roles or configured Authentik groups to viewer, operator and admin. `/health` remains anonymous for deployment recovery, while APIs fail closed when OIDC configuration is incomplete. No client credentials, issuer secrets or tokens are stored in ServiceMap's SQLite database or frontend configuration. diff --git a/docs/screenshots/demo-health.png b/docs/screenshots/demo-health.png new file mode 100644 index 0000000..814efaf Binary files /dev/null and b/docs/screenshots/demo-health.png differ diff --git a/docs/screenshots/demo-landscape.png b/docs/screenshots/demo-landscape.png new file mode 100644 index 0000000..c774424 Binary files /dev/null and b/docs/screenshots/demo-landscape.png differ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c5b69f3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build + +WORKDIR /app +COPY frontend/package.json ./package.json +COPY frontend/package-lock.json ./package-lock.json +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts new file mode 100644 index 0000000..aae90d8 --- /dev/null +++ b/frontend/e2e/demo.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from "@playwright/test"; + +type ServiceSnapshot = { + service: { + id: string; + name: string; + depends_on: string[]; + }; + latest_probe: { status: string } | null; + effective_status: string; +}; + +const topologyOf = (snapshots: ServiceSnapshot[]) => + snapshots + .map(({ service }) => ({ + id: service.id, + name: service.name, + depends_on: [...service.depends_on].sort(), + })) + .sort((left, right) => left.id.localeCompare(right.id)); + +const isDemoMutation = (path: string) => + (response: { + url: () => string; + request: () => { method: () => string }; + }) => + response.url().endsWith(path) && response.request().method() === "POST"; + +test("Explore demo exposes evidence-backed root cause and reset-safe exit", async ({ page, request }) => { + test.setTimeout(60_000); + + // The Playwright server uses a persistent temporary database, so restore any + // session left by an interrupted run before taking this test's baseline. + const cleanup = await request.post("/api/v1/demo/exit"); + expect(cleanup.ok()).toBeTruthy(); + + const baselineResponse = await request.get("/api/v1/services"); + expect(baselineResponse.ok()).toBeTruthy(); + const baselineTopology = topologyOf(await baselineResponse.json()); + + await page.goto("/"); + const explore = page.getByRole("button", { name: "Explore demo", exact: true }); + await expect(explore).toBeEnabled({ timeout: 30_000 }); + + const loadResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo")); + await explore.click(); + if (baselineTopology.length > 0) { + const confirmation = page + .getByRole("alert") + .filter({ hasText: "temporarily replace this topology" }); + await expect(confirmation).toBeVisible(); + await confirmation.getByRole("button", { name: "Explore demo", exact: true }).click(); + } + const loadResponse = await loadResponsePromise; + expect(loadResponse.ok()).toBeTruthy(); + expect(await loadResponse.json()).toMatchObject({ + active: true, + synthetic: true, + scenario_name: "Northstar Platform", + service_count: 9, + total: 9, + root_cause_service_name: "PostgreSQL", + affected_count: 5, + }); + + const demoServicesResponse = await request.get("/api/v1/services"); + expect(demoServicesResponse.ok()).toBeTruthy(); + const demoServices: ServiceSnapshot[] = await demoServicesResponse.json(); + expect(demoServices).toHaveLength(9); + const postgres = demoServices.find(({ service }) => service.name === "PostgreSQL"); + expect(postgres).toBeDefined(); + expect(postgres!.latest_probe?.status).toBe("down"); + expect(postgres!.effective_status).toBe("down"); + + const [incidentsResponse, historyResponse, availabilityResponse] = + await Promise.all([ + request.get("/api/v1/incidents"), + request.get(`/api/v1/services/${postgres!.service.id}/history?limit=50`), + request.get(`/api/v1/services/${postgres!.service.id}/availability?hours=24`), + ]); + expect(incidentsResponse.ok()).toBeTruthy(); + const incident = (await incidentsResponse.json()) as { + active: boolean; + candidates: Array<{ + service_name: string; + affected_service_ids: string[]; + }>; + }; + expect(incident.active).toBe(true); + expect(incident.candidates[0]?.service_name).toBe("PostgreSQL"); + expect(incident.candidates[0]?.affected_service_ids).toHaveLength(5); + + expect(historyResponse.ok()).toBeTruthy(); + expect(await historyResponse.json()).toHaveLength(24); + expect(availabilityResponse.ok()).toBeTruthy(); + expect(await availabilityResponse.json()).toMatchObject({ + total_checks: 24, + down_checks: 2, + uptime_percent: 91.67, + }); + + const demoBanner = page.getByRole("status", { name: "Explore demo scenario" }); + await expect(demoBanner).toBeVisible(); + await expect(demoBanner).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByText("Demo scenario", { exact: false })).toBeVisible(); + await expect(page.getByText("PostgreSQL", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Likely root cause", { exact: true })).toBeVisible(); + await expect(page.getByText("Health evidence", { exact: true })).toBeVisible(); + + const workspaces = page.getByRole("navigation", { name: "ServiceMap workspaces" }); + await workspaces.getByRole("button", { name: "Overview" }).click(); + await expect(page.getByRole("heading", { name: "9/9 with current health evidence" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Review unresolved" })).toBeDisabled(); + await page.locator(".overview-lead").getByRole("button", { name: /1 down/i }).click(); + await expect(page.getByRole("combobox", { name: "Status", exact: true })).toHaveValue("down"); + await workspaces.getByRole("button", { name: "Incidents" }).click(); + await expect( + page.getByRole("heading", { name: "Active and historical incidents" }), + ).toBeVisible(); + await page.getByLabel("Search incidents").fill("does not exist"); + await expect(page.getByText("No incidents match these filters.")).toBeVisible(); + await page.getByLabel("Search incidents").fill(""); + await workspaces.getByRole("button", { name: "Topology", exact: true }).click(); + await expect(page.getByRole("region", { name: "Topology commands" })).toBeVisible(); + await page.getByRole("region", { name: "Topology commands" }).locator("summary").filter({ hasText: "Filters" }).click(); + await expect(page.getByRole("combobox", { name: "Filter topology by status" })).toBeVisible(); + const inspector = page.getByRole("complementary"); + await expect(inspector.getByRole("heading", { name: "PostgreSQL" })).toBeVisible(); + await expect(inspector.getByText("Likely root cause", { exact: true })).toBeVisible(); + await expect(inspector.getByText("Health evidence", { exact: true })).toBeVisible(); + + const reset = page.locator("header").getByRole("button", { name: "Reset demo" }); + const resetResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo/reset")); + await reset.click(); + const resetResponse = await resetResponsePromise; + expect(resetResponse.ok()).toBeTruthy(); + expect(await resetResponse.json()).toMatchObject({ + active: true, + root_cause_service_name: "PostgreSQL", + }); + + // A rendered PostgreSQL node can be left over from before Reset completed. + // Wait for the demo region to leave its busy state before attempting Exit. + await expect(demoBanner).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByText("PostgreSQL", { exact: true }).first()).toBeVisible(); + const exit = page.getByRole("button", { name: "Exit demo", exact: true }); + await expect(exit).toBeEnabled(); + + const exitResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo/exit")); + await exit.click(); + const exitResponse = await exitResponsePromise; + expect(exitResponse.ok()).toBeTruthy(); + expect(await exitResponse.json()).toMatchObject({ active: false, restored: true }); + + await expect(demoBanner).toBeHidden({ timeout: 30_000 }); + await expect( + page.getByText("Demo closed. Your previous topology was restored.", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Explore demo", exact: true }), + ).toBeEnabled({ timeout: 30_000 }); + + const restoredDemoResponse = await request.get("/api/v1/demo"); + expect(restoredDemoResponse.ok()).toBeTruthy(); + expect(await restoredDemoResponse.json()).toMatchObject({ active: false }); + + const restoredServicesResponse = await request.get("/api/v1/services"); + expect(restoredServicesResponse.ok()).toBeTruthy(); + expect(topologyOf(await restoredServicesResponse.json())).toEqual(baselineTopology); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..9b62355 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import tseslint from 'typescript-eslint' + +export default [ + { + ignores: ['dist/**', 'coverage/**', 'playwright-report/**', 'test-results/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + globals: globals.browser, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['vite.config.ts', 'playwright.config.ts'], + languageOptions: { + globals: globals.node, + }, + }, +] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fb43d2f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + ServiceMap — Infrastructure Intelligence + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a524271 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /health { + proxy_pass http://backend:8000/health; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9d713a7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3929 @@ +{ + "name": "servicemap-frontend", + "version": "1.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "servicemap-frontend", + "version": "1.6.2", + "dependencies": { + "@xyflow/react": "^12.8.5", + "elkjs": "^0.12.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.57.0", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.70.1", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.6.tgz", + "integrity": "sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.82", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.82", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.82.tgz", + "integrity": "sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.12.0.tgz", + "integrity": "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==", + "license": "EPL-2.0 OR GPL-3.0-or-later" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d547042 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "servicemap-frontend", + "private": true, + "version": "1.6.2", + "type": "module", + "scripts": { + "dev": "vite", + "lint": "eslint . --max-warnings=0", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "@xyflow/react": "^12.8.5", + "elkjs": "^0.12.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.57.0", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.70.1", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..eaa2e3d --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + fullyParallel: false, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:4173", + trace: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "python -m uvicorn app.main:app --app-dir ../backend --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173/health", + reuseExistingServer: !process.env.CI, + env: { + ...process.env, + SERVICEMAP_DB: join(tmpdir(), "servicemap-playwright-v160.db"), + SERVICEMAP_STATIC_DIR: "dist", + SERVICEMAP_AUTH_MODE: "disabled", + SERVICEMAP_PROBE_INTERVAL: "0", + }, + }, +}); diff --git a/frontend/public/brand/servicemap-dark.svg b/frontend/public/brand/servicemap-dark.svg new file mode 100644 index 0000000..a45add6 --- /dev/null +++ b/frontend/public/brand/servicemap-dark.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/brand/servicemap-mark.svg b/frontend/public/brand/servicemap-mark.svg new file mode 100644 index 0000000..9fc7b5e --- /dev/null +++ b/frontend/public/brand/servicemap-mark.svg @@ -0,0 +1 @@ +ServiceMap diff --git a/frontend/public/brand/servicemap-mono.svg b/frontend/public/brand/servicemap-mono.svg new file mode 100644 index 0000000..b2f4a18 --- /dev/null +++ b/frontend/public/brand/servicemap-mono.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/brand/servicemap-wordmark.svg b/frontend/public/brand/servicemap-wordmark.svg new file mode 100644 index 0000000..333b1ec --- /dev/null +++ b/frontend/public/brand/servicemap-wordmark.svg @@ -0,0 +1 @@ +ServiceMapServiceMap diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..b68246f Binary files /dev/null and b/frontend/public/icons/apple-touch-icon.png differ diff --git a/frontend/public/icons/favicon-16.png b/frontend/public/icons/favicon-16.png new file mode 100644 index 0000000..5ba357b Binary files /dev/null and b/frontend/public/icons/favicon-16.png differ diff --git a/frontend/public/icons/favicon-32.png b/frontend/public/icons/favicon-32.png new file mode 100644 index 0000000..c43a47c Binary files /dev/null and b/frontend/public/icons/favicon-32.png differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..afaa9df Binary files /dev/null and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..3b7d98b Binary files /dev/null and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/servicemap-dockerman-256.png b/frontend/public/icons/servicemap-dockerman-256.png new file mode 100644 index 0000000..61a2ee5 Binary files /dev/null and b/frontend/public/icons/servicemap-dockerman-256.png differ diff --git a/frontend/public/icons/servicemap-dockerman-512.png b/frontend/public/icons/servicemap-dockerman-512.png new file mode 100644 index 0000000..3b7d98b Binary files /dev/null and b/frontend/public/icons/servicemap-dockerman-512.png differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..9e2a501 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"ServiceMap","short_name":"ServiceMap","description":"Production infrastructure intelligence","start_url":"/","display":"standalone","background_color":"#07111d","theme_color":"#071722","icons":[{"src":"/icons/icon-192.png","sizes":"192x192","type":"image/png"},{"src":"/icons/icon-512.png","sizes":"512x512","type":"image/png"}]} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b0b102b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,3739 @@ +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 & { + 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([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [busyAll, setBusyAll] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(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("topology"); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [history, setHistory] = useState([]); + const [historyServiceId, setHistoryServiceId] = useState(null); + const [availability, setAvailability] = useState< + Record + >({ 24: null, 168: null, 720: null }); + const [incident, setIncident] = useState(null); + const [overview, setOverview] = useState< + import("./api").OverviewSummary | null + >(null); + const [semanticTopology, setSemanticTopology] = + useState(null); + const [events, setEvents] = useState([]); + const [demo, setDemo] = useState(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( + [], + ); + const [dockerBusy, setDockerBusy] = useState(false); + const [appopsUrl, setAppopsUrl] = useState(""); + const [discoveryRun, setDiscoveryRun] = useState(null); + const [discoveryStatus, setDiscoveryStatus] = + useState(null); + const [discoveryItems, setDiscoveryItems] = useState( + [], + ); + const [discoverySuggestions, setDiscoverySuggestions] = useState< + TopologySuggestion[] + >([]); + const [discoveryBusy, setDiscoveryBusy] = useState(false); + const [probeRepair, setProbeRepair] = useState( + null, + ); + const [editMode, setEditMode] = useState(false); + const [showMiniMap, setShowMiniMap] = useState(false); + const [edgeEvidence, setEdgeEvidence] = useState([]); + const [providerStatuses, setProviderStatuses] = useState([]); + const [topologySnapshots, setTopologySnapshots] = useState([]); + const [topologyDiff, setTopologyDiff] = useState(null); + const [selectedSnapshotId, setSelectedSnapshotId] = useState(null); + const [snapshotCheckedAt, setSnapshotCheckedAt] = useState(0); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [monitoringFilter, setMonitoringFilter] = + useState("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("landscape"); + const [applicationFilter, setApplicationFilter] = useState("all"); + const [componentScope, setComponentScope] = useState<{ + label: string; + memberIds: string[]; + } | null>(null); + const [topologyLocation, setTopologyLocation] = useState( + topologyRoot, + ); + // Compatibility adapter for legacy non-semantic controls. Navigation state + // itself remains the single TopologyLocation value above. + const semanticScope = topologyLocation; + const setSemanticScope = ( + update: + | Pick + | ((scope: TopologyLocation) => Pick), + ) => { + 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(null); + const [selectedIncident, setSelectedIncident] = + useState(null); + const [incidentDetail, setIncidentDetail] = useState( + null, + ); + const flowInstance = useRef | null>( + null, + ); + const sidePanelRef = useRef(null); + const demoFocusApplied = useRef(false); + const [layoutPositions, setLayoutPositions] = useState< + Record + >({}); + const [layoutRoutes, setLayoutRoutes] = useState< + Record + >({}); + + 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(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(); + 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( + () => + 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( + () => + 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, + ), + [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>((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 => ({ + 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 ( +
+
+
+ Relationship inspector +

+ {source?.name ?? "Unknown"} →{" "} + {target?.name ?? selectedEdge.target_ref ?? "Unknown"} +

+
+ + {selectedEdge.active ? "active" : "inactive"} + +
+
+
+
Direction
+
+ {source?.name ?? selectedEdge.source_service_id} depends on{" "} + {target?.name ?? selectedEdge.target_ref ?? "unknown"} +
+
+
+
Confidence
+
{selectedEdge.confidence}
+
+
+
Evidence
+
{selectedEdge.evidence_type}
+
+
+
Locked
+
{selectedEdge.locked ? "Yes" : "No"}
+
+
+
First seen
+
{formatDate(selectedEdge.first_seen_at)}
+
+
+
Last seen
+
{formatDate(selectedEdge.last_seen_at)}
+
+
+

{selectedEdge.explanation}

+
+ + + + +
+
+ ); + }; + + const renderInspector = () => ( +
+
+
+ Inspector +

{selected?.service.name ?? "Select a service"}

+
+ {selected && ( + + {" "} + {statusLabel(effectiveStatus(selected))} + + )} +
+ {!selected ? ( +

+ Select a node to inspect health, history, dependencies and incident + context. +

+ ) : ( + <> +
+ Why {statusLabel(effectiveStatus(selected))} now? + {selected.health_resolution.explanation || "No current health explanation is available."} + + {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)}` + : ""} + +
+ {panel === "topology" && !nodes.some((node) => node.id === selected.service.id) && ( +

The selected service is outside this graph level. Open its application or use Services to inspect the component in context.

+ )} + {selectedIsRootCause && ( +
+ Likely root cause + + {demo?.affected_count ?? + selectedIncident?.affected_service_ids.length ?? + 0}{" "} + downstream services are affected. + + + {( + demo?.affected_service_names ?? + selectedIncident?.affected_service_names ?? + [] + ).join(" · ")} + +
+ )} + {!selectedIsRootCause && impactIds.has(selected.service.id) && ( +
+ Dependency impact + + This service is affected through the current root cause. + +
+ )} + {!hasCurrentNetworkProbe(selected) && ( +
+ {monitoringLabel(selected.service.monitoring_state)} + + {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."} + + {selected.service.probe_reachability === "internal_only" && ( + + Internal Docker endpoint evidence is retained for discovery and is never used as an active probe. + + )} +
+ )} +
+
+
Effective health
+
+ {statusIcon(effectiveStatus(selected))}{" "} + {statusLabel(effectiveStatus(selected))} +
+
+
+
Direct health
+
+ {statusIcon(selected.direct_status)}{" "} + {statusLabel(selected.direct_status)} +
+
+
+
Monitoring
+
+ {selected.service.monitoring_state.replace("_", " ")} ·{" "} + {selected.service.probe_source.replace("_", " ")} +
+
+
+
Reachability
+
+ {selected.service.probe_reachability.replace("_", " ")} ·{" "} + {selected.service.probe_confidence} +
+
+
+
Runtime
+
+ {selected.service.runtime_state ?? "—"} + {selected.service.runtime_health + ? ` · ${selected.service.monitoring_state === "appops_observation" ? "AppOps" : "Docker"} ${selected.service.runtime_health}` + : ""} +
+
+
+
Application
+
+ {selected.service.application ?? selected.service.group ?? "—"} +
+
+
+
Target
+
+ {selected.service.url ?? + `${selected.service.host ?? "—"}${selected.service.port ? `:${selected.service.port}` : ""}`} +
+
+
+
Probe
+
+ {selected.service.kind.toUpperCase()} ·{" "} + {hasCurrentNetworkProbe(selected) && + selected.latest_probe?.latency_ms != null + ? `${selected.latest_probe.latency_ms} ms` + : selected.latest_probe + ? "Historical probe data" + : "—"} +
+
+
+
{hasCurrentNetworkProbe(selected) ? "Last probe" : "Probe history"}
+
+ {selected.latest_probe + ? `${hasCurrentNetworkProbe(selected) ? "Current" : "Historical"} · ${formatDate(selected.latest_probe.checked_at)}` + : "—"} +
+
+
+
Next scheduled
+
{hasCurrentNetworkProbe(selected) ? formatDate(selected.next_probe_at) : "No active probe"}
+
+
+
Availability
+
+ {hasCurrentNetworkProbe(selected) && + hasCurrentHistory && + availability[24]?.uptime_percent != null + ? `${availability[24]?.uptime_percent}% (24h)` + : selected.latest_probe + ? "Historical probe data" + : "—"} +
+
+
+
7d / 30d
+
+ {hasCurrentNetworkProbe(selected) && hasCurrentHistory + ? `${availability[168]?.uptime_percent ?? "—"}% / ${availability[720]?.uptime_percent ?? "—"}%` + : "Not current"} +
+
+
+
Failures / recovery
+
+ {selected.service.failure_streak} /{" "} + {selected.service.recovery_streak} +
+
+
+
Dependencies
+
{selected.service.depends_on.length}
+
+
+
Direct dependents
+
{directDependents.get(selected.service.id)?.length ?? 0}
+
+
+
Group / env
+
+ {selected.service.group ?? "—"} /{" "} + {selected.service.environment ?? "—"} +
+
+
+
Category / lifecycle
+
+ {selected.service.category.replace("_", " ")} /{" "} + {selected.service.lifecycle} +
+
+
+
Discovery source
+
+ {selected.service.source_type + ? `${selected.service.source_type} · ${selected.service.source_ref ?? "—"}` + : "Manual"} +
+
+
+
Last seen
+
{formatDate(selected.service.last_seen_at)}
+
+
+ {hasCurrentNetworkProbe(selected) && selected.latest_probe &&

{selected.latest_probe.detail}

} +
+ Health evidence + + {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("_", " ")}`} + + {selected.health_resolution.explanation} + {selected.health_evidence.slice(0, 8).map((item) => ( + + {item.scope} · {item.provider} · {statusLabel(item.status)} · {item.freshness} · {(item.confidence * 100).toFixed(0)}% · {formatDate(item.observed_at)} + + ))} +
+ {!!selected.sources.length && ( +
+ Sources + {selected.sources.map((source) => ( + + {source.source_type}: {source.source_name ?? source.source_id}{" "} + · {source.lifecycle} + + ))} +
+ )} + {!!selected.evidence.length && ( +
+ Relationship evidence + {selected.evidence + .filter((item) => item.active) + .map((item) => ( + + {item.confidence} · {item.explanation} + + ))} +
+ )} + {hasCurrentHistory && !!history.length && ( +
+ {[...history].reverse().map((item) => ( + + ))} +
+ )} +
+ + {!hasCurrentNetworkProbe(selected) && ( + + )} + + + +
+ + )} +
+ ); + + const renderServiceForm = () => ( +
setFormOpen((event.target as HTMLDetailsElement).open)} + > + + + {editingId ? "Edit service" : "Add service"} + + +
void submit(event)}> + +
+ + +
+ {form.kind === "http" ? ( + + ) : ( +
+ + {form.kind !== "dns" && ( + + )} +
+ )} +
+ + +
+
+ Topology identity +
+ + +
+
+ +

+ Set a value to lock the operator override; leave it blank/unknown + for deterministic discovery metadata. +

+
+
+
+ + +
+ + +
+ + +
+
+ + +
+
+ Dependencies + {services + .filter((item) => item.service.id !== editingId) + .map((item) => ( + + ))} + {!services.length && ( + No services yet. + )} +
+
+ + {editingId && ( + + )} +
+
+
+ ); + + const renderOverview = () => ( +
+
+ Service intelligence +

+ {demo?.active + ? "Northstar incident at a glance" + : "Operational overview"} +

+

+ {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."} +

+
+ + + + +
+ {(overview?.total_services ?? services.length) > services.length && ( +

{(overview?.total_services ?? services.length) - services.length} hidden services are excluded from this operational view.

+ )} + {!!providerStatuses.length && ( +
+ {providerStatuses.map((provider) => ( + + {provider.provider.replace("_", " ")}: {provider.status} + + ))} +
+ )} +
+
+ Incident posture +

+ {incident?.active + ? `${incident.candidates.length} likely root cause${incident.candidates.length === 1 ? "" : "s"}` + : "No active incident"} +

+ {incident?.candidates.length ? ( +
+ {incident.candidates.map((candidate) => ( + + ))} +
+ ) : ( +

+ Healthy dependency graph. Events will appear here when state + changes. +

+ )} +
+
+
+
+ Monitoring coverage +

+ {services.length + ? `${resolvedComponentCount}/${services.length} with current health evidence` + : "No services yet"} +

+
+ +
+
+ {( + [ + "active_probe", + "docker_health", + "appops_observation", + "observed", + "unmonitored", + ] as MonitoringState[] + ).map((state) => ( +
+ {coverageCounts[state] ?? 0} + {monitoringLabel(state)} +
+ ))} +
+

+ {unresolvedComponentCount} services still have unresolved health. Runtime observation alone confirms presence, not health. Active probes require a verified endpoint. +

+
+
+ Infrastructure discovery +

+ {discoverySuggestions.length + ? `${discoverySuggestions.length} open findings across runs` + : "Discovery is up to date"} +

+

+ {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."} +

+
+ + {!demo?.active && ( + + )} +
+
+
+
+ Recent events + +
+
+ {(overview?.recent_events ?? events.slice(0, 8)).map((item) => ( +
+ +
+ {item.message} + {formatDate(item.created_at)} +
+
+ ))} + {!(overview?.recent_events.length || events.length) && ( +
No events yet.
+ )} +
+
+
+ ); + + const renderIncidents = () => ( +
+
+
+
+ Persistent incident journal +

+ {incident?.active + ? "Active and historical incidents" + : "Incident history"} +

+
+ + {incident?.incidents.length ?? 0} records + +
+
+ + +
+

Showing {visibleIncidents.length} of {incident?.incidents.length ?? 0} records.

+
+ {visibleIncidents.map((item) => ( + + ))} + {!visibleIncidents.length && ( +
No incidents match these filters.
+ )} +
+
+
+ {incidentDetail ? ( + <> + Incident timeline +

{incidentDetail.incident.title}

+
+ + {incidentDetail.incident.status} + + {incidentDetail.incident.status === "open" && ( + + )} + +
+

+ {incidentDetail.incident.root_cause_reason} +

+
+ {incidentDetail.timeline.map((item) => ( +
+ +
+ {item.message} + {formatDate(item.created_at)} +
+
+ ))} +
+ + ) : ( +

+ Select an incident to inspect its root cause, acknowledgement state, + timeline and blast radius. +

+ )} +
+
+ ); + + const renderServicesTable = () => ( +
+
+
+ + + + + + + +
+
+
+ Service + Application + Runtime + Monitoring + Health + Latency + 24h +
+ {filteredServices.map((item) => ( + + ))} + {!filteredServices.length && ( +
+ No services match the current filters. +
+ )} +
+
+ {(selected || formOpen) && ( + + )} +
+ ); + + const renderDiscovery = () => { + const diff = discoveryRun?.diff; + const currentRun = discoveryStatus?.current; + return ( +
+
+ Production infrastructure +

Discover the real environment

+

+ 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. +

+
+ + +
+
+ + + +
+ {currentRun && ( +

+ Discovery is currently {currentRun.status}. +

+ )} + {discoveryRun && ( +
+
+ {discoveryRun.status} + last run +
+
+ {diff?.new ?? 0} + new +
+
+ {diff?.changed ?? 0} + changed +
+
+ {diff?.missing ?? 0} + missing +
+
+ {diff?.new_suggestions ?? 0} + new suggestions +
+
+ )} +
+ Canonical topology is deliberate. + + Importing high-confidence services and relationships is explicit. + Low-confidence network hints remain review-only and never affect + health propagation. + +
+
+ Monitoring coverage repair + + Published endpoints are preflighted before activation. Container-only + Docker DNS is discovery evidence only; AppOps, Docker health and + runtime observations retain their own provenance. + +
+ + +
+ {probeRepair && ( + + {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 + + )} + {!!probeRepair?.changes.length && ( +
+ {probeRepair.changes.slice(0, 12).map((change) => ( +
+ {change.service_name} + + {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}` + : ""} + + {change.reason} +
+ ))} +
+ )} +
+
+ + + {discoverySuggestions.length} open suggestions ·{" "} + {discoveryItems.length} observations + +
+ {reconcileConfirm && ( +
+ Import discovery findings? +

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.

+
+
+ )} +
+
+
+
+ Evidence review +

Open discovery suggestions

+
+ + {discoverySuggestions.length} open + +
+

+ Accept individual findings only after checking their evidence. + Rejected findings are retained and will not reappear on every scan. +

+
+ + + +
+

Showing {Math.min(suggestionLimit, visibleSuggestions.length)} of {visibleSuggestions.length} matching findings ({discoverySuggestions.length} open).

+
+ {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 ( +
+
+
+ + {suggestion.suggestion_type} + + + {source?.name ?? + suggestion.proposed_name ?? + suggestion.source_ref ?? + "Discovered item"} + {suggestion.suggestion_type === "relation" && ( + <> + {" "} + {" "} + {target?.name ?? suggestion.target_ref ?? "target"} + + )} + +
+ + {suggestion.confidence} + +
+ {suggestion.suggestion_type === "service" && ( + + {suggestion.proposed_category.replace("_", " ")} ·{" "} + {suggestion.proposed_url ?? + `${suggestion.proposed_host ?? "no endpoint"}${suggestion.proposed_port ? `:${suggestion.proposed_port}` : ""}`} + + )} + {suggestion.suggestion_type === "relation" && ( + + {suggestion.relation_type ?? "relationship"} · canonical + health edge only after acceptance + + )} +
+ Evidence + {suggestion.evidence.map((item) => ( + {item} + ))} +
+
+ + + {suggestion.suggestion_type === "service" && ( + + )} +
+
+ ); + })} + {!visibleSuggestions.length && ( +
+ No findings match these filters. +
+ )} +
+ {visibleSuggestions.length > suggestionLimit && } +
+
+
+
+ Observed infrastructure +

Latest source observations

+
+ + {dockerCandidates.length} Docker candidates + +
+
+ {discoveryItems.slice(0, 24).map((item) => ( +
+ {item.source_name} + {item.change_type} + + {item.lifecycle} · {formatDate(item.observed_at)} + +
+ ))} + {!discoveryItems.length && ( +
+ No completed discovery run yet. +
+ )} +
+
+ + Show candidate inventory ({dockerCandidates.length}) + +
+ {dockerCandidates.slice(0, 80).map((candidate) => ( +
+
+ {candidate.name} + + {candidate.category.replace("_", " ")} · {candidate.state} + {candidate.health ? ` · ${candidate.health}` : ""} + +
+ + {candidate.image} · {candidate.import_state} + + + {candidate.stable_source_ref ?? "no stable identity"} + {candidate.compose_project + ? ` · compose ${candidate.compose_project}` + : ""} + + {candidate.proxy_route_evidence.map((item) => ( + {item} + ))} + {candidate.probe_candidates.map((probe) => ( + + {probe.source.replace("_", " ")} · {probe.reachability} + {probe.url ?? + (probe.host + ? ` · ${probe.host}${probe.port ? `:${probe.port}` : ""}` + : "")} + {probe.preflight_detail ? ` · ${probe.preflight_detail}` : ""} + + ))} +
+ ))} +
+
+
+
+ ); + }; + + 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 | 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 ( +
+ Change intelligence +

Topology evidence over time

+

+ Snapshots are compact canonical topology captures. They compare service identity, lifecycle, semantic metadata and dependencies without changing your topology. +

+ {selectedSnapshot && ( + + )} + {selectedSnapshot && snapshotCheckedAt - new Date(selectedSnapshot.created_at).getTime() > 24 * 60 * 60 * 1000 && ( +

This topology snapshot is more than 24 hours old. Snapshots update after discovery reconciliation; current health evidence is shown in Overview and Services.

+ )} + {selectedSnapshot ? ( +
+
Selected topology snapshot
{formatDate(selectedSnapshot.created_at)}
+
Compared with
{formatDate(previousSnapshot?.created_at)}
+
Services / relationships
{selectedSnapshot.service_count} / {selectedSnapshot.relationship_count}
+
Added
{topologyDiff?.summary.added ?? 0}
+
Changed
{topologyDiff?.summary.changed ?? 0}
+
Removed
{topologyDiff?.summary.removed ?? 0}
+
+ ) :

A topology snapshot will be captured after the next reconciliation.

} + {changes.length ? ( + <> +

Showing {Math.min(changeLimit, changes.length)} of {changes.length} changes. Expand an item to inspect the changed fields.

+
+ {changes.slice(0, changeLimit).map((change) => { + const fields = changedFields(change.before, change.after); + return
+ {change.name}{change.change_type} {change.entity_type} · {fields.length ? `${fields.length} fields changed` : "record added or removed"} + {fields.length > 0 &&
{fields.map((item) =>
{item.field.replaceAll("_", " ")}{item.before} → {item.after}
)}
} + {change.after && } +
; + })} +
+ {changes.length > changeLimit && } + + ) : ( +

No topology difference is recorded between the two latest snapshots.

+ )} +
+ ); + }; + + const renderConfig = () => ( +
+ Configuration v2 +

Import / export

+

+ Imports validate the complete graph atomically before committing. + Existing v1 documents remain importable. +

+
+ + +
+