@sleetdrop/dsh-plugin-topology 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sleetdrop
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NEXT-STEPS.md ADDED
@@ -0,0 +1,32 @@
1
+ # Next steps
2
+
3
+ Planned directions for the plugin topology renderer. None of these are
4
+ committed API; they are design notes for future releases.
5
+
6
+ ## Node coloring by fiber state
7
+
8
+ The snapshot already captures every fiber's lifecycle state (`ACTIVE`,
9
+ `FAILED`, `PENDING`, `DISPOSED`, …), but the SVG currently colors only
10
+ unresolved dependencies (red stroke). Color nodes by state instead:
11
+
12
+ - `FAILED` — red fill
13
+ - `DISPOSED` — gray, dashed border
14
+ - `PENDING` — light fill
15
+ - merged nodes take the "worst" state of their instances
16
+
17
+ This surfaces the most common diagnostic question — "which plugins did not
18
+ load successfully" — at a glance.
19
+
20
+ ## Click a node for details
21
+
22
+ The snapshot captures each plugin's `inject` (service names) and `source`
23
+ (module specifier), but the UI never shows them. Clicking a node could open a
24
+ small popover with state, injected services, module path, and degree
25
+ centrality. Requires switching the SVG from an `<img>` to inline rendering
26
+ with pointer hit-testing — a medium-sized change.
27
+
28
+ ## Deferred ideas
29
+
30
+ - Show an instance count (`timer ×3`) instead of the ordinal list. Deferred:
31
+ the ordinal list doubles as a startup-order hint, and adding the count
32
+ crowds the label.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @sleetdrop/dsh-plugin-topology
2
+
3
+ Plugin dependency graph inspector for DeepSeek Harness. It snapshots the live
4
+ Cordis plugin fiber tree into a plugin/service bipartite graph, derives
5
+ graph-theory metrics, and renders it as JSON, Graphviz DOT, or SVG.
6
+
7
+ One installable bundle ships three surfaces:
8
+
9
+ - **host service** (`@sleetdrop/dsh-plugin-topology`): a `TypertRemoteService`
10
+ exposing `analyze()` and `render()` over the gateway.
11
+ - **host tool** (`@sleetdrop/dsh-plugin-topology/tool`): the model-facing
12
+ `graph.render` tool, registering on `ctx.tools`.
13
+ - **browser surface** (`dsh.client`): a global panel reachable from the sidebar
14
+ footer that renders the graph, metrics, legend, unresolved-dependency log, and
15
+ pan/zoom + format downloads.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ # From the npm registry (prebuilt lib/, no build permission needed):
21
+ dsh plugin --profile <name> add @sleetdrop/dsh-plugin-topology
22
+
23
+ # From a local checkout:
24
+ git clone https://github.com/sleetdrop/dsh-plugin-topology.git
25
+ cd dsh-plugin-topology && pnpm install && pnpm build
26
+ dsh plugin --profile <name> add ./dsh-plugin-topology
27
+ ```
28
+
29
+ The bundle patch mounts the host service row and the `graph.render` tool row.
30
+ The tool row can live in the profile patch instead — a deployment that prefers
31
+ per-session placement may remove it from `cordis.patch.yml` and add it to its
32
+ agent preset.
33
+
34
+ ## Model Experience
35
+
36
+ The `graph.render` tool serializes the running instance's plugin dependency
37
+ graph as one document:
38
+
39
+ - `json` — a NetworkX-compatible node-link graph whose nodes carry graph-theory
40
+ metrics (in/out degree, degree/betweenness/eigenvector centrality, page-rank)
41
+ and whose `graph` object carries global metrics (density, strongly connected
42
+ components, `isDag`, diameter).
43
+ - `dot` — Graphviz source for layout via any Graphviz tool.
44
+ - `svg` — a Graphviz-rendered SVG, with isolated plugins composed beside the
45
+ main graph.
46
+
47
+ For `dot` or `svg`, write the returned content to a file with the write tool to
48
+ produce a shareable artifact. Use `json` to inspect or analyze the assembly.
49
+
50
+ ## Browser panel
51
+
52
+ The trigger sits in `sidebar.footer.action` (a root-scope list slot, above
53
+ Settings), visible with or without a selected session — the topology is
54
+ runtime-global and needs no session context. The panel opens maximized by
55
+ default (the Graphviz canvas needs the space); the header button restores a
56
+ centered window. Same-named plugin instances merge into one node; each node's
57
+ label carries the instance creation ordinals in brackets (`timer [1,9,23]`),
58
+ assigned at startup and meaningful only within that run.
59
+
60
+ The client injects `remote` (the gateway ClientRemote service) and self-mounts
61
+ its own `pluginTopology` Remote contribution, so it does not require editing
62
+ the host assembly's contribution list.
63
+
64
+ ## Compatibility
65
+
66
+ Targets DeepSeek Harness `0.1.1-rc.2`; the `peerDependencies` pin the client
67
+ packages and `@deepseek-ai/cordis@^4.0.1` the snapshot reads through. The
68
+ service reads Cordis internals (`root.registry`, `root.reflect.store`, fiber
69
+ fields) that are not part of the stable public API — verify the installed
70
+ harness satisfies the peer ranges before enabling the tool or panel.
71
+
72
+ ## Known Limitations
73
+
74
+ - A future Cordis that reshapes the internal registry/reflect surfaces breaks
75
+ `snapshot()`; it does not degrade silently.
76
+ - The browser panel requires a web profile (the `--patch` overlay covers only
77
+ the node half).
78
+ - Same-named instances merge in the display graph; per-instance identity stays
79
+ available in the JSON export and the complete downloadable DOT.
80
+
81
+ ## Development
82
+
83
+ ```sh
84
+ pnpm install
85
+ pnpm run build # tsc (node half) + tsdown (client bundle)
86
+ pnpm test # node:test over compiled specs
87
+ pnpm run typecheck # noEmit check
88
+ ```
89
+
90
+ The `dsh.client` browser bundle inlines everything except `react` and
91
+ `@deepseek-ai/dsh-client-runtime`, the two rows every harness shell serves.
92
+
93
+ See [NEXT-STEPS.md](NEXT-STEPS.md) for planned renderer improvements.
94
+
95
+ ## Publishing
96
+
97
+ `prepublishOnly` runs the build and tests. `publishConfig` pins `access:
98
+ public` and the `registry.npmjs.org` target (machine-local pnpm may default to
99
+ a read-only mirror), so:
100
+
101
+ ```sh
102
+ npm login # once, with the sleetdrop account
103
+ npm publish # builds, tests, and publishes the tarball
104
+ ```
105
+
106
+ If `npm publish` fails with an `EPERM` from the npm cache
107
+ (`root-owned files`), either fix the cache once
108
+ (`sudo chown -R $(id -u):$(id -g) ~/.npm`) or publish through pnpm, whose
109
+ store avoids the npm cache entirely:
110
+
111
+ ```sh
112
+ pnpm publish # same prepublishOnly gate, pnpm store
113
+ ```
114
+
115
+ `files` ships `lib/`, `cordis.patch.yml`, and `overlay.example.yml`; npm adds
116
+ README and LICENSE automatically.
117
+
118
+ ## License
119
+
120
+ [MIT](LICENSE)
@@ -0,0 +1,17 @@
1
+ # The plugin-topology bundle patch. Applied when a profile lists this bundle.
2
+ # The plugin-topology host service is the package default export; the graph.render
3
+ # tool is the ./tool subpath default export; the browser surface is the dsh.client
4
+ # row the bundle declares (see package.json dsh.client).
5
+
6
+ - insert:
7
+ - id: plugin-topology
8
+ name: '@sleetdrop/dsh-plugin-topology'
9
+
10
+ - insert:
11
+ - id: tool-plugin-topology
12
+ name: '@sleetdrop/dsh-plugin-topology/tool'
13
+ # Model-visible tools in the harness mount through the per-session agent
14
+ # preset; a bundle may place the tool here so a profile that adds this
15
+ # bundle exposes the tool in its own preset. Users who prefer the
16
+ # per-session placement can remove this row and add it to their agent
17
+ # preset instead.
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { TopologyTransform } from './stores.ts';
3
+ /** Zoom/pan props plus the localized control labels and the shared transform. */
4
+ export interface TopologyGraphViewProps {
5
+ svg: string;
6
+ alt: string;
7
+ /** Viewport height as a CSS length (`100%` inside the view body). */
8
+ height: string;
9
+ /** Remembered transform; null until the first fit lands. */
10
+ transform: TopologyTransform | null;
11
+ /** Persist the latest transform (panel reopen survival). */
12
+ onTransformChange: (transform: TopologyTransform) => void;
13
+ zoomInLabel: string;
14
+ zoomOutLabel: string;
15
+ /** Reset-to-initial-view control (returns to the centered fit transform). */
16
+ resetViewLabel: string;
17
+ /** Accessible name for the clickable zoom-level toggle. */
18
+ zoomLevelLabel: string;
19
+ }
20
+ /**
21
+ * Pan/zoom SVG viewer: fit-and-center on load, wheel zooms toward the cursor,
22
+ * drag pans, double-click zooms toward the cursor, and a compact floating pill
23
+ * (fit / zoom-out / zoom-level / zoom-in) collapses to just the fit control at
24
+ * rest. The transform is written back to the parent store on every change so it
25
+ * survives panel close/reopen.
26
+ */
27
+ export declare function TopologyGraphView({ svg, alt, height, transform, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, }: TopologyGraphViewProps): ReactNode;
@@ -0,0 +1,246 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { IconFullscreenOutline16, IconMinusOutline16, IconPlusOutline16 } from "./icons.js";
4
+ import css from './TopologyGraphView.module.css';
5
+ const MIN_K = 0.05;
6
+ /** A fit never shrinks below this scale, so the default and reset land at actual size. */
7
+ const MIN_FIT_K = 1.0;
8
+ const MAX_K = 64;
9
+ const ZOOM_FACTOR = 1.25;
10
+ /** Arrow-key pan step in screen px. */
11
+ const PAN_STEP = 40;
12
+ function clampK(k) {
13
+ return Math.min(MAX_K, Math.max(MIN_K, k));
14
+ }
15
+ /** Format the current scale as a map-like zoom level (e.g. "2.5×"). */
16
+ function formatZoom(k) {
17
+ return `${Math.round(k * 10) / 10}×`;
18
+ }
19
+ /** Fit the natural-size image inside the container and center it, but never below `MIN_FIT_K`. */
20
+ function fitView(containerWidth, containerHeight, naturalWidth, naturalHeight) {
21
+ const k = Math.max(MIN_FIT_K, Math.min(containerWidth / naturalWidth, containerHeight / naturalHeight));
22
+ return {
23
+ k,
24
+ x: (containerWidth - naturalWidth * k) / 2,
25
+ y: (containerHeight - naturalHeight * k) / 2,
26
+ };
27
+ }
28
+ /**
29
+ * Pan/zoom SVG viewer: fit-and-center on load, wheel zooms toward the cursor,
30
+ * drag pans, double-click zooms toward the cursor, and a compact floating pill
31
+ * (fit / zoom-out / zoom-level / zoom-in) collapses to just the fit control at
32
+ * rest. The transform is written back to the parent store on every change so it
33
+ * survives panel close/reopen.
34
+ */
35
+ export function TopologyGraphView({ svg, alt, height, transform, onTransformChange, zoomInLabel, zoomOutLabel, resetViewLabel, zoomLevelLabel, }) {
36
+ const containerRef = useRef(null);
37
+ const naturalRef = useRef(null);
38
+ const dragRef = useRef(null);
39
+ const [view, setView] = useState(() => transform ?? { x: 0, y: 0, k: 1 });
40
+ const [fitted, setFitted] = useState(transform !== null);
41
+ const [containerSize, setContainerSize] = useState(null);
42
+ const [atFit, setAtFit] = useState(false);
43
+ const [panelHovered, setPanelHovered] = useState(false);
44
+ const [viewportHovered, setViewportHovered] = useState(false);
45
+ const commit = useCallback((next) => {
46
+ setView(next);
47
+ onTransformChange(next);
48
+ }, [onTransformChange]);
49
+ const fit = useCallback(() => {
50
+ const container = containerRef.current;
51
+ const natural = naturalRef.current;
52
+ if (container === null || natural === null)
53
+ return;
54
+ const rect = container.getBoundingClientRect();
55
+ commit(fitView(rect.width, rect.height, natural.width, natural.height));
56
+ setAtFit(true);
57
+ }, [commit]);
58
+ const zoomAtCenter = useCallback((factor) => {
59
+ const element = containerRef.current;
60
+ if (element === null)
61
+ return;
62
+ const rect = element.getBoundingClientRect();
63
+ const cx = rect.width / 2;
64
+ const cy = rect.height / 2;
65
+ setView((current) => {
66
+ const k = clampK(current.k * factor);
67
+ const ratio = k / current.k;
68
+ const next = { k, x: cx - ratio * (cx - current.x), y: cy - ratio * (cy - current.y) };
69
+ onTransformChange(next);
70
+ return next;
71
+ });
72
+ setAtFit(false);
73
+ }, [onTransformChange]);
74
+ const zoomAround = useCallback((px, py, factor) => {
75
+ setView((current) => {
76
+ const k = clampK(current.k * factor);
77
+ const ratio = k / current.k;
78
+ const next = { k, x: px - ratio * (px - current.x), y: py - ratio * (py - current.y) };
79
+ onTransformChange(next);
80
+ return next;
81
+ });
82
+ setAtFit(false);
83
+ }, [onTransformChange]);
84
+ const panBy = useCallback((dx, dy) => {
85
+ setView((current) => {
86
+ const next = { ...current, x: current.x + dx, y: current.y + dy };
87
+ onTransformChange(next);
88
+ return next;
89
+ });
90
+ setAtFit(false);
91
+ }, [onTransformChange]);
92
+ const onLoad = (event) => {
93
+ const img = event.currentTarget;
94
+ naturalRef.current = { width: img.naturalWidth, height: img.naturalHeight };
95
+ if (transform === null)
96
+ fit();
97
+ setFitted(true);
98
+ };
99
+ // Track the container size so the zoom-level "actual size" (100%) stays centered.
100
+ // ResizeObserver is unavailable in jsdom; the initial size already comes from
101
+ // getBoundingClientRect, so the center just won't react to resizes there.
102
+ useEffect(() => {
103
+ const element = containerRef.current;
104
+ if (element === null)
105
+ return;
106
+ const update = () => {
107
+ const rect = element.getBoundingClientRect();
108
+ setContainerSize({ width: rect.width, height: rect.height });
109
+ };
110
+ update();
111
+ if (typeof ResizeObserver === 'undefined')
112
+ return;
113
+ const observer = new ResizeObserver(update);
114
+ observer.observe(element);
115
+ return () => { observer.disconnect(); };
116
+ }, []);
117
+ // A non-passive wheel listener (React's synthetic onWheel is passive, so it cannot preventDefault).
118
+ useEffect(() => {
119
+ const element = containerRef.current;
120
+ if (element === null)
121
+ return;
122
+ const onWheel = (event) => {
123
+ event.preventDefault();
124
+ const rect = element.getBoundingClientRect();
125
+ const px = event.clientX - rect.left;
126
+ const py = event.clientY - rect.top;
127
+ setView((current) => {
128
+ const factor = event.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
129
+ const k = clampK(current.k * factor);
130
+ const ratio = k / current.k;
131
+ const next = { k, x: px - ratio * (px - current.x), y: py - ratio * (py - current.y) };
132
+ onTransformChange(next);
133
+ return next;
134
+ });
135
+ setAtFit(false);
136
+ };
137
+ element.addEventListener('wheel', onWheel, { passive: false });
138
+ return () => { element.removeEventListener('wheel', onWheel); };
139
+ }, [onTransformChange]);
140
+ // Keyboard shortcuts while the pointer is over the canvas: +/− zoom, 0/F reset-to-fit,
141
+ // arrow keys pan. Ignored while a text field has focus.
142
+ useEffect(() => {
143
+ if (!viewportHovered)
144
+ return;
145
+ const onKey = (event) => {
146
+ const target = document.activeElement;
147
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
148
+ || (target instanceof HTMLElement && target.isContentEditable))
149
+ return;
150
+ switch (event.key) {
151
+ case '+':
152
+ case '=':
153
+ zoomAtCenter(ZOOM_FACTOR);
154
+ event.preventDefault();
155
+ break;
156
+ case '-':
157
+ case '_':
158
+ zoomAtCenter(1 / ZOOM_FACTOR);
159
+ event.preventDefault();
160
+ break;
161
+ case '0':
162
+ case 'f':
163
+ case 'F':
164
+ fit();
165
+ event.preventDefault();
166
+ break;
167
+ case 'ArrowLeft':
168
+ panBy(-PAN_STEP, 0);
169
+ event.preventDefault();
170
+ break;
171
+ case 'ArrowRight':
172
+ panBy(PAN_STEP, 0);
173
+ event.preventDefault();
174
+ break;
175
+ case 'ArrowUp':
176
+ panBy(0, -PAN_STEP);
177
+ event.preventDefault();
178
+ break;
179
+ case 'ArrowDown':
180
+ panBy(0, PAN_STEP);
181
+ event.preventDefault();
182
+ break;
183
+ }
184
+ };
185
+ window.addEventListener('keydown', onKey);
186
+ return () => { window.removeEventListener('keydown', onKey); };
187
+ }, [viewportHovered, zoomAtCenter, fit, panBy]);
188
+ const onPointerDown = (event) => {
189
+ dragRef.current = {
190
+ pointerId: event.pointerId,
191
+ startX: event.clientX,
192
+ startY: event.clientY,
193
+ baseX: view.x,
194
+ baseY: view.y,
195
+ };
196
+ event.currentTarget.setPointerCapture(event.pointerId);
197
+ };
198
+ const onPointerMove = (event) => {
199
+ const drag = dragRef.current;
200
+ if (drag === null || drag.pointerId !== event.pointerId)
201
+ return;
202
+ commit({
203
+ ...view,
204
+ x: drag.baseX + (event.clientX - drag.startX),
205
+ y: drag.baseY + (event.clientY - drag.startY),
206
+ });
207
+ setAtFit(false);
208
+ };
209
+ const onPointerEnd = (event) => {
210
+ if (dragRef.current?.pointerId === event.pointerId)
211
+ dragRef.current = null;
212
+ };
213
+ const onDoubleClick = (event) => {
214
+ const element = containerRef.current;
215
+ if (element === null)
216
+ return;
217
+ const rect = element.getBoundingClientRect();
218
+ const px = event.clientX - rect.left;
219
+ const py = event.clientY - rect.top;
220
+ zoomAround(px, py, event.shiftKey ? 1 / ZOOM_FACTOR : ZOOM_FACTOR);
221
+ };
222
+ // Clicking the zoom level toggles between the reset-to-fit transform and 100% (k = 1).
223
+ const onZoomLevelClick = () => {
224
+ if (atFit) {
225
+ const size = containerSize;
226
+ const natural = naturalRef.current;
227
+ const width = size?.width ?? 0;
228
+ const height = size?.height ?? 0;
229
+ commit({
230
+ k: 1,
231
+ x: (width - (natural?.width ?? 0)) / 2,
232
+ y: (height - (natural?.height ?? 0)) / 2,
233
+ });
234
+ setAtFit(false);
235
+ }
236
+ else {
237
+ fit();
238
+ }
239
+ };
240
+ const collapsed = atFit && !panelHovered;
241
+ const src = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
242
+ return (_jsxs("div", { className: css.graphBox, style: { height }, children: [_jsx("div", { ref: containerRef, className: css.graphViewport, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, onDoubleClick: onDoubleClick, onPointerEnter: () => { setViewportHovered(true); }, onPointerLeave: () => { setViewportHovered(false); }, children: _jsx("img", { className: css.graphImg, src: src, alt: alt, draggable: false, onLoad: onLoad, style: {
243
+ opacity: fitted ? 1 : 0,
244
+ transform: `translate(${view.x}px, ${view.y}px) scale(${view.k})`,
245
+ } }) }), _jsxs("div", { className: collapsed ? `${css.zoomControls} ${css.collapsed}` : css.zoomControls, onPointerEnter: () => { setPanelHovered(true); }, onPointerLeave: () => { setPanelHovered(false); }, children: [_jsx("button", { type: "button", className: `${css.iconButton} ${css.fitButton}`, onClick: fit, "aria-label": resetViewLabel, title: resetViewLabel, children: _jsx(IconFullscreenOutline16, {}) }), _jsx("button", { type: "button", className: `${css.iconButton} ${css.zoomButton}`, onClick: () => { zoomAtCenter(1 / ZOOM_FACTOR); }, "aria-label": zoomOutLabel, title: zoomOutLabel, children: _jsx(IconMinusOutline16, {}) }), _jsx("button", { type: "button", className: css.zoomLevel, onClick: onZoomLevelClick, "aria-label": zoomLevelLabel, title: zoomLevelLabel, children: formatZoom(view.k) }), _jsx("button", { type: "button", className: `${css.iconButton} ${css.zoomButton}`, onClick: () => { zoomAtCenter(ZOOM_FACTOR); }, "aria-label": zoomInLabel, title: zoomInLabel, children: _jsx(IconPlusOutline16, {}) })] })] }));
246
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { Rankdir, RenderFormat, TopologyAnalysis } from '../types.ts';
3
+ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots';
4
+ import type { PluginTopologyLocaleKey } from './locales.ts';
5
+ import { type TopologyTransform } from './stores.ts';
6
+ /** Registration-side injected callbacks used by the view. */
7
+ export interface TopologyViewInjected {
8
+ /** Read the current Host plugin topology analysis for the metrics. */
9
+ analyze: () => Promise<TopologyAnalysis>;
10
+ /** Serialize the current topology in one format along the given layout direction. */
11
+ render: (format: RenderFormat, rankdir: Rankdir) => Promise<string>;
12
+ }
13
+ /** Viewer state selector: the remembered transform (null until the first fit). */
14
+ export type ViewerStateSelector = SnapshotSelectorHook<{
15
+ transform: TopologyTransform | null;
16
+ }>;
17
+ /** Full component props: the store seat, the injected callbacks, and the locale `t`. */
18
+ export type TopologyViewProps = {
19
+ useStore: ViewerStateSelector;
20
+ actions: {
21
+ setTransform: (transform: TopologyTransform | null) => void;
22
+ };
23
+ t: (key: PluginTopologyLocaleKey) => string;
24
+ } & TopologyViewInjected;
25
+ /** The plugin dependency graph view, rendered inside the global footer-action panel. */
26
+ export declare function TopologyView({ useStore, actions, analyze, render, t }: TopologyViewProps): ReactNode;
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { IconChevronDownOutline14, IconDownloadOutline16, OutlineButton } from "./icons.js";
4
+ import { TopologyGraphView } from "./TopologyGraphView.js";
5
+ import { createTopologyViewStore } from "./stores.js";
6
+ import css from './TopologyView.module.css';
7
+ const DOWNLOAD_FORMATS = [
8
+ { format: 'svg', labelKey: 'downloadSvg' },
9
+ { format: 'dot', labelKey: 'downloadDot' },
10
+ { format: 'json', labelKey: 'downloadJson' },
11
+ ];
12
+ /**
13
+ * The health-oriented metrics: scale, the broken-dependency signal, and the
14
+ * three coupling/depth/fragmentation facts. Cycle invariants (isDag, SCC) and
15
+ * descriptive counts (average path, source/sink) are omitted — the former is
16
+ * constant on an inject/provide graph, the latter carry no health signal.
17
+ */
18
+ function metricRows(analysis, t) {
19
+ const m = analysis.graphMetrics;
20
+ const unresolved = analysis.graph.unresolved.length;
21
+ return [
22
+ { label: t('metricPlugins'), value: String(m.nodeCount) },
23
+ { label: t('metricDependencies'), value: String(m.edgeCount) },
24
+ { label: t('metricUnresolved'), value: String(unresolved), warning: unresolved > 0, expandable: unresolved > 0 },
25
+ { label: t('metricDensity'), value: m.density.toFixed(3) },
26
+ { label: t('metricWeakComponents'), value: String(m.weaklyConnectedComponents) },
27
+ { label: t('metricDiameter'), value: String(m.diameter) },
28
+ ];
29
+ }
30
+ /** Trigger a browser download of the rendered document. */
31
+ async function download(format, rankdir, render) {
32
+ const content = await render(format, rankdir);
33
+ const mime = format === 'json' ? 'application/json' : format === 'dot' ? 'text/vnd.graphviz' : 'image/svg+xml';
34
+ const blob = new Blob([content], { type: mime });
35
+ const url = URL.createObjectURL(blob);
36
+ const anchor = document.createElement('a');
37
+ anchor.href = url;
38
+ anchor.download = `plugin-topology.${format}`;
39
+ document.body.append(anchor);
40
+ anchor.click();
41
+ anchor.remove();
42
+ URL.revokeObjectURL(url);
43
+ }
44
+ /** Group unresolved dependencies per plugin, so one row carries every missing service. */
45
+ function unresolvedRows(analysis) {
46
+ const labels = new Map(analysis.graph.nodes
47
+ .filter(node => node.kind === 'plugin')
48
+ .map(node => [node.id, node.label]));
49
+ const groups = new Map();
50
+ for (const dep of analysis.graph.unresolved) {
51
+ let group = groups.get(dep.plugin);
52
+ if (group === undefined) {
53
+ const name = labels.get(dep.plugin) ?? dep.plugin;
54
+ group = { id: dep.plugin, plugin: `${name} [${dep.plugin.replace(/^p:/, '')}]`, state: dep.state, reasons: [] };
55
+ groups.set(dep.plugin, group);
56
+ }
57
+ group.reasons.push(dep.service);
58
+ }
59
+ return [...groups.values()].map(group => ({
60
+ key: group.id,
61
+ plugin: group.plugin,
62
+ state: group.state,
63
+ reasons: group.reasons,
64
+ }));
65
+ }
66
+ /** The plugin dependency graph view, rendered inside the global footer-action panel. */
67
+ export function TopologyView({ useStore, actions, analyze, render, t }) {
68
+ const [request, setRequest] = useState(0);
69
+ const [state, setState] = useState({ status: 'loading' });
70
+ const [unresolvedOpen, setUnresolvedOpen] = useState(false);
71
+ const [rankdir, setRankdir] = useState('LR');
72
+ const transform = useStore(store => store.transform);
73
+ useEffect(() => {
74
+ let current = true;
75
+ void Promise.all([analyze(), render('svg', rankdir)]).then(([analysis, svg]) => { if (current)
76
+ setState({ status: 'ready', analysis, svg }); }, (error) => {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ console.error('[plugin-topology] load failed:', error);
79
+ if (current)
80
+ setState({ status: 'error', message });
81
+ });
82
+ return () => { current = false; };
83
+ }, [request, rankdir, analyze, render]);
84
+ const retry = () => {
85
+ setState({ status: 'loading' });
86
+ setRequest(value => value + 1);
87
+ };
88
+ const onTransformChange = (next) => { actions.setTransform(next); };
89
+ const onRankdirChange = (next) => {
90
+ if (next === rankdir)
91
+ return;
92
+ actions.setTransform(null);
93
+ setRankdir(next);
94
+ };
95
+ return (_jsxs("div", { className: css.root, "data-plugin-topology-view": "", children: [state.status === 'loading' ? _jsx("p", { className: css.status, children: t('loading') }) : null, state.status === 'error' ? (_jsxs("div", { className: css.failure, children: [_jsx("p", { className: css.status, role: "alert", children: t('error') }), _jsx("pre", { className: css.errorDetail, children: state.message }), _jsx(OutlineButton, { onClick: retry, children: t('retry') })] })) : null, state.status === 'ready' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.toolbar, children: [_jsx("div", { className: css.summaryGroup, children: _jsx("div", { className: css.metricStrip, children: metricRows(state.analysis, t).map(row => (_jsxs("span", { className: row.warning === true ? `${css.stat} ${css.statAlert}` : css.stat, children: [_jsx("span", { className: css.statLabel, children: row.label }), row.expandable === true ? (_jsxs("button", { type: "button", className: css.statToggle, "aria-expanded": unresolvedOpen, onClick: () => { setUnresolvedOpen(value => !value); }, children: [_jsx("span", { className: css.statValue, children: row.value }), _jsx(IconChevronDownOutline14, { className: unresolvedOpen ? `${css.statChevron} ${css.statChevronOpen}` : css.statChevron })] })) : (_jsx("span", { className: css.statValue, children: row.value }))] }, row.label))) }) }), _jsxs("div", { className: css.actions, children: [_jsxs("div", { className: css.rankdirGroup, role: "group", "aria-label": t('rankdirToggle'), children: [_jsx("button", { type: "button", className: css.rankdirOption, "aria-pressed": rankdir === 'TB', onClick: () => { onRankdirChange('TB'); }, children: "TD" }), _jsx("button", { type: "button", className: css.rankdirOption, "aria-pressed": rankdir === 'LR', onClick: () => { onRankdirChange('LR'); }, children: "LR" })] }), DOWNLOAD_FORMATS.map(({ format, labelKey }) => (_jsx(OutlineButton, { icon: _jsx(IconDownloadOutline16, { size: 14 }), onClick: () => { void download(format, rankdir, render); }, children: t(labelKey) }, format)))] })] }), unresolvedOpen ? (_jsx("div", { className: css.unresolvedPanel, children: _jsx("div", { className: css.unresolvedScroll, children: _jsxs("table", { className: css.unresolvedTable, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: css.unresolvedColPlugin, children: t('unresolvedPlugin') }), _jsx("th", { scope: "col", className: css.unresolvedColState, children: t('unresolvedState') }), _jsx("th", { scope: "col", children: t('unresolvedService') })] }) }), _jsx("tbody", { children: unresolvedRows(state.analysis).map(row => (_jsxs("tr", { children: [_jsx("td", { className: css.unresolvedPlugin, children: row.plugin }), _jsx("td", { className: css.unresolvedState, children: row.state }), _jsx("td", { className: css.unresolvedService, children: row.reasons.join(' · ') })] }, row.key))) })] }) }) })) : null, _jsxs("div", { className: css.legend, role: "note", "aria-label": t('legendLabel'), children: [_jsxs("span", { className: css.legendItem, children: [_jsx("span", { className: `${css.legendSwatch} ${css.legendIsolatedBox}`, "aria-hidden": true }), t('legendIsolated')] }), _jsxs("span", { className: css.legendItem, children: [_jsx("span", { className: `${css.legendSwatch} ${css.legendUnresolvedBox}`, "aria-hidden": true }), t('legendUnresolved')] }), _jsxs("span", { className: css.legendItem, children: [_jsx("code", { className: css.legendIdSample, children: "[42]" }), t('legendId')] })] }), _jsx("div", { className: css.body, children: _jsx(TopologyGraphView, { svg: state.svg, alt: t('title'), height: "100%", transform: transform, onTransformChange: onTransformChange, zoomInLabel: t('zoomIn'), zoomOutLabel: t('zoomOut'), resetViewLabel: t('resetView'), zoomLevelLabel: t('zoomLevel') }) })] })) : null] }));
96
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Local icon set for the topology viewer.
3
+ *
4
+ * The 0.1.1-rc.2 harness has no shared `dsh-client-ui-primitives` package, so
5
+ * the plugin carries its own icons and controls. Each glyph is a self-contained
6
+ * SVG in the `currentColor` convention.
7
+ * @module @sleetdrop/dsh-plugin-topology/client/icons
8
+ */
9
+ import type { ReactNode } from 'react';
10
+ export interface IconProps {
11
+ readonly size?: number;
12
+ readonly className?: string;
13
+ }
14
+ /** Stroke-free minus glyph, matching the plus geometry. */
15
+ export declare function IconMinusOutline16({ size, className }: IconProps): ReactNode;
16
+ /** Stroke-free plus glyph. */
17
+ export declare function IconPlusOutline16({ size, className }: IconProps): ReactNode;
18
+ /** Close cross glyph. */
19
+ export declare function IconCloseOutline16({ size, className }: IconProps): ReactNode;
20
+ /** Down chevron for expandable stats. */
21
+ export declare function IconChevronDownOutline14({ size, className }: IconProps): ReactNode;
22
+ /** Download arrow glyph. */
23
+ export declare function IconDownloadOutline16({ size, className }: IconProps): ReactNode;
24
+ /** Fullscreen / fit glyph. */
25
+ export declare function IconFullscreenOutline16({ size, className }: IconProps): ReactNode;
26
+ /** Plugin-mark glyph for the footer trigger. */
27
+ export declare function IconPluginOutline16({ size, className }: IconProps): ReactNode;
28
+ /** Minimal outline button (the rc.2 harness ships no shared Button). */
29
+ export declare function OutlineButton({ children, onClick, title, icon, }: {
30
+ children: ReactNode;
31
+ onClick: () => void;
32
+ title?: string;
33
+ icon?: ReactNode;
34
+ }): ReactNode;