diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts index 4e640f9f8..46bb658fc 100644 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts +++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/analyze.ts @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'; import { type CallExpression, type ClassDeclaration, + type InterfaceDeclaration, type Node, type ParameterDeclaration, Project, @@ -70,6 +71,28 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s { token: 'IAgentScopeContext', scope: 'Agent', impl: 'AgentScopeContext' }, ]; +/** + * Production composition-root bindings seeded by `bootstrap()` via + * `ScopeOptions.extra`. `buildCollection` applies `extra` AFTER the static + * `registerScopedService` registry, so these take precedence at runtime: they + * override a static default where one exists (e.g. `ISkillCatalogStore` → + * `FileSkillCatalogStore`) and supply the binding where the layer ships no + * in-package default (the Storage-layer tokens → `FileStorageService`, whose + * in-memory backend is no longer auto-registered). The analyzer mirrors that + * so the graph reflects the backend that actually runs in production. + * + * Each entry's `file`/`line`/`domain` are derived from the impl class + * declaration at analysis time, so the node points at the real backend rather + * than any registration site it replaces. + */ +const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl: string }[] = [ + { token: 'IStorageService', scope: 'App', impl: 'FileStorageService' }, + { token: 'IAppendLogStorage', scope: 'App', impl: 'FileStorageService' }, + { token: 'IAtomicDocumentStorage', scope: 'App', impl: 'FileStorageService' }, + { token: 'IBlobStorage', scope: 'App', impl: 'FileStorageService' }, + { token: 'ISkillCatalogStore', scope: 'App', impl: 'FileSkillCatalogStore' }, +]; + /** * Turn a `(scope, token)` pair into the unique node id used across the * graph. This matches the DI registration identity: one `registerScopedService` @@ -169,6 +192,53 @@ function sameRef(a: EdgeRef, b: EdgeRef): boolean { ); } +/** + * Collect every top-level `interface` declaration in the tree, keyed by + * name. Used to pull each service's public callable surface out of its + * token interface (e.g. `interface IAgentSystemReminderService { ... }`) + * so the graph view can render every method as a port row even when + * nothing calls into it yet. + * + * Duplicate names win latest — TS itself would merge them via declaration + * merging, but the codebase does not intentionally split a service + * interface across files, so ties here are effectively edge cases. + */ +function collectInterfaces(sourceFiles: SourceFile[]): Map { + const out = new Map(); + for (const file of sourceFiles) { + for (const iface of file.getInterfaces()) { + const name = iface.getName(); + if (!name) continue; + out.set(name, iface); + } + } + return out; +} + +/** + * Return the public callable surface names on an interface — every method + * signature name plus every property name — sorted and de-duplicated. + * Skips `_serviceBrand` (the DI type-erased identity marker) and index / + * call signatures (they have no member name to render as a port). TS + * interfaces cannot declare `private` members, so every remaining name is + * part of the public API by construction. + */ +function collectInterfaceMembers(iface: InterfaceDeclaration): string[] { + const names = new Set(); + for (const member of iface.getMembers()) { + const kind = member.getKind(); + if (kind === SyntaxKind.MethodSignature) { + const name = member.asKindOrThrow(SyntaxKind.MethodSignature).getName(); + names.add(name); + } else if (kind === SyntaxKind.PropertySignature) { + const name = member.asKindOrThrow(SyntaxKind.PropertySignature).getName(); + if (name === '_serviceBrand') continue; + names.add(name); + } + } + return [...names].sort(); +} + /** * Extract the token identifier from a `registerScopedService(...)` call. * Returns `undefined` if the call doesn't match the expected shape. @@ -471,6 +541,7 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} const sourceFiles = project.getSourceFiles(); const { services, implClasses, bindings } = collectServices(sourceFiles); + const interfacesByName = collectInterfaces(sourceFiles); // Seed the framework tokens as synthetic nodes so edges to them resolve // like any other registered service. They are marked domain=`framework` @@ -495,6 +566,40 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} if (!scopeMap.has(node.scope)) scopeMap.set(node.scope, node); } + // Apply production composition-root bindings: bootstrap() seeds these tokens + // via `extra`, which the container applies after the static registry. They + // override any static default (skill catalog) or supply the binding outright + // (storage layer, which ships no in-package default). Mirror that here so + // edges resolve to the backend that actually runs in production. + for (const override of PRODUCTION_OVERRIDES) { + const id = nodeId(override.scope, override.token); + const cls = implClasses.get(override.impl); + const file = cls ? relFromRepo(cls.getSourceFile().getFilePath()) : SRC_ROOT; + const domain = cls ? domainOf(cls.getSourceFile().getFilePath()) : 'unknown'; + const line = cls ? cls.getStartLineNumber() : 0; + const node: ServiceNode = { + id, + token: override.token, + impl: override.impl, + scope: override.scope, + domain, + file, + line, + }; + const existingIndex = services.findIndex((s) => s.id === id); + if (existingIndex >= 0) { + services[existingIndex] = node; + } else { + services.push(node); + } + let scopeMap = bindings.get(override.token); + if (!scopeMap) { + scopeMap = new Map(); + bindings.set(override.token, scopeMap); + } + scopeMap.set(override.scope, node); + } + const acc: EdgeAccumulator = { services, edges: new Map(), @@ -502,6 +607,18 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} unknownRefs: new Set(), }; + // Attach each service's public callable surface. Runs after registration, + // framework seeding, and PRODUCTION_OVERRIDES so every node in the graph + // gets the same treatment. Nodes whose token has no interface declaration + // in `src/` (framework tokens, synthetic overrides) simply get no + // `publicMembers` field — the view falls back to the edge-derived ports. + for (const svc of services) { + const iface = interfacesByName.get(svc.token); + if (!iface) continue; + const members = collectInterfaceMembers(iface); + if (members.length > 0) svc.publicMembers = members; + } + for (const svc of services) { const cls = implClasses.get(svc.impl); if (!cls) continue; @@ -516,6 +633,53 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {} collectRuntimeEdges(cls, svc, injectedFields, acc); } + // Synthesise interface-only nodes for tokens referenced by edges but with no + // registered impl at any scope. Each unresolved edge already targets + // `unresolved::${token}`; creating a matching node lets the viewer render it + // (with a distinct border) instead of dropping the edge as dangling. The node + // is placed at the outer-most scope that references it — a hint at where the + // missing binding is first needed — and inherits the interface's declared + // public surface so its ports read like a real service. + const nodeById = new Map(services.map((s) => [s.id, s])); + const unresolvedReferrers = new Map>(); + for (const edge of acc.edges.values()) { + if (!edge.unresolved) continue; + let scopes = unresolvedReferrers.get(edge.token); + if (!scopes) { + scopes = new Set(); + unresolvedReferrers.set(edge.token, scopes); + } + const source = nodeById.get(edge.from); + if (source) scopes.add(source.scope); + } + for (const [token, scopes] of unresolvedReferrers) { + let scope: ServiceScope = 'App'; + let minLevel = Number.POSITIVE_INFINITY; + for (const s of scopes) { + const lvl = SCOPE_LEVEL[s]; + if (lvl < minLevel) { + minLevel = lvl; + scope = s; + } + } + const node: ServiceNode = { + id: `unresolved::${token}`, + token, + impl: token, + scope, + domain: 'unresolved', + file: '', + line: 0, + unresolved: true, + }; + const iface = interfacesByName.get(token); + if (iface) { + const members = collectInterfaceMembers(iface); + if (members.length > 0) node.publicMembers = members; + } + services.push(node); + } + return { generatedAt: options.generatedAt ?? new Date(0).toISOString(), services: services.sort( diff --git a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts index 8971f4567..d11de159f 100644 --- a/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts +++ b/packages/agent-core-v2/scripts/dep-graph/analyzer/types.ts @@ -41,6 +41,22 @@ export interface ServiceNode { file: string; /** 1-indexed line of the `registerScopedService(...)` call. */ line: number; + /** + * Public callable surface of this service — the method/property names + * declared on the interface identified by `token`. Sorted, deduped, with + * the `_serviceBrand` DI marker filtered out. Absent when the analyzer + * couldn't locate an interface declaration for the token (e.g. synthetic + * framework bindings whose token has no interface in `src/`). + */ + publicMembers?: string[]; + /** + * True for synthesized interface-only nodes: the token is referenced by at + * least one edge but has no implementation registered at any scope. These + * nodes have no real impl (so `impl` mirrors `token`) and the viewer renders + * them with a distinct border so missing bindings stand out from concrete + * services rather than being dropped as dangling edges. + */ + unresolved?: true; } export interface EdgeRef { diff --git a/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts b/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts index 9567a9278..9de280e5c 100644 --- a/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts +++ b/packages/agent-core-v2/scripts/dep-graph/plugin/virtual-dep-graph.ts @@ -45,31 +45,59 @@ interface PluginOptions { writeSnapshotFile?: boolean; } +/** + * Structural fingerprint of a graph: services + edges + unknownTokens only, + * with `generatedAt` deliberately excluded. The analyzer already sorts each + * of these arrays deterministically, so a stable `JSON.stringify` is enough + * to detect real content changes and ignore metadata-only churn (e.g. the + * HEAD sha bumping without any DI edit). + */ +function fingerprint(g: Graph): string { + return JSON.stringify({ + services: g.services, + edges: g.edges, + unknownTokens: g.unknownTokens, + }); +} + export function depGraphPlugin(options: PluginOptions = {}): Plugin { const shouldWrite = options.writeSnapshotFile ?? true; let cached: Graph | undefined; + let cachedFingerprint: string | undefined; let server: ViteDevServer | undefined; let debounceTimer: ReturnType | undefined; let watcher: FSWatcher | undefined; - function analyzeNow(reason: string): void { + /** + * Re-run the analyzer and swap `cached` only when the structural + * fingerprint changed. Returns whether the graph actually changed so the + * caller can decide whether to invalidate the virtual module. + */ + function analyzeNow(reason: string): boolean { const started = Date.now(); - cached = analyze({ generatedAt: tag() }); - if (shouldWrite) writeSnapshot(cached); + const next = analyze({ generatedAt: tag() }); + const nextFingerprint = fingerprint(next); + const changed = nextFingerprint !== cachedFingerprint; + if (changed) { + cached = next; + cachedFingerprint = nextFingerprint; + if (shouldWrite) writeSnapshot(next); + } const took = Date.now() - started; - console.log( - `[dep-graph] ${reason} → ${summarize(cached)}${ - shouldWrite ? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})` : '' - } in ${took}ms`, - ); + const suffix = changed + ? shouldWrite + ? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})` + : '' + : ' (no change)'; + console.log(`[dep-graph] ${reason} → ${summarize(next)}${suffix} in ${took}ms`); + return changed; } function scheduleRefresh(reason: string): void { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = undefined; - analyzeNow(reason); - invalidate(); + if (analyzeNow(reason)) invalidate(); }, DEBOUNCE_MS); } diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx index 30f45dee9..74f71bae9 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/Filters.tsx @@ -62,9 +62,10 @@ export function Filters({ graph, domains, state, onChange }: FiltersProps): JSX. onChange({ ...state, search: e.target.value })} + title="Substring match across impl class, token interface, domain, and public members" style={{ width: '100%', padding: '6px 8px', diff --git a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx index c58376095..65360b1d6 100644 --- a/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx +++ b/packages/agent-core-v2/scripts/dep-graph/web/src/GraphView.tsx @@ -9,6 +9,7 @@ import { Position, ReactFlow, type Edge as RFEdge, + type Viewport, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { useMemo } from 'react'; @@ -36,6 +37,13 @@ const PORTS_PAD_TOP = 4; interface ServicePortsInfo { inPorts: string[]; outPorts: string[]; + /** + * Subset of `inPorts` that actually has at least one edge terminating on + * it (as opposed to being seeded from the interface's declared surface + * with no caller). Used to dim the handle / label so unused public + * methods stand out visually. + */ + connectedIn: Set; } interface GraphViewProps { @@ -49,6 +57,12 @@ interface GraphViewProps { interface ServiceNodeData extends Record { service: ServiceNode; selected: boolean; + /** + * True when the search box has content and this node matches. Rendered + * as a distinct cyan outline so search hits are visually separable from + * the yellow-outlined click-selected node. + */ + matched: boolean; dim: boolean; ports: ServicePortsInfo; } @@ -71,18 +85,34 @@ function effectiveToMethod(kind: EdgeKind, refTo: string | undefined): string | /** * Build the port lists per node from a set of edges. * - * The port list depends on which edges are actually rendered — filtering out - * a whole edge kind, for example, also hides the port rows it would have - * populated. Computing this off the filtered edges keeps the node from - * showing dangling ports with nothing connected. + * `inPorts` are seeded from `service.publicMembers` — every method / + * property declared on the service's interface, whether anything actually + * calls it or not, so the node advertises its full public surface. Any + * inbound edge method that isn't already in that seed (unusual — usually + * event-bus edges named after the kind) is folded in too. + * + * `outPorts` remain edge-driven: they are the methods on THIS service + * that make a call outward, so filtering out an edge kind naturally + * collapses the rows it would have populated. */ function computeServicePorts( services: ServiceNode[], edges: Edge[], ): Map { - const acc = new Map; out: Set }>(); + const acc = new Map< + string, + { in: Set; out: Set; connectedIn: Set } + >(); for (const s of services) { - acc.set(s.id, { in: new Set(), out: new Set() }); + const bucket = { + in: new Set(), + out: new Set(), + connectedIn: new Set(), + }; + if (s.publicMembers) { + for (const name of s.publicMembers) bucket.in.add(name); + } + acc.set(s.id, bucket); } for (const e of edges) { const src = acc.get(e.from); @@ -90,7 +120,10 @@ function computeServicePorts( for (const ref of e.refs) { const toMethod = effectiveToMethod(e.kind, ref.toMethod); if (ref.fromMethod !== undefined && src) src.out.add(ref.fromMethod); - if (toMethod !== undefined && dst) dst.in.add(toMethod); + if (toMethod !== undefined && dst) { + dst.in.add(toMethod); + dst.connectedIn.add(toMethod); + } } } const result = new Map(); @@ -98,6 +131,7 @@ function computeServicePorts( result.set(id, { inPorts: [...sets.in].sort(), outPorts: [...sets.out].sort(), + connectedIn: sets.connectedIn, }); } return result; @@ -110,17 +144,36 @@ function nodeHeight(ports: ServicePortsInfo): number { } function ServiceNodeView({ data }: NodeProps>): JSX.Element { - const { service, selected, dim, ports } = data; + const { service, selected, matched, dim, ports } = data; const bg = SCOPE_STYLE[service.scope].color; const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length); + // Interface-only node: the token is referenced but has no registered impl. + // Flagged with a dashed warning border so missing bindings stand out from + // concrete services at a glance. Selection / search-match still win so the + // active node stays unambiguous. + const isUnresolved = service.unresolved === true; + const borderColor = selected + ? '#ffdf5d' + : matched + ? '#79c0ff' + : isUnresolved + ? '#f85149' + : 'rgba(0,0,0,0.4)'; + const borderWidth = selected || matched || isUnresolved ? 2 : 1; + const borderStyle = isUnresolved && !selected && !matched ? 'dashed' : 'solid'; + const glow = selected + ? '0 0 0 3px rgba(255,223,93,0.25)' + : matched + ? '0 0 0 3px rgba(121,192,255,0.25)' + : 'none'; return (
>): JSX.Elemen
- {service.token} + {isUnresolved ? 'no implementation registered' : service.token}
{service.domain}
@@ -215,7 +268,13 @@ function ServiceNodeView({ data }: NodeProps>): JSX.Elemen id={`in:${inn}`} type="target" position={Position.Right} - style={{ background: '#a8c8f6' }} + // Dim handle when the port is only there because it's + // declared on the interface — nothing calls into it. + // The connected-vs-declared distinction reads at a + // glance without hunting for edges. + style={{ + background: ports.connectedIn.has(inn) ? '#a8c8f6' : '#3d444d', + }} /> )}
>): JSX.Elemen overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - color: '#c8e0fb', + color: + inn !== undefined && !ports.connectedIn.has(inn) + ? '#6e7681' + : '#c8e0fb', }} > {inn ?? ''} @@ -289,6 +351,42 @@ function BandLabelView({ data }: NodeProps | null; + if ( + parsed === null || + typeof parsed.x !== 'number' || + typeof parsed.y !== 'number' || + typeof parsed.zoom !== 'number' + ) { + return undefined; + } + return { x: parsed.x, y: parsed.y, zoom: parsed.zoom }; + } catch { + return undefined; + } +} + +function saveViewport(v: Viewport): void { + try { + sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(v)); + } catch { + // Storage disabled (private mode / quota) — silently drop; the graph + // still works, it just won't remember the viewport across reloads. + } +} + function passesFilter( service: ServiceNode, filters: FilterState, @@ -296,29 +394,40 @@ function passesFilter( ): boolean { if (!filters.scopes.has(service.scope)) return false; if (filters.hiddenDomains.has(service.domain)) return false; - if (filters.search) { - const q = filters.search.toLowerCase(); - const hay = `${service.token} ${service.impl} ${service.domain}`.toLowerCase(); - if (!hay.includes(q)) return false; - } + // NOTE: search intentionally does NOT filter here — it drives the + // highlight/dim treatment below so context around a hit stays visible. if (filters.hideOrphans && !connected.has(service.id)) return false; return true; } +/** + * Case-insensitive substring match across the identity fields and public + * surface. Kept close to `passesFilter` so the two search-related pieces + * (highlight input, matches predicate) stay obviously in sync. + */ +function matchesSearch(service: ServiceNode, query: string): boolean { + const members = service.publicMembers ? ` ${service.publicMembers.join(' ')}` : ''; + const hay = `${service.token} ${service.impl} ${service.domain}${members}`.toLowerCase(); + return hay.includes(query); +} + export function GraphView({ graph, filters, selectedId, onSelect, }: GraphViewProps): JSX.Element { + // Compute once at mount so a re-render that adds nodes doesn't yank the + // viewport back to the stored value while the user is panning. + const initialViewport = useMemo(() => loadViewport(), []); + const { nodes, edges, selectedService, selectedEdges } = useMemo(() => { - // Which edges survive the edge-kind filter? - const survivingEdges: Edge[] = graph.edges - .filter((e) => filters.kinds.has(e.kind)) - // Drop unresolved edges — their `to` points at a pseudo id that isn't - // in the node set. The lint reports them separately; showing them - // here would just clutter the graph with dangling arrows. - .filter((e) => !e.unresolved); + // Which edges survive the edge-kind filter? Unresolved edges are kept: the + // analyzer now synthesises an interface-only node for each unresolved token + // (rendered with a distinct border), so their `to` resolves to a real node + // instead of dangling. Edges whose endpoint is filtered out are dropped + // below via the `visibleIds` check. + const survivingEdges: Edge[] = graph.edges.filter((e) => filters.kinds.has(e.kind)); // Node ids that appear on either end of any surviving edge — for the // orphan filter. @@ -342,16 +451,35 @@ export function GraphView({ // dead weight on the node, so we compute after filter+visibility. const ports = computeServicePorts(visibleServices, finalEdges); - // Neighbours of the selected node — used to dim non-related ones. - const highlighted = new Set(); - if (selectedId) { - highlighted.add(selectedId); - for (const e of finalEdges) { - if (e.from === selectedId) highlighted.add(e.to); - if (e.to === selectedId) highlighted.add(e.from); + // Compute the two focus drivers: + // • `selectedId` — the click-selected node (0 or 1 at a time). + // • `matched` — every node whose identity or public surface hits + // the current search string. + // Their neighbours (nodes touched by any surviving edge) are folded in + // so the graph keeps enough context around a hit to be readable — + // this is the "act like a click" behaviour: nothing disappears, just + // dims. `focused` is the union used to decide dim vs bright. + const searchQuery = filters.search.trim().toLowerCase(); + const matched = new Set(); + if (searchQuery) { + for (const s of visibleServices) { + if (matchesSearch(s, searchQuery)) matched.add(s.id); } } + const focused = new Set(); + const seedFocus = (id: string): void => { + focused.add(id); + for (const e of finalEdges) { + if (e.from === id) focused.add(e.to); + if (e.to === id) focused.add(e.from); + } + }; + if (selectedId !== undefined) seedFocus(selectedId); + for (const id of matched) seedFocus(id); + + const focusActive = selectedId !== undefined || matched.size > 0; + const layout = layoutDagre(visibleServices, finalEdges, { groupByScope: filters.groupByScope, nodeSize: (id) => { @@ -369,8 +497,13 @@ export function GraphView({ data: { service, selected: service.id === selectedId, - dim: selectedId !== undefined && !highlighted.has(service.id), - ports: ports.get(service.id) ?? { inPorts: [], outPorts: [] }, + matched: matched.has(service.id), + dim: focusActive && !focused.has(service.id), + ports: ports.get(service.id) ?? { + inPorts: [], + outPorts: [], + connectedIn: new Set(), + }, }, }), ); @@ -396,8 +529,11 @@ export function GraphView({ const rfEdges: RFEdge[] = []; for (const e of finalEdges) { const style = EDGE_STYLE[e.kind]; - const isHighlighted = - selectedId !== undefined && (e.from === selectedId || e.to === selectedId); + // With a focus (click or search) active, an edge is bright when both + // ends are in the focus set — i.e. it either sits directly on a hit + // or bridges two things adjacent to a hit. When no focus is active + // every edge stays at its default opacity. + const isHighlighted = focusActive && focused.has(e.from) && focused.has(e.to); // Group refs by (fromMethod, effectiveToMethod) so identical method // pairs on different lines collapse into a single arrow between the // same two handles instead of stacking. @@ -423,7 +559,7 @@ export function GraphView({ stroke: style.color, strokeWidth: isHighlighted ? 2.2 : 1.2, strokeDasharray: style.dashed ? '4 3' : undefined, - opacity: selectedId !== undefined ? (isHighlighted ? 1 : 0.1) : 0.75, + opacity: focusActive ? (isHighlighted ? 1 : 0.1) : 0.75, }, animated: false, }); @@ -446,7 +582,14 @@ export function GraphView({ nodes={nodes} edges={edges} nodeTypes={nodeTypes} - fitView + // Only `fitView` on the very first mount of a fresh browser session. + // Once a viewport is remembered, hand it to React Flow as + // `defaultViewport` so the pan/zoom the user last landed on is + // preserved across dev-server reloads. + {...(initialViewport + ? { defaultViewport: initialViewport } + : { fitView: true })} + onMoveEnd={(_, viewport) => saveViewport(viewport)} minZoom={0.1} maxZoom={1.6} onNodeClick={(_, node) => { @@ -464,7 +607,8 @@ export function GraphView({ nodeColor={(n) => { if (n.id.startsWith('band::')) return 'transparent'; const service = (n.data as ServiceNodeData | undefined)?.service; - return service ? SCOPE_STYLE[service.scope].color : '#7d8590'; + if (!service) return '#7d8590'; + return service.unresolved ? '#f85149' : SCOPE_STYLE[service.scope].color; }} /> @@ -512,13 +656,21 @@ function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JS
{service.impl}
-
{service.token}
+ {service.unresolved ? ( +
+ No implementation registered +
+ ) : ( +
{service.token}
+ )}
{service.scope} · {service.domain}
-
- {service.file}:{service.line} -
+ {!service.unresolved && ( +
+ {service.file}:{service.line} +
+ )}