@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 +21 -0
- package/NEXT-STEPS.md +32 -0
- package/README.md +120 -0
- package/cordis.patch.yml +17 -0
- package/lib/client/TopologyGraphView.d.ts +27 -0
- package/lib/client/TopologyGraphView.js +246 -0
- package/lib/client/TopologyView.d.ts +26 -0
- package/lib/client/TopologyView.js +96 -0
- package/lib/client/icons.d.ts +34 -0
- package/lib/client/icons.js +37 -0
- package/lib/client/index.d.ts +29 -0
- package/lib/client/index.js +76 -0
- package/lib/client/locales.d.ts +4 -0
- package/lib/client/locales.js +55 -0
- package/lib/client/panel.d.ts +19 -0
- package/lib/client/panel.js +41 -0
- package/lib/client/remote-client.d.ts +12 -0
- package/lib/client/remote-client.js +148 -0
- package/lib/client/stores.d.ts +25 -0
- package/lib/client/stores.js +15 -0
- package/lib/client/useDismissOnOutsidePointer.d.ts +9 -0
- package/lib/client/useDismissOnOutsidePointer.js +23 -0
- package/lib/client.js +5566 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +54 -0
- package/lib/index.js +198 -0
- package/lib/invariant.d.ts +15 -0
- package/lib/invariant.js +17 -0
- package/lib/metrics.d.ts +26 -0
- package/lib/metrics.js +331 -0
- package/lib/render.d.ts +72 -0
- package/lib/render.js +262 -0
- package/lib/tool.d.ts +14 -0
- package/lib/tool.js +57 -0
- package/lib/typert.d.ts +139 -0
- package/lib/typert.js +142 -0
- package/lib/types.d.ts +126 -0
- package/lib/types.js +5 -0
- package/overlay.example.yml +19 -0
- package/package.json +114 -0
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)
|
package/cordis.patch.yml
ADDED
|
@@ -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;
|