diff --git a/.bestpractices.json b/.bestpractices.json index 699484e..98bd437 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -1,6 +1,6 @@ { "$schema": "https://bestpractices.coreinfrastructure.org/projects.schema.json", - "_comment": "OpenSSF Best Practices self-assessment for RandomCodeSpace/otelcontext. Project page: https://www.bestpractices.dev/en/projects/12646. This file follows the canonical flat per-criterion schema described in coreinfrastructure/best-practices-badge:docs/bestpractices-json.md so the autofill robot can pre-fill the criteria page on board flip. Each _status / _justification (and _url where required) mirrors the answers submitted to bestpractices.dev. Refresh whenever criteria scoring changes (RAN-58).", + "_comment": "OpenSSF Best Practices self-assessment for RandomCodeSpace/otelcontext. Project page: https://www.bestpractices.dev/en/projects/12646. This file follows the canonical flat per-criterion schema described in coreinfrastructure/best-practices-badge:docs/bestpractices-json.md so the autofill robot can pre-fill the criteria page on board flip. Each _status / _justification mirrors the answers submitted to bestpractices.dev; for url-required criteria the URL is embedded inline in the justification (bestpractices.dev has no separate per-criterion URL field). Refresh whenever criteria scoring changes (RAN-58).", "project_id": 12646, "name": "otelcontext", "description": "Self-hosted OTLP observability platform in a single Go binary \u2014 OTLP gRPC + HTTP ingest, GraphRAG-powered root-cause analysis, multi-tenant storage, and a built-in MCP server for AI agents.", @@ -12,101 +12,78 @@ "project_page_url": "https://www.bestpractices.dev/en/projects/12646", "description_good_status": "Met", "description_good_justification": "See README.md. otelcontext is a self-hosted OTLP observability platform in a single Go binary \u2014 OTLP gRPC + HTTP ingest, GraphRAG-powered root-cause analysis, multi-tenant storage, and a built-in MCP server for AI agents.", - "description_good_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/README.md", "interact_status": "Met", "interact_justification": "GitHub Issues for bug reports and discussion, SECURITY.md for private vulnerability disclosure (GitHub Security Advisories). Both linked from README.", - "interact_url": "https://github.com/RandomCodeSpace/otelcontext/issues", "contribution_status": "Met", "contribution_justification": "Contribution process documented in CONTRIBUTING.md (PR template, conventional commits, test + vet + lint requirements): https://github.com/RandomCodeSpace/otelcontext/blob/main/CONTRIBUTING.md", - "contribution_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/CONTRIBUTING.md", "contribution_requirements_status": "Met", "contribution_requirements_justification": "CONTRIBUTING.md documents PR requirements: `go test -race ./...` passing, `go vet ./...` clean, golangci-lint clean, Semgrep + OSV-Scanner + Trivy + Gitleaks security stack green, Conventional Commit style. See https://github.com/RandomCodeSpace/otelcontext/blob/main/CONTRIBUTING.md", - "contribution_requirements_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/CONTRIBUTING.md", "floss_license_status": "Met", "floss_license_justification": "MIT \u2014 https://opensource.org/licenses/MIT", "floss_license_osi_status": "Met", "floss_license_osi_justification": "MIT is OSI-approved.", "license_location_status": "Met", "license_location_justification": "LICENSE.md at repository root: https://github.com/RandomCodeSpace/otelcontext/blob/main/LICENSE.md", - "license_location_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/LICENSE.md", "documentation_basics_status": "Met", "documentation_basics_justification": "README.md covers what otelcontext is, how to run it, ports/protocols (OTLP gRPC :4317, HTTP :8080), database driver options, TLS bootstrap, and authentication. CLAUDE.md documents the architecture (ingestion, GraphRAG, MCP, storage, configuration).", - "documentation_basics_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/README.md", "documentation_interface_status": "Met", "documentation_interface_justification": "OTLP wire protocol surface (gRPC + HTTP /v1/{traces,logs,metrics}) follows the OpenTelemetry OTLP spec. MCP tool catalogue (21 tools, JSON-RPC 2.0 + SSE) documented in CLAUDE.md. REST API surface and configuration env vars documented in CLAUDE.md.", - "documentation_interface_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/CLAUDE.md", "sites_https_status": "Met", "sites_https_justification": "All project links (README badges, docs, release downloads) use HTTPS via github.com.", "discussion_status": "Met", "discussion_justification": "GitHub Issues serves as the public discussion forum for bug reports and feature proposals.", - "discussion_url": "https://github.com/RandomCodeSpace/otelcontext/issues", "english_status": "Met", "english_justification": "All source comments, documentation, commit messages, and issue discussions are in English.", "maintained_status": "Met", "maintained_justification": "Active development on `main`. Recent fixes landed in commits 89de89a (RAN-56 HIGH/CRITICAL CVE patch), f433139 (RAN-53 OpenSSF stack), 4e0a4a2 (RAN-22). Dependabot weekly + security updates enabled.", - "maintained_url": "https://github.com/RandomCodeSpace/otelcontext/commits/main", "repo_public_status": "Met", "repo_public_justification": "Repository is public.", - "repo_public_url": "https://github.com/RandomCodeSpace/otelcontext", "repo_track_status": "Met", "repo_track_justification": "Git, hosted on GitHub.", - "repo_track_url": "https://github.com/RandomCodeSpace/otelcontext", "repo_interim_status": "Met", "repo_interim_justification": "All commits merged to `main` are publicly visible via GitHub. No batch or secret merges; small, reviewable PRs (e.g. PR #29..#34 land per-RAN-ticket).", "repo_distributed_status": "Met", "repo_distributed_justification": "Git is a distributed VCS; every clone holds full history.", "version_unique_status": "Met", "version_unique_justification": "Each release carries a unique semver tag (e.g. v0.0.11-beta.15) and an immutable git SHA.", - "version_unique_url": "https://github.com/RandomCodeSpace/otelcontext/tags", "version_semver_status": "Met", "version_semver_justification": "MAJOR.MINOR.PATCH-prerelease.N (e.g. v0.0.11-beta.15) \u2014 pre-release semver per https://semver.org. Pre-1.0 development phase.", - "version_semver_url": "https://github.com/RandomCodeSpace/otelcontext/tags", "version_tags_status": "Met", "version_tags_justification": "Tagged releases are published on GitHub.", - "version_tags_url": "https://github.com/RandomCodeSpace/otelcontext/tags", "release_notes_status": "Met", "release_notes_justification": "GitHub auto-generates release notes from PR titles for each tag; releases are listed at https://github.com/RandomCodeSpace/otelcontext/releases . Pre-1.0 cadence ships beta tags from main.", - "release_notes_url": "https://github.com/RandomCodeSpace/otelcontext/releases", "release_notes_vulns_status": "Met", "release_notes_vulns_justification": "Security fixes are landed in tagged commits with `fix(security):` Conventional-Commit prefix and surface in the auto-generated release notes (e.g. commit 89de89a `fix(security): patch HIGH/CRITICAL vulns flagged by OSV+Trivy on PR #34 (RAN-56)`).", "report_process_status": "Met", "report_process_justification": "SECURITY.md documents how to report bugs (GitHub Issues) vs vulnerabilities (private GitHub Security Advisory or email fallback to ak.nitrr13@gmail.com with `[otelcontext security]` subject prefix). See https://github.com/RandomCodeSpace/otelcontext/blob/main/SECURITY.md", - "report_process_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/SECURITY.md", "report_tracker_status": "Met", "report_tracker_justification": "GitHub Issues is the public bug tracker.", - "report_tracker_url": "https://github.com/RandomCodeSpace/otelcontext/issues", "report_responses_status": "Met", "report_responses_justification": "Maintainer responds to reported issues within 14 days; SECURITY.md commits to a 5 business-day acknowledgement for security reports and a 10 business-day triage decision for High/Critical issues.", "enhancement_responses_status": "Met", "enhancement_responses_justification": "Enhancement requests are triaged via GitHub Issues; maintainer responds within 14 days.", "report_archive_status": "Met", - "report_archive_justification": "GitHub Issues is the public, searchable archive of bug reports, open and closed: https://github.com/RandomCodeSpace/otelcontext/issues?q=is%3Aissue . GitHub Security Advisories archives coordinated-disclosure reports.", - "report_archive_url": "https://github.com/RandomCodeSpace/otelcontext/issues?q=is%3Aissue", + "report_archive_justification": "GitHub Issues is the public, searchable archive of bug reports, open and closed: https://github.com/RandomCodeSpace/otelcontext/issues . GitHub Security Advisories archives coordinated-disclosure reports.", "vulnerability_report_process_status": "Met", "vulnerability_report_process_justification": "SECURITY.md is the canonical disclosure entry point: preferred channel is a private GitHub Security Advisory, with email fallback. Acknowledgement SLA 5 business days; triage SLA 10 business days for High/Critical; default embargo 90 days or first patched release. See https://github.com/RandomCodeSpace/otelcontext/blob/main/SECURITY.md", - "vulnerability_report_process_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/SECURITY.md", "vulnerability_report_private_status": "Met", "vulnerability_report_private_justification": "GitHub private vulnerability reporting is the preferred channel; advisories are filed at https://github.com/RandomCodeSpace/otelcontext/security/advisories/new", - "vulnerability_report_private_url": "https://github.com/RandomCodeSpace/otelcontext/security/advisories/new", "vulnerability_report_response_status": "Met", "vulnerability_report_response_justification": "SECURITY.md commits to acknowledgement within 5 business days and a fix triage decision within 10 business days for High/Critical.", "build_status": "Met", "build_justification": "Single-command build: `go build -o otelcontext .` (or `make build`). CI builds every PR via `.github/workflows/ci.yml`.", - "build_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.github/workflows/ci.yml", "build_common_tools_status": "Met", "build_common_tools_justification": "Go toolchain (go 1.25.9 per go.mod) + Node/npm 20+ for the embedded React UI. Both are widely available and standard.", "build_floss_tools_status": "Met", "build_floss_tools_justification": "Go (BSD-3-Clause), Node/npm (MIT/ISC), GNU Make (GPL). All FLOSS.", "test_status": "Met", "test_justification": "Automated test suite under internal/* runs on every push (`go test -race ./...`). Coverage spans graphrag, ingest, storage, queue, vectordb, telemetry, tls, compress, config, api.", - "test_url": "https://github.com/RandomCodeSpace/otelcontext/actions/workflows/ci.yml", "test_invocation_status": "Met", "test_invocation_justification": "`go test -race ./...` \u2014 documented in CONTRIBUTING.md and exercised by `.github/workflows/ci.yml`.", "test_most_status": "Met", "test_most_justification": "Tests cover the core packages: internal/graphrag (drain, builder, investigation, migrate, tenant), internal/ingest (otlp_grpc_limits, otlp_e2e, otlp_http_gzip), internal/storage, internal/queue (dlq), internal/vectordb, internal/tls (selfsigned, filelock), internal/api, internal/telemetry, internal/config, internal/compress.", "test_continuous_integration_status": "Met", "test_continuous_integration_justification": "CI runs full test suite (`go test -race -timeout 180s ./...`) on every PR and every push to main.", - "test_continuous_integration_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.github/workflows/ci.yml", "test_policy_status": "Met", "test_policy_justification": "CONTRIBUTING.md requires tests for new features and regression tests for bug fixes; PR review enforces it.", "tests_are_added_status": "Met", @@ -115,7 +92,6 @@ "tests_documented_added_justification": "CONTRIBUTING.md documents the test-with-every-change expectation.", "warnings_status": "Met", "warnings_justification": "`go vet ./...` and `golangci-lint` (.golangci.yml) run on every CI build; any warning fails the build.", - "warnings_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.golangci.yml", "warnings_fixed_status": "Met", "warnings_fixed_justification": "All vet/lint warnings resolved on `main`; no suppressions without justification.", "warnings_strict_status": "Met", @@ -124,7 +100,6 @@ "know_secure_design_justification": "Maintainer applies defense-in-depth: per-tenant context propagation across HTTP/gRPC/MCP layers (internal/api/tenant_middleware.go, internal/ingest/otlp.go), tenant-scoped reads in internal/storage and internal/graphrag, signed-commit setup for `main` (scripts/setup-git-signed.sh), strict TLS (sslmode require/verify-ca/verify-full enforced when DB_AZURE_AUTH=true in internal/storage), parameterised queries via GORM, secrets via env (no in-repo secrets enforced by gitleaks).", "know_common_errors_status": "Met", "know_common_errors_justification": "Familiar with OWASP Top 10 and CWE-22/78/79/89/918. Semgrep `p/owasp-top-ten` + `p/security-audit` + `p/golang` rulesets run on every PR; OSV-Scanner against go.mod + ui/package-lock.json blocks merge on High/Critical.", - "know_common_errors_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.github/workflows/security.yml", "crypto_published_status": "Met", "crypto_published_justification": "Only published algorithms used: Go crypto/tls, crypto/rand, crypto/sha256. No custom crypto.", "crypto_call_status": "Met", @@ -149,15 +124,12 @@ "delivery_unsigned_justification": "Source is delivered exclusively via HTTPS-served git from github.com. No unsigned binary delivery channel: pre-1.0 betas are tagged commits (verifiable by SHA), and binary build is in the user's pipeline (`go build`) \u2014 there is no pre-built binary distribution in scope. Signed-commit hardening on `main` is configured via scripts/setup-git-signed.sh.", "vulnerabilities_fixed_60_days_status": "Met", "vulnerabilities_fixed_60_days_justification": "Most recent example: commit 89de89a (`fix(security): patch HIGH/CRITICAL vulns flagged by OSV+Trivy on PR #34 (RAN-56)`) landed within 60 days of the OSV/Trivy advisory. Dependabot + OSV-Scanner + Trivy run weekly.", - "vulnerabilities_fixed_60_days_url": "https://github.com/RandomCodeSpace/otelcontext/security/advisories", "vulnerabilities_critical_fixed_status": "Met", "vulnerabilities_critical_fixed_justification": "Zero High/Critical open. RAN-56 patched all OSV+Trivy HIGH/CRITICAL findings on PR #34 (commit 89de89a).", "no_leaked_credentials_status": "Met", "no_leaked_credentials_justification": "Gitleaks job in `.github/workflows/security.yml` scans the full git history on every PR and push; merge blocked on any finding. GitHub repo-level secret scanning + push protection enabled.", - "no_leaked_credentials_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.github/workflows/security.yml", "static_analysis_status": "Met", "static_analysis_justification": "Semgrep (p/security-audit + p/owasp-top-ten + p/golang) + golangci-lint run on every PR and push. Merge blocked on `--severity ERROR`.", - "static_analysis_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.github/workflows/security.yml", "static_analysis_common_vulnerabilities_status": "Met", "static_analysis_common_vulnerabilities_justification": "Semgrep `p/owasp-top-ten` and `p/security-audit` rulesets cover CWE-22/78/79/89/918 and the broader OWASP Top 10. `p/golang` adds Go-specific issue patterns.", "static_analysis_fixed_status": "Met", @@ -166,13 +138,12 @@ "static_analysis_often_justification": "Semgrep + golangci-lint run on every push to main and every PR; Dependabot weekly cadence catches advisory drift between commits.", "dynamic_analysis_status": "Met", "dynamic_analysis_justification": "`go test -race -timeout 180s ./...` runs in CI on every PR (.github/workflows/ci.yml). The race detector instruments concurrency-heavy paths (graphrag event workers, retention scheduler, DLQ replay) and fails the build on any race.", - "dynamic_analysis_url": "https://github.com/RandomCodeSpace/otelcontext/blob/main/.github/workflows/ci.yml", "dynamic_analysis_unsafe_status": "N/A", "dynamic_analysis_unsafe_justification": "N/A \u2014 Go is memory-safe. The codebase contains no `unsafe.Pointer` in application code; only stdlib-internal use via the standard libraries.", "dynamic_analysis_enable_assertions_status": "Met", "dynamic_analysis_enable_assertions_justification": "Go panics-on-invariants are used at boundary conditions (e.g. config validation in internal/config). The `-race` detector is the Go equivalent of runtime concurrency assertions and is enabled in CI.", "dynamic_analysis_fixed_status": "Met", "dynamic_analysis_fixed_justification": "Race-detector and `go test` failures gate every PR; no known unfixed dynamic-analysis findings on `main`.", - "self_assessment_date": "2026-04-26", + "self_assessment_date": "2026-06-18", "self_assessment_author": "TechLead (RAN-58)" } \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 4d47b4f..d064b98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -185,11 +185,12 @@ type Config struct { IngestPipelineWorkers int // default 8 worker goroutines // IngestPipelinePerTenantCap caps in-flight batches per tenant so a noisy // tenant cannot starve siblings of fresh queue slots when fullness is - // below the soft-backpressure threshold. 0 (default) disables — single- - // tenant deployments need no cap. Operators on multi-tenant deployments - // should set INGEST_PIPELINE_PER_TENANT_CAP to roughly Capacity/N where - // N is the expected number of concurrently-active tenants, with some - // headroom (e.g. 2× the fair-share value) for short bursts. + // below the soft-backpressure threshold. When unset it defaults to ~30% of + // the resolved queue size (see Load) so multi-tenant deployments are + // protected out of the box; an explicit INGEST_PIPELINE_PER_TENANT_CAP=0 + // disables the cap for single-tenant deployments. Operators can instead + // pin it to roughly Capacity/N where N is the expected number of + // concurrently-active tenants, with headroom for short bursts. IngestPipelinePerTenantCap int // TLS (HTTP + gRPC). When both paths are set, TLS is enabled on both servers. @@ -377,6 +378,16 @@ func Load(customPath string) (*Config, error) { AllowSqliteProd: parseTruthy(getEnv("OTELCONTEXT_ALLOW_SQLITE_PROD", "")), } applyDriverDefaults(cfg) + + // Derive a sane per-tenant ingest cap when the operator did not set one. + // Run AFTER applyDriverDefaults so it tracks the (possibly SQLite-adjusted) + // queue size: ~30% of the queue lets a single tenant burst but stops one + // noisy tenant from monopolising every slot at 100–200 services. An explicit + // INGEST_PIPELINE_PER_TENANT_CAP=0 is respected as "disabled". + if _, set := os.LookupEnv("INGEST_PIPELINE_PER_TENANT_CAP"); !set && cfg.IngestPipelinePerTenantCap == 0 { + cfg.IngestPipelinePerTenantCap = cfg.IngestPipelineQueueSize * 30 / 100 + } + return cfg, nil } diff --git a/internal/graphrag/adjacency_index_test.go b/internal/graphrag/adjacency_index_test.go new file mode 100644 index 0000000..386746e --- /dev/null +++ b/internal/graphrag/adjacency_index_test.go @@ -0,0 +1,169 @@ +package graphrag + +import ( + "context" + "fmt" + "sort" + "testing" + "time" +) + +// edgeIDs collects from→to keys so two []*Edge slices can be compared +// order-independently. +func edgeIDs(edges []*Edge) []string { + out := make([]string, 0, len(edges)) + for _, e := range edges { + out = append(out, e.FromID+"->"+e.ToID) + } + sort.Strings(out) + return out +} + +func fullScanFrom(s *ServiceStore, service string) []*Edge { + var out []*Edge + for _, e := range s.Edges { + if e.Type == EdgeCalls && e.FromID == service { + out = append(out, e) + } + } + return out +} + +func fullScanTo(s *ServiceStore, service string) []*Edge { + var out []*Edge + for _, e := range s.Edges { + if e.Type == EdgeCalls && e.ToID == service { + out = append(out, e) + } + } + return out +} + +// TestAdjacencyIndexMatchesFullScan asserts the indexed CallEdgesFrom/To return +// exactly the same CALLS edges a full Edges-map scan would, across both the +// aggregating UpsertCallEdge path and the existence-only EnsureCallEdge path. +func TestAdjacencyIndexMatchesFullScan(t *testing.T) { + g := newTestGraphRAG(t) + now := time.Now() + s := g.storesForTenant("t").service + + // Aggregating path. + s.UpsertCallEdge("a", "b", 5, false, now) + s.UpsertCallEdge("a", "c", 5, true, now) + s.UpsertCallEdge("b", "c", 5, false, now) + // Existence-only (pre-sample topology observer) path. + s.EnsureService("d", now) + s.EnsureCallEdge("a", "d", now) + s.EnsureCallEdge("d", "c", now) + // Repeat upserts must not duplicate index entries. + s.UpsertCallEdge("a", "b", 5, false, now) + s.EnsureCallEdge("a", "d", now) + + for _, svc := range []string{"a", "b", "c", "d"} { + gotFrom := edgeIDs(s.CallEdgesFrom(svc)) + wantFrom := edgeIDs(fullScanFrom(s, svc)) + if fmt.Sprint(gotFrom) != fmt.Sprint(wantFrom) { + t.Errorf("CallEdgesFrom(%q) = %v, full scan = %v", svc, gotFrom, wantFrom) + } + gotTo := edgeIDs(s.CallEdgesTo(svc)) + wantTo := edgeIDs(fullScanTo(s, svc)) + if fmt.Sprint(gotTo) != fmt.Sprint(wantTo) { + t.Errorf("CallEdgesTo(%q) = %v, full scan = %v", svc, gotTo, wantTo) + } + } + + // a→b should be a single edge despite the repeated upsert. + if from := s.CallEdgesFrom("a"); len(from) != 3 { // a→b, a→c, a→d + t.Fatalf("CallEdgesFrom(a) = %d edges, want 3", len(from)) + } +} + +// TestOperationsForServiceIndex asserts the per-service operations index matches +// a filter over the full Operations map. +func TestOperationsForServiceIndex(t *testing.T) { + g := newTestGraphRAG(t) + now := time.Now() + s := g.storesForTenant("t").service + + s.UpsertOperation("a", "GET /x", 1, false, now) + s.UpsertOperation("a", "GET /y", 1, false, now) + s.UpsertOperation("b", "POST /z", 1, false, now) + s.UpsertOperation("a", "GET /x", 1, true, now) // repeat: no new index entry + + if ops := s.OperationsForService("a"); len(ops) != 2 { + t.Fatalf("OperationsForService(a) = %d, want 2", len(ops)) + } + if ops := s.OperationsForService("b"); len(ops) != 1 { + t.Fatalf("OperationsForService(b) = %d, want 1", len(ops)) + } + if ops := s.OperationsForService("missing"); ops != nil { + t.Fatalf("OperationsForService(missing) = %v, want nil", ops) + } +} + +// TestServiceMapAroundBounded asserts the focused service map only returns the +// subgraph reachable within depth hops, while the unfocused full map returns all +// services. +func TestServiceMapAroundBounded(t *testing.T) { + g := newTestGraphRAG(t) + now := time.Now() + ctx := context.Background() + s := g.storesFor(ctx).service + + // Chain a→b→c→d plus an isolated island e→f. + for _, sv := range []string{"a", "b", "c", "d", "e", "f"} { + s.UpsertService(sv, 1, false, now) + } + s.UpsertCallEdge("a", "b", 1, false, now) + s.UpsertCallEdge("b", "c", 1, false, now) + s.UpsertCallEdge("c", "d", 1, false, now) + s.UpsertCallEdge("e", "f", 1, false, now) + + // depth 1 from b → {b, a, c}. + got := g.ServiceMapAround(ctx, "b", 1) + if len(got) != 3 { + t.Fatalf("ServiceMapAround(b, 1) = %d entries, want 3", len(got)) + } + + // depth 2 from a → {a, b, c} (downstream only reaches c at 2 hops). + got = g.ServiceMapAround(ctx, "a", 2) + if len(got) != 3 { + t.Fatalf("ServiceMapAround(a, 2) = %d entries, want 3", len(got)) + } + + // Unknown seed → nil. + if got := g.ServiceMapAround(ctx, "nope", 3); got != nil { + t.Fatalf("ServiceMapAround(nope) = %v, want nil", got) + } + + // Full map returns every service (island included). + if full := g.ServiceMap(ctx, 0); len(full) != 6 { + t.Fatalf("ServiceMap = %d entries, want 6", len(full)) + } +} + +// BenchmarkServiceMap exercises the full topology dump at 200 services / ~800 +// edges to confirm the adjacency index keeps it linear. +func BenchmarkServiceMap(b *testing.B) { + g := newTestGraphRAG(b) + now := time.Now() + ctx := context.Background() + s := g.storesFor(ctx).service + + const n = 200 + for i := 0; i < n; i++ { + s.UpsertService(fmt.Sprintf("svc-%d", i), 1, false, now) + } + // ~4 outgoing edges per service. + for i := 0; i < n; i++ { + for k := 1; k <= 4; k++ { + s.UpsertCallEdge(fmt.Sprintf("svc-%d", i), fmt.Sprintf("svc-%d", (i+k)%n), 1, false, now) + } + s.UpsertOperation(fmt.Sprintf("svc-%d", i), "GET /", 1, false, now) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = g.ServiceMap(ctx, 0) + } +} diff --git a/internal/graphrag/builder_test.go b/internal/graphrag/builder_test.go index 5c26222..28bfd97 100644 --- a/internal/graphrag/builder_test.go +++ b/internal/graphrag/builder_test.go @@ -28,7 +28,7 @@ func newTestRepo(t *testing.T) *storage.Repository { // newTestGraphRAG constructs a GraphRAG usable in tests without a repo or // vectordb. The event workers are started so ingestion callbacks process // events asynchronously; tests must call Stop() via t.Cleanup. -func newTestGraphRAG(t *testing.T) *GraphRAG { +func newTestGraphRAG(t testing.TB) *GraphRAG { t.Helper() g := New(nil, nil, nil, DefaultConfig()) // Start only the event workers — the background refresh/snapshot/anomaly diff --git a/internal/graphrag/queries.go b/internal/graphrag/queries.go index 595db18..f0dedd8 100644 --- a/internal/graphrag/queries.go +++ b/internal/graphrag/queries.go @@ -383,23 +383,74 @@ func (g *GraphRAG) ServiceMap(ctx context.Context, depth int) []ServiceMapEntry result := make([]ServiceMapEntry, 0, len(services)) for _, svc := range services { - entry := ServiceMapEntry{ - Service: svc, - CallsTo: stores.service.CallEdgesFrom(svc.Name), - CalledBy: stores.service.CallEdgesTo(svc.Name), - } + result = append(result, g.serviceMapEntry(stores, svc)) + } + + return result +} + +// serviceMapEntry builds the topology entry for one service via the adjacency +// indexes (O(deg + ops_of_svc), not a full-store scan). +func (g *GraphRAG) serviceMapEntry(stores *tenantStores, svc *ServiceNode) ServiceMapEntry { + return ServiceMapEntry{ + Service: svc, + CallsTo: stores.service.CallEdgesFrom(svc.Name), + CalledBy: stores.service.CallEdgesTo(svc.Name), + Operations: stores.service.OperationsForService(svc.Name), + } +} + +// ServiceMapAround returns the subgraph reachable from seed within depth hops in +// either direction (downstream via CALLS-from, upstream via CALLS-to). It bounds +// both the compute and the payload for focused "map around service X" queries at +// 100–200 services; callers wanting the full map use ServiceMap. depth<=0 falls +// back to a single hop so a focus query always returns the seed + neighbours. +func (g *GraphRAG) ServiceMapAround(ctx context.Context, seed string, depth int) []ServiceMapEntry { + if depth <= 0 { + depth = 1 + } + stores := g.storesFor(ctx) + if _, ok := stores.service.GetService(seed); !ok { + return nil + } + + visited := map[string]bool{seed: true} + type queueItem struct { + svc string + depth int + } + queue := []queueItem{{seed, 0}} + order := []string{seed} - // Get operations for this service - stores.service.mu.RLock() - for _, op := range stores.service.Operations { - if op.Service == svc.Name { - entry.Operations = append(entry.Operations, op) + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + if item.depth >= depth { + continue + } + neighbours := stores.service.CallEdgesFrom(item.svc) + neighbours = append(neighbours, stores.service.CallEdgesTo(item.svc)...) + for _, e := range neighbours { + next := e.ToID + if e.FromID != item.svc { + next = e.FromID } + if visited[next] { + continue + } + visited[next] = true + order = append(order, next) + queue = append(queue, queueItem{next, item.depth + 1}) } - stores.service.mu.RUnlock() - - result = append(result, entry) } + result := make([]ServiceMapEntry, 0, len(order)) + for _, name := range order { + svc, ok := stores.service.GetService(name) + if !ok { + continue + } + result = append(result, g.serviceMapEntry(stores, svc)) + } return result } diff --git a/internal/graphrag/store.go b/internal/graphrag/store.go index 803cdeb..00383e7 100644 --- a/internal/graphrag/store.go +++ b/internal/graphrag/store.go @@ -64,13 +64,25 @@ type ServiceStore struct { Services map[string]*ServiceNode // key: service name Operations map[string]*OperationNode // key: service|operation Edges map[string]*Edge // key: type|from|to + // Adjacency indexes over the CALLS edges and operations, maintained at the + // upsert sites so ServiceMap/ImpactAnalysis don't rescan the whole Edges map + // per service (O(N·E)→O(N+E)). Safe as plain slices because ServiceStore + // CALLS edges and operations are append-only at runtime — the only + // .Edges deletions live in TraceStore/SignalStore/AnomalyStore, and the + // store is never replaced or cleared after newServiceStore(). + edgesByFrom map[string][]*Edge // CALLS edges keyed by FromID + edgesByTo map[string][]*Edge // CALLS edges keyed by ToID + opsByService map[string][]*OperationNode // operations keyed by service } func newServiceStore() *ServiceStore { return &ServiceStore{ - Services: make(map[string]*ServiceNode), - Operations: make(map[string]*OperationNode), - Edges: make(map[string]*Edge), + Services: make(map[string]*ServiceNode), + Operations: make(map[string]*OperationNode), + Edges: make(map[string]*Edge), + edgesByFrom: make(map[string][]*Edge), + edgesByTo: make(map[string][]*Edge), + opsByService: make(map[string][]*OperationNode), } } @@ -179,6 +191,7 @@ func (s *ServiceStore) UpsertOperation(service, operation string, durationMs flo LastSeen: ts, } s.Operations[key] = op + s.opsByService[service] = append(s.opsByService[service], op) } op.CallCount++ op.TotalMs += durationMs @@ -217,6 +230,8 @@ func (s *ServiceStore) UpsertCallEdge(source, target string, durationMs float64, ToID: target, } s.Edges[ek] = e + s.edgesByFrom[source] = append(s.edgesByFrom[source], e) + s.edgesByTo[target] = append(s.edgesByTo[target], e) } e.CallCount++ e.TotalMs += durationMs @@ -270,12 +285,15 @@ func (s *ServiceStore) EnsureCallEdge(source, target string, ts time.Time) bool if _, ok := s.Edges[ek]; ok { return false } - s.Edges[ek] = &Edge{ + e := &Edge{ Type: EdgeCalls, FromID: source, ToID: target, UpdatedAt: ts, } + s.Edges[ek] = e + s.edgesByFrom[source] = append(s.edgesByFrom[source], e) + s.edgesByTo[target] = append(s.edgesByTo[target], e) return true } @@ -309,24 +327,38 @@ func (s *ServiceStore) AllEdges() []*Edge { func (s *ServiceStore) CallEdgesFrom(service string) []*Edge { s.mu.RLock() defer s.mu.RUnlock() - var out []*Edge - for _, e := range s.Edges { - if e.Type == EdgeCalls && e.FromID == service { - out = append(out, e) - } + src := s.edgesByFrom[service] + if len(src) == 0 { + return nil } + out := make([]*Edge, len(src)) + copy(out, src) return out } func (s *ServiceStore) CallEdgesTo(service string) []*Edge { s.mu.RLock() defer s.mu.RUnlock() - var out []*Edge - for _, e := range s.Edges { - if e.Type == EdgeCalls && e.ToID == service { - out = append(out, e) - } + src := s.edgesByTo[service] + if len(src) == 0 { + return nil + } + out := make([]*Edge, len(src)) + copy(out, src) + return out +} + +// OperationsForService returns the operations exposed by a service via the +// per-service index, avoiding a full Operations-map scan per service. +func (s *ServiceStore) OperationsForService(service string) []*OperationNode { + s.mu.RLock() + defer s.mu.RUnlock() + src := s.opsByService[service] + if len(src) == 0 { + return nil } + out := make([]*OperationNode, len(src)) + copy(out, src) return out } diff --git a/internal/mcp/cache.go b/internal/mcp/cache.go index 4cf85bf..2814c23 100644 --- a/internal/mcp/cache.go +++ b/internal/mcp/cache.go @@ -26,6 +26,10 @@ var cacheableTools = map[string]struct{}{ "get_service_health": {}, "root_cause_analysis": {}, "impact_analysis": {}, + // A completed trace's topology is immutable, so memoizing by trace_id is + // safe and high-hit for repeated drill-downs; only non-error results are + // cached, so "trace not found" is never memoized. + "trace_graph": {}, } // isCacheable reports whether a tool name is on the cache whitelist. diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 6e399b5..5722d08 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -153,7 +153,7 @@ func (s *Server) toolGetServiceHealth(ctx context.Context, args map[string]any) } for _, entry := range s.graphRAG.ServiceMap(mcpCtx(ctx), 0) { if entry.Service != nil && entry.Service.Name == svcName { - data, err := json.MarshalIndent(entry, "", " ") + data, err := json.Marshal(entry) if err != nil { return errorResult(fmt.Sprintf("failed to marshal service health: %v", err)) } @@ -242,7 +242,7 @@ func (s *Server) toolSearchLogs(ctx context.Context, args map[string]any) ToolCa "count": len(logs), "entries": toLogSummaries(logs), } - data, err := json.MarshalIndent(result, "", " ") + data, err := json.Marshal(result) if err != nil { return errorResult(fmt.Sprintf("failed to marshal search results: %v", err)) } @@ -256,8 +256,16 @@ func (s *Server) toolGetServiceMap(ctx context.Context, args map[string]any) Too return errorResult(errGraphRAGNotInit) } depth := argInt(args, "depth", 3) - result := s.graphRAG.ServiceMap(mcpCtx(ctx), depth) - data, err := json.MarshalIndent(result, "", " ") + // When a focus service is supplied, return only the subgraph reachable within + // `depth` hops (bounds compute + payload at 100–200 services). With no focus, + // return the full topology so existing clients are unaffected. + var result []graphrag.ServiceMapEntry + if svc, _ := args["service"].(string); svc != "" { + result = s.graphRAG.ServiceMapAround(mcpCtx(ctx), svc, depth) + } else { + result = s.graphRAG.ServiceMap(mcpCtx(ctx), depth) + } + data, err := json.Marshal(result) if err != nil { return errorResult(fmt.Sprintf("failed to marshal service map: %v", err)) } @@ -279,13 +287,13 @@ func (s *Server) toolTraceGraph(ctx context.Context, args map[string]any) ToolCa if err != nil { return errorResult(fmt.Sprintf("trace not found: %v", err)) } - data, err := json.MarshalIndent(trace, "", " ") + data, err := json.Marshal(trace) if err != nil { return errorResult(fmt.Sprintf("failed to marshal trace: %v", err)) } return resourceResult(resourceURIPrefix+"traces/"+traceID, httpconst.ContentTypeJSON, string(data)) } - data, err := json.MarshalIndent(spans, "", " ") + data, err := json.Marshal(spans) if err != nil { return errorResult(fmt.Sprintf("failed to marshal trace graph: %v", err)) } @@ -302,7 +310,7 @@ func (s *Server) toolImpactAnalysis(ctx context.Context, args map[string]any) To } depth := argInt(args, "depth", 5) result := s.graphRAG.ImpactAnalysis(mcpCtx(ctx), svcName, depth) - data, err := json.MarshalIndent(result, "", " ") + data, err := json.Marshal(result) if err != nil { return errorResult(fmt.Sprintf("failed to marshal impact analysis: %v", err)) } @@ -321,7 +329,7 @@ func (s *Server) toolRootCauseAnalysis(ctx context.Context, args map[string]any) parseTimeRange(args, "time_range", &since) causes := s.graphRAG.RootCauseAnalysis(mcpCtx(ctx), svcName, since) - data, err := json.MarshalIndent(causes, "", " ") + data, err := json.Marshal(causes) if err != nil { return errorResult(fmt.Sprintf("failed to marshal root cause analysis: %v", err)) } @@ -342,7 +350,7 @@ func (s *Server) toolGetAnomalyTimeline(ctx context.Context, args map[string]any } else { anomalies = s.graphRAG.AnomalyTimeline(mcpCtx(ctx), since) } - data, err := json.MarshalIndent(anomalies, "", " ") + data, err := json.Marshal(anomalies) if err != nil { return errorResult(fmt.Sprintf("failed to marshal anomaly timeline: %v", err)) } diff --git a/internal/storage/factory.go b/internal/storage/factory.go index 857805d..ec225ef 100644 --- a/internal/storage/factory.go +++ b/internal/storage/factory.go @@ -156,12 +156,22 @@ func NewDatabase(driver, dsn string) (*gorm.DB, error) { log.Printf("📊 SQLite Optimization: MaxOpen=1, WAL Mode=Enabled") default: maxOpen := getEnvPoolInt("DB_MAX_OPEN_CONNS", 50) - maxIdle := getEnvPoolInt("DB_MAX_IDLE_CONNS", 10) + // Default idle pool raised 10→25: under 100–200 services the concurrent + // readers (MCP semaphore up to 32) + ingest workers + retention + + // partition scheduler churn far more than 10 idle conns, forcing constant + // reconnects. 25 keeps a warm pool roughly half of MaxOpen. + maxIdle := getEnvPoolInt("DB_MAX_IDLE_CONNS", 25) lifetime := getEnvPoolDuration("DB_CONN_MAX_LIFETIME", time.Hour) + idleTime := getEnvPoolDuration("DB_CONN_MAX_IDLE_TIME", 10*time.Minute) sqlDB.SetMaxOpenConns(maxOpen) sqlDB.SetMaxIdleConns(maxIdle) sqlDB.SetConnMaxLifetime(lifetime) - log.Printf("📊 DB Pool Configured: MaxOpen=%d, MaxIdle=%d, Driver=%s", maxOpen, maxIdle, driver) // #nosec G706 -- log.Printf with controlled config ints and enum driver + sqlDB.SetConnMaxIdleTime(idleTime) + // driver is operator-set config (DB_DRIVER); strip CR/LF before logging so a + // malformed value can't forge log lines (CWE-117 / go/log-injection). The + // counts and duration are non-injectable. + safeDriver := strings.NewReplacer("\r", "", "\n", "").Replace(driver) + log.Printf("📊 DB Pool Configured: MaxOpen=%d, MaxIdle=%d, MaxIdleTime=%s, Driver=%s", maxOpen, maxIdle, idleTime, safeDriver) // Entra override: Azure AD tokens are ~60-90 min TTL. Cap pooled-conn // lifetime below that window so we never present a stale token on @@ -444,6 +454,21 @@ func AutoMigrateModelsWithOptions(db *gorm.DB, driver string, opts MigrateOption log.Println("🔎 Postgres: pg_trgm extension verified; GIN indexes ready on logs.body and logs.service_name") } } + + // BRIN indexes on the time columns that drive the by-age retention DELETE. + // spans/traces are append-mostly and physically time-ordered, so BRIN is a + // tiny (kilobytes) index that lets the planner range-prune the 7-day purge + // without the write/space cost of a B-tree. Best-effort: a failure here + // only means retention falls back to a fuller scan, so we log and continue. + brinIndexes := []struct{ name, ddl string }{ + {"idx_spans_start_time_brin", "CREATE INDEX IF NOT EXISTS idx_spans_start_time_brin ON spans USING BRIN (start_time)"}, + {"idx_traces_timestamp_brin", "CREATE INDEX IF NOT EXISTS idx_traces_timestamp_brin ON traces USING BRIN (timestamp)"}, + } + for _, idx := range brinIndexes { + if err := db.Exec(idx.ddl).Error; err != nil { + log.Printf("⚠️ index creation failed: %s: %v", idx.name, err) + } + } } return nil diff --git a/internal/storage/models.go b/internal/storage/models.go index 4993a6c..fd0f50c 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -119,18 +119,22 @@ type Trace struct { // the legacy idx_spans_tenant_trace as a left-prefix; the legacy index // is retained for query-plan stability across upgrades. type Span struct { - ID uint `gorm:"primaryKey" json:"id"` - TenantID string `gorm:"size:64;default:'default';not null;index:idx_spans_tenant_trace,priority:1;index:idx_spans_tenant_service_start,priority:1;uniqueIndex:idx_spans_tenant_trace_span,priority:1" json:"tenant_id"` - TraceID string `gorm:"size:32;not null;index:idx_spans_tenant_trace,priority:2;uniqueIndex:idx_spans_tenant_trace_span,priority:2" json:"trace_id"` - SpanID string `gorm:"size:16;not null;uniqueIndex:idx_spans_tenant_trace_span,priority:3" json:"span_id"` - ParentSpanID string `gorm:"size:16" json:"parent_span_id"` - OperationName string `gorm:"size:255;index" json:"operation_name"` - StartTime time.Time `gorm:"index:idx_spans_tenant_service_start,priority:3" json:"start_time"` - EndTime time.Time `json:"end_time"` - Duration int64 `json:"duration"` // Microseconds - ServiceName string `gorm:"size:255;index:idx_spans_tenant_service_start,priority:2" json:"service_name"` // Originating service - Status string `gorm:"size:50;default:'STATUS_CODE_UNSET';index" json:"status"` // OTLP status code (e.g. STATUS_CODE_ERROR); drives GraphRAG error signal - AttributesJSON CompressedText `json:"attributes_json"` // Compressed JSON string + ID uint `gorm:"primaryKey" json:"id"` + TenantID string `gorm:"size:64;default:'default';not null;index:idx_spans_tenant_trace,priority:1;index:idx_spans_tenant_service_start,priority:1;index:idx_spans_tenant_status_start,priority:1;uniqueIndex:idx_spans_tenant_trace_span,priority:1" json:"tenant_id"` + TraceID string `gorm:"size:32;not null;index:idx_spans_tenant_trace,priority:2;uniqueIndex:idx_spans_tenant_trace_span,priority:2" json:"trace_id"` + SpanID string `gorm:"size:16;not null;uniqueIndex:idx_spans_tenant_trace_span,priority:3" json:"span_id"` + ParentSpanID string `gorm:"size:16" json:"parent_span_id"` + OperationName string `gorm:"size:255;index" json:"operation_name"` + StartTime time.Time `gorm:"index:idx_spans_tenant_service_start,priority:3;index:idx_spans_tenant_status_start,priority:3" json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration int64 `json:"duration"` // Microseconds + ServiceName string `gorm:"size:255;index:idx_spans_tenant_service_start,priority:2" json:"service_name"` // Originating service + // Status drives the GraphRAG error signal. The composite (tenant_id, status, + // start_time) backs per-tenant error scans (status='STATUS_CODE_ERROR' over a + // time window) far better than a bare low-cardinality status index, which no + // query used for an indexed equality scan. + Status string `gorm:"size:50;default:'STATUS_CODE_UNSET';index:idx_spans_tenant_status_start,priority:2" json:"status"` // OTLP status code (e.g. STATUS_CODE_ERROR) + AttributesJSON CompressedText `json:"attributes_json"` // Compressed JSON string } // Log represents a log entry associated with a trace. diff --git a/internal/storage/trace_repo.go b/internal/storage/trace_repo.go index 846b733..0c50e89 100644 --- a/internal/storage/trace_repo.go +++ b/internal/storage/trace_repo.go @@ -398,7 +398,6 @@ func (r *Repository) PurgeTracesBatched(ctx context.Context, olderThan time.Time if batchSize <= 0 { batchSize = 10_000 } - driver := strings.ToLower(r.driver) // Delete traces older than cutoff, then sweep any spans whose trace_id is no longer // present. Correlating via trace existence alone races with concurrent ingest: @@ -407,20 +406,13 @@ func (r *Repository) PurgeTracesBatched(ctx context.Context, olderThan time.Time // Constrain the sweep to old spans (start_time < cutoff) so fresh in-flight spans // are never candidates. Clock-skewed historical spans under a still-present trace // are still protected by the trace-existence subquery. - deleteOrphanSpansSQL := "DELETE FROM spans WHERE start_time < ? AND trace_id NOT IN (SELECT trace_id FROM traces)" - - if driver == "sqlite" || driver == "" { - // Unscoped() forces a hard DELETE so the orphan-span sweep below sees a - // consistent traces table. Retention must reclaim disk, not soft-delete. - result := r.db.WithContext(ctx).Unscoped().Where("timestamp < ?", olderThan).Delete(&Trace{}) - if result.Error != nil { - return result.RowsAffected, result.Error - } - if err := r.db.WithContext(ctx).Exec(deleteOrphanSpansSQL, olderThan).Error; err != nil { - return result.RowsAffected, fmt.Errorf("sweep orphan spans: %w", err) - } - return result.RowsAffected, nil - } + // + // Both the trace purge and the orphan-span sweep run in bounded LIMIT batches + // with a yield between batches for EVERY driver — including SQLite. The raw-SQL + // DELETEs are hard deletes (they bypass GORM soft-delete), so the orphan sweep + // still sees a consistent traces table. SQLite previously ran these two DELETEs + // UNBATCHED, holding the single writer lock for the whole multi-GB purge and + // stalling ingest into a 429 storm; batching releases the lock between chunks. var total int64 for { diff --git a/ui/package-lock.json b/ui/package-lock.json index cce000a..491aebc 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -4787,9 +4787,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/ui/src/components/map/FlowMap.module.css b/ui/src/components/map/FlowMap.module.css index 29f0ff8..78bd50d 100644 --- a/ui/src/components/map/FlowMap.module.css +++ b/ui/src/components/map/FlowMap.module.css @@ -92,6 +92,7 @@ * wrapper-box border; the dot is centred in the node box and carries the status * hue. ---- */ .dotNode { + position: relative; display: flex; align-items: center; justify-content: center; @@ -99,6 +100,33 @@ height: 100%; cursor: pointer; } + +/* Compact label under the status dot, shown at dot zoom so services stay + * identifiable without hovering. Centred under the dot, non-interactive, and + * haloed via text-shadow so it stays legible over crossing edges. It inherits + * the dot's emphasis (it sits inside .dimmed/.dotNeighbor/.dotSelected), and + * fades with a dimmed node via the rule below. */ +.dotLabel { + position: absolute; + top: calc(50% + 13px); + left: 50%; + transform: translateX(-50%); + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; + font-size: 10px; + line-height: 1.2; + font-variant-numeric: tabular-nums; + color: var(--text-2); + text-shadow: + 0 0 3px var(--bg-inset), + 0 0 3px var(--bg-inset); +} +.dimmed .dotLabel { + opacity: 0.25; +} .dotNode::before { content: ''; width: 18px; diff --git a/ui/src/components/map/FlowMap.tsx b/ui/src/components/map/FlowMap.tsx index 5205330..667856b 100644 --- a/ui/src/components/map/FlowMap.tsx +++ b/ui/src/components/map/FlowMap.tsx @@ -74,6 +74,13 @@ interface FlowMapProps { impact?: ReadonlyMap | null onSelect: (id: string) => void onClearSelection: () => void + /** + * Active search highlight. When non-null the matched services are accented + * (and always labelled), their 1-hop neighbours emphasised, and the rest of + * the field dims — reusing the same emphasis machinery as selection. null + * means no active search. In non-impact mode an active search owns emphasis. + */ + searchMatches?: ReadonlySet | null /** Reserved (palette filter). No-op in the React Flow map. */ onFocusFilter?: () => void /** Reserved overlay slots — kept for API stability, unused here. */ @@ -91,6 +98,9 @@ interface ServiceNodeData { node: SystemNode emphasis: Emphasis impactDepth: number | null + /** Phyllotaxis band (0 = disc centre = most critical); drives label priority + * at dot zoom so the most important services stay labelled when zoomed out. */ + labelBand: number [key: string]: unknown } type ServiceNode = Node @@ -101,13 +111,36 @@ const FIT_OPTIONS = { padding: 0.18 } as const // services fitView lands well under this, so the dense view is dots, not a wall // of overlapping 168px boxes; zooming in past it restores the labelled chips. const LOD_ZOOM = 0.62 +// Below this zoom only emphasized dots (selected/neighbour/search-match/cone) +// carry a label; between here and LOD_ZOOM the per-band label budget grows so +// the most-critical services label first and the field reveals progressively as +// you zoom in — avoiding a wall of overlapping labels at the densest view. +const LABEL_ZOOM_MIN = 0.22 +const LABEL_MAX_BAND = 16 + +/** Shorten a service id to its last dotted/slashed segment, capped — keeps dot + * labels legible without a wall of text. The full id stays in the tooltip. */ +function shortName(id: string): string { + const seg = id.split(/[./]/).filter(Boolean).pop() ?? id + return seg.length > 14 ? seg.slice(0, 13) + '…' : seg +} + +/** Whether a dot should show its label at the given zoom: emphasized nodes + * always do; the rest only once the zoom-scaled band budget reaches them. */ +function dotLabelVisible(zoom: number, emphasized: boolean, band: number): boolean { + if (emphasized) return true + if (zoom <= LABEL_ZOOM_MIN) return false + const t = (zoom - LABEL_ZOOM_MIN) / (LOD_ZOOM - LABEL_ZOOM_MIN) + return band <= Math.round(t * LABEL_MAX_BAND) +} /** The token-themed status chip, with a level-of-detail dot variant when zoomed * out. Handles are invisible border anchors (React Flow needs them; the routed * edge draws its own path). */ function ServiceMapNode({ data }: NodeProps) { - const compact = useStore((s) => s.transform[2] < LOD_ZOOM) - const { node, emphasis, impactDepth } = data + const zoom = useStore((s) => s.transform[2]) + const compact = zoom < LOD_ZOOM + const { node, emphasis, impactDepth, labelBand } = data const status = nodeStatus(node.status) const styleVar = { '--node-color': statusToken(status), width: NODE_W, height: NODE_H } as CSSProperties const handles = ( @@ -117,6 +150,8 @@ function ServiceMapNode({ data }: NodeProps) { ) if (compact) { + const emphasized = + emphasis === 'selected' || emphasis === 'neighbor' || impactDepth !== null const cls = [ styles.dotNode, emphasis === 'dim' && styles.dimmed, @@ -126,8 +161,11 @@ function ServiceMapNode({ data }: NodeProps) { .filter(Boolean) .join(' ') return ( -
+
{handles} + {dotLabelVisible(zoom, emphasized, labelBand) && ( + + )}
) } @@ -221,12 +259,13 @@ function FlowMapInner({ edges, selectedId, impact, + searchMatches, dim, onSelect, onClearSelection, ref, }: Readonly< - Pick + Pick >) { const { fitView } = useReactFlow() useImperativeHandle(ref, () => ({ fit: () => { fitView(FIT_OPTIONS) } }), [fitView]) @@ -257,14 +296,28 @@ function FlowMapInner({ () => (selectedId ? neighborsOf(edgeRefs, selectedId) : null), [edgeRefs, selectedId], ) + // 1-hop neighbours of ALL search matches, so a search highlights matched + // services together with what they talk to. + const searchNeighbors = useMemo(() => { + if (!searchMatches || searchMatches.size === 0) return null + const out = new Set() + for (const id of searchMatches) { + for (const nb of neighborsOf(edgeRefs, id)) out.add(nb) + } + return out + }, [edgeRefs, searchMatches]) const desired: ServiceNode[] = useMemo(() => { return nodes.map((n) => { const p = layout.nodes.get(n.id) const depth = impact?.get(n.id) let emphasis: Emphasis = 'normal' + // Precedence: a blast-radius cone (impact) wins; otherwise an active search + // owns emphasis; otherwise the normal selection/neighbour/dim ladder. if (impact) emphasis = depth === undefined ? 'dim' : 'normal' - else if (selectedId === n.id) emphasis = 'selected' + else if (searchMatches) { + emphasis = searchMatches.has(n.id) ? 'selected' : searchNeighbors?.has(n.id) ? 'neighbor' : 'dim' + } else if (selectedId === n.id) emphasis = 'selected' else if (neighbors?.has(n.id)) emphasis = 'neighbor' else if (dimField) emphasis = 'dim' return { @@ -273,12 +326,12 @@ function FlowMapInner({ position: { x: p?.x ?? 0, y: p?.y ?? 0 }, width: NODE_W, height: NODE_H, - data: { node: n, emphasis, impactDepth: depth ?? null }, + data: { node: n, emphasis, impactDepth: depth ?? null, labelBand: p?.layer ?? 0 }, connectable: false, focusable: true, } }) - }, [nodes, layout, selectedId, neighbors, impact, dimField]) + }, [nodes, layout, selectedId, neighbors, impact, searchMatches, searchNeighbors, dimField]) // React Flow owns node state; we reconcile `desired` into it. Re-seed // positions when a FRESH layout lands (worker result or topology change) or on @@ -320,13 +373,16 @@ function FlowMapInner({ // match its stroke, so flow direction is legible statically — at any zoom and // under reduced motion. Failing edges keep the `edgeFailing` class hook. const rfEdges: Edge[] = useMemo(() => { - const focusing = selectedId !== null || impact != null + const focusing = selectedId !== null || impact != null || searchMatches != null return edges.map((e) => { const failing = isEdgeFailing(e.status) const key = `${e.source}->${e.target}` - const active = impact - ? impact.has(e.source) && impact.has(e.target) - : selectedId !== null && (e.source === selectedId || e.target === selectedId) + // Precedence mirrors the node emphasis ladder: a blast-radius cone wins, + // then an active search (edges incident to a match), then selection. + let active: boolean + if (impact) active = impact.has(e.source) && impact.has(e.target) + else if (searchMatches) active = searchMatches.has(e.source) || searchMatches.has(e.target) + else active = selectedId !== null && (e.source === selectedId || e.target === selectedId) const dimmed = focusing && !active const stroke = failing ? 'var(--crit)' : active ? 'var(--accent)' : 'var(--text-3)' const width = active @@ -347,7 +403,7 @@ function FlowMapInner({ } as CSSProperties, } }) - }, [edges, selectedId, impact]) + }, [edges, selectedId, impact, searchMatches]) const onNodeClick = useCallback((_: unknown, n: Node) => onSelect(n.id), [onSelect]) @@ -395,6 +451,7 @@ export default function FlowMap({ edges, selectedId, impact, + searchMatches, dim, onSelect, onClearSelection, @@ -413,6 +470,7 @@ export default function FlowMap({ edges={edges} selectedId={selectedId} impact={impact} + searchMatches={searchMatches} dim={dim} onSelect={onSelect} onClearSelection={onClearSelection} diff --git a/ui/src/components/triage/ConstellationHome.module.css b/ui/src/components/triage/ConstellationHome.module.css index c67fabe..068a695 100644 --- a/ui/src/components/triage/ConstellationHome.module.css +++ b/ui/src/components/triage/ConstellationHome.module.css @@ -61,6 +61,61 @@ } } +/* Service search — translucent chrome pinned to the canvas top-left (mirrors + the .fit button's corner treatment) so the graticule reads through. Filters + the map's highlight; does not refetch. */ +.searchBar { + position: absolute; + top: var(--space-2); + left: var(--space-2); + z-index: 2; + display: flex; + align-items: center; + gap: var(--space-1); + max-width: min(60%, 320px); + padding: 0 var(--space-2); + height: 32px; + border: 1px solid var(--stroke-1); + border-radius: var(--radius-2); + background: color-mix(in srgb, var(--bg-raised) 70%, transparent); + color: var(--text-2); +} +.searchIcon { + flex: none; + color: var(--text-3); +} +.searchInput { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--text-1); + font-size: var(--text-sm); + outline: none; +} +.searchInput::placeholder { + color: var(--text-3); +} +.searchClear { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: var(--radius-1); + background: transparent; + color: var(--text-2); + cursor: pointer; +} +.searchCount { + flex: none; + font-size: var(--text-xs); + color: var(--text-3); + white-space: nowrap; +} + /* ---- the worst-first side rail (md+) ---- */ .sideRail { display: flex; diff --git a/ui/src/components/triage/ConstellationHome.tsx b/ui/src/components/triage/ConstellationHome.tsx index edc8f21..18c0cfd 100644 --- a/ui/src/components/triage/ConstellationHome.tsx +++ b/ui/src/components/triage/ConstellationHome.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Maximize2, X } from 'lucide-react' +import { Maximize2, Search, X } from 'lucide-react' import { useLocation, useSearch } from 'wouter' import FlowMap, { type FlowMapHandle } from '@/components/map/FlowMap' import ConnectInline from '@/components/shell/ConnectInline' @@ -66,6 +66,22 @@ export default function ConstellationHome() { const nodes = useMemo(() => graph?.nodes ?? [], [graph]) const edges = useMemo(() => graph?.edges ?? [], [graph]) + // Client-side service search over the already-loaded graph (no API call). + // useDeferredValue keeps typing responsive — the match recompute + map + // re-emphasis run at a lower priority than the keystroke. Matches drive the + // map's `searchMatches` highlight; null when the box is empty (no highlight). + const [query, setQuery] = useState('') + const deferredQuery = useDeferredValue(query) + const searchMatches = useMemo(() => { + const q = deferredQuery.trim().toLowerCase() + if (!q) return null + const out = new Set() + for (const n of nodes) { + if (n.id.toLowerCase().includes(q)) out.add(n.id) + } + return out + }, [deferredQuery, nodes]) + // Services with an active anomaly — feeds the side panel's "Anomalies" group. // Reads the shared ['anomaly-timeline'] cache (no extra fetch of its own). const { data: anomalyData } = useAnomalyTimeline() @@ -162,12 +178,39 @@ export default function ConstellationHome() {
{showCanvas ? (
+
+