@leavepulse/ui 0.12.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/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LpTopologyCanvas — a declarative infra topology graph on Vue Flow (the engine
|
|
4
|
+
* n8n & NetBird use), themed with kit tokens. Pan/zoom, draggable nodes, bezier
|
|
5
|
+
* edges, dot-grid background, minimap, controls. Data-driven: the consuming app
|
|
6
|
+
* passes `nodes`/`edges` and listens to `connect` / `node-select`; this
|
|
7
|
+
* component owns NO data or fetching, so any panel can reuse it.
|
|
8
|
+
*
|
|
9
|
+
* Edge observed-state styling: solid brand = applied, animated dashed =
|
|
10
|
+
* pending, dashed danger = drift.
|
|
11
|
+
*/
|
|
12
|
+
import { Background } from "@vue-flow/background"
|
|
13
|
+
import { Controls } from "@vue-flow/controls"
|
|
14
|
+
import {
|
|
15
|
+
MarkerType,
|
|
16
|
+
Panel,
|
|
17
|
+
SelectionMode,
|
|
18
|
+
VueFlow,
|
|
19
|
+
useVueFlow,
|
|
20
|
+
type Connection,
|
|
21
|
+
type Node,
|
|
22
|
+
type NodeTypesObject,
|
|
23
|
+
} from "@vue-flow/core"
|
|
24
|
+
import { MiniMap } from "@vue-flow/minimap"
|
|
25
|
+
import { computed, markRaw, watch, type Component } from "vue"
|
|
26
|
+
import LpInfraNode, { type InfraNodeData } from "./LpInfraNode.vue"
|
|
27
|
+
import LpLaneNode from "./LpLaneNode.vue"
|
|
28
|
+
import LpServiceNode, { type ServiceNodeData } from "./LpServiceNode.vue"
|
|
29
|
+
|
|
30
|
+
// Vue Flow base CSS + canvas chrome theming is shipped via the kit's
|
|
31
|
+
// "@leavepulse/ui/canvas.css" export (a plain CSS file). Component-local CSS
|
|
32
|
+
// imports don't reach apps through the built dist, so the consuming app imports
|
|
33
|
+
// that one file. See src/canvas.css.
|
|
34
|
+
|
|
35
|
+
export type EdgeObserved = "applied" | "pending" | "drift"
|
|
36
|
+
|
|
37
|
+
export interface TopologyNode {
|
|
38
|
+
id: string
|
|
39
|
+
position: { x: number; y: number }
|
|
40
|
+
/** Host/group nodes use InfraNodeData; service nodes use ServiceNodeData. */
|
|
41
|
+
data: InfraNodeData | ServiceNodeData
|
|
42
|
+
/**
|
|
43
|
+
* Node kind: "infra" (a host card, default), "service" (a small service node
|
|
44
|
+
* placed inside a host frame), or "group" (a host frame that contains
|
|
45
|
+
* services). Services set `parent` to their group's id.
|
|
46
|
+
*/
|
|
47
|
+
type?: "infra" | "service" | "group"
|
|
48
|
+
/** For service nodes: the id of the host-frame group node they live in. */
|
|
49
|
+
parent?: string
|
|
50
|
+
/** For group nodes: pixel size of the frame, and an optional label. */
|
|
51
|
+
width?: number
|
|
52
|
+
height?: number
|
|
53
|
+
label?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Edge semantics, encoded with both colour AND line style so the two kinds read
|
|
58
|
+
* apart at a glance (and for colour-blind users): "network" = a transport wire
|
|
59
|
+
* between hosts (solid, brand-cyan); "structural" = a depends_on relation
|
|
60
|
+
* between services (dotted, violet, thinner). Inferred from `kind` when omitted
|
|
61
|
+
* (`depends_on` → structural, everything else → network).
|
|
62
|
+
*/
|
|
63
|
+
export type EdgeCategory = "network" | "structural"
|
|
64
|
+
|
|
65
|
+
export interface TopologyEdge {
|
|
66
|
+
id: string
|
|
67
|
+
source: string
|
|
68
|
+
target: string
|
|
69
|
+
/** transport label shown on the edge (e.g. "wg", "cloudflared"). */
|
|
70
|
+
kind?: string
|
|
71
|
+
/** Semantic class driving colour + dash. Inferred from `kind` if omitted. */
|
|
72
|
+
category?: EdgeCategory
|
|
73
|
+
observed?: EdgeObserved
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A background swimlane: a sized zone (one per project / overlay network) drawn
|
|
78
|
+
* beneath the nodes. The consumer computes the geometry; the canvas renders it
|
|
79
|
+
* as a non-interactive Vue Flow node so it pans/zooms in lockstep with the
|
|
80
|
+
* graph. Lanes are layered below real nodes and never intercept pointer events.
|
|
81
|
+
*/
|
|
82
|
+
export interface TopologyLane {
|
|
83
|
+
id: string
|
|
84
|
+
label: string
|
|
85
|
+
x: number
|
|
86
|
+
y: number
|
|
87
|
+
width: number
|
|
88
|
+
height: number
|
|
89
|
+
/** Optional accent colour token for the lane border + label. */
|
|
90
|
+
accent?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Canvas viewport transform — for restoring a saved pan/zoom. */
|
|
94
|
+
export interface CanvasViewport {
|
|
95
|
+
x: number
|
|
96
|
+
y: number
|
|
97
|
+
zoom: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const props = withDefaults(
|
|
101
|
+
defineProps<{
|
|
102
|
+
nodes: TopologyNode[]
|
|
103
|
+
edges: TopologyEdge[]
|
|
104
|
+
/** Allow drag-to-connect (emits `connect`). Default false (read-only). */
|
|
105
|
+
connectable?: boolean
|
|
106
|
+
/**
|
|
107
|
+
* Desktop-style marquee selection: drag on empty canvas to rubber-band a
|
|
108
|
+
* box and select every node inside; the selected set then drags together.
|
|
109
|
+
* Hold Shift to add to the selection. Panning moves to a right-drag / the
|
|
110
|
+
* space bar so left-drag is free for selecting. Default false (left-drag
|
|
111
|
+
* pans, the classic read-only behaviour). Emits `selection-change`.
|
|
112
|
+
*/
|
|
113
|
+
selectable?: boolean
|
|
114
|
+
/** Show the edge-type legend (Network / Depends-on / Pending / Drift). */
|
|
115
|
+
legend?: boolean
|
|
116
|
+
/**
|
|
117
|
+
* Background swimlanes drawn beneath the nodes (one per project / overlay).
|
|
118
|
+
* Non-interactive; pan/zoom with the graph. Empty = no lanes.
|
|
119
|
+
*/
|
|
120
|
+
lanes?: TopologyLane[]
|
|
121
|
+
}>(),
|
|
122
|
+
{ connectable: false, selectable: false, legend: true, lanes: () => [] },
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const emit = defineEmits<{
|
|
126
|
+
(e: "connect", value: Connection): void
|
|
127
|
+
(e: "node-select", id: string): void
|
|
128
|
+
(e: "node-contextmenu", value: { id: string; x: number; y: number }): void
|
|
129
|
+
(e: "edge-contextmenu", value: { id: string; x: number; y: number }): void
|
|
130
|
+
(e: "pane-click"): void
|
|
131
|
+
/** Ids of the marquee/multi-selected nodes (empty when cleared). */
|
|
132
|
+
(e: "selection-change", ids: string[]): void
|
|
133
|
+
(e: "update:nodes", value: TopologyNode[]): void
|
|
134
|
+
/** A node finished being dragged — its new canvas position (for persistence). */
|
|
135
|
+
(
|
|
136
|
+
e: "node-drag-stop",
|
|
137
|
+
value: { id: string; position: { x: number; y: number } },
|
|
138
|
+
): void
|
|
139
|
+
/** Pan/zoom settled — the new viewport (for per-user persistence). */
|
|
140
|
+
(
|
|
141
|
+
e: "viewport-change",
|
|
142
|
+
value: { zoom: number; x: number; y: number },
|
|
143
|
+
): void
|
|
144
|
+
}>()
|
|
145
|
+
|
|
146
|
+
// Vue Flow types node components as `Component<NodeProps>`; our nodes declare
|
|
147
|
+
// their own props (data/selected) which Vue Flow supplies at runtime as part of
|
|
148
|
+
// NodeProps. Widen to `Component` (not the concrete DefineComponent) so the
|
|
149
|
+
// registry types cleanly without a cast.
|
|
150
|
+
const nodeTypes: NodeTypesObject = {
|
|
151
|
+
infra: markRaw(LpInfraNode) as Component,
|
|
152
|
+
service: markRaw(LpServiceNode) as Component,
|
|
153
|
+
lane: markRaw(LpLaneNode) as Component,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Lanes render as non-interactive Vue Flow nodes BENEATH the graph: prepended
|
|
157
|
+
// so they paint first, with selecting/dragging/connecting off and a low z so
|
|
158
|
+
// real nodes always sit on top. They pan/zoom with the viewport for free.
|
|
159
|
+
const laneNodes = computed<Node[]>(() =>
|
|
160
|
+
props.lanes.map(
|
|
161
|
+
(l): Node => ({
|
|
162
|
+
id: `lane-${l.id}`,
|
|
163
|
+
type: "lane",
|
|
164
|
+
position: { x: l.x, y: l.y },
|
|
165
|
+
data: { label: l.label, accent: l.accent, width: l.width, height: l.height },
|
|
166
|
+
selectable: false,
|
|
167
|
+
draggable: false,
|
|
168
|
+
connectable: false,
|
|
169
|
+
focusable: false,
|
|
170
|
+
zIndex: 0,
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const graphNodes = computed<Node[]>(() =>
|
|
176
|
+
props.nodes.map((n): Node => {
|
|
177
|
+
const type = n.type ?? "infra"
|
|
178
|
+
const node: Node = {
|
|
179
|
+
id: n.id,
|
|
180
|
+
type,
|
|
181
|
+
position: n.position,
|
|
182
|
+
data: n.data,
|
|
183
|
+
zIndex: 1,
|
|
184
|
+
...(n.parent ? { parentNode: n.parent } : {}),
|
|
185
|
+
}
|
|
186
|
+
if (type === "group") {
|
|
187
|
+
// A host frame: a Vue Flow group node sized to hold its services.
|
|
188
|
+
node.style = {
|
|
189
|
+
width: `${n.width ?? 260}px`,
|
|
190
|
+
height: `${n.height ?? 160}px`,
|
|
191
|
+
background: "color-mix(in srgb, var(--color-surface-soft) 60%, transparent)",
|
|
192
|
+
border: "1px solid var(--color-line)",
|
|
193
|
+
borderRadius: "var(--radius-card)",
|
|
194
|
+
opacity: (n.data as { dimmed?: boolean }).dimmed ? "0.35" : "1",
|
|
195
|
+
transition: "opacity var(--duration-fast)",
|
|
196
|
+
}
|
|
197
|
+
node.label = n.label ?? n.data.name
|
|
198
|
+
}
|
|
199
|
+
return node
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// Lanes first (painted beneath), then the graph nodes on top.
|
|
204
|
+
const flowNodes = computed<Node[]>(() => [
|
|
205
|
+
...laneNodes.value,
|
|
206
|
+
...graphNodes.value,
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
// Category drives the resting colour + base dash; `depends_on` is structural.
|
|
210
|
+
function edgeCategory(e: TopologyEdge): EdgeCategory {
|
|
211
|
+
return e.category ?? (e.kind === "depends_on" ? "structural" : "network")
|
|
212
|
+
}
|
|
213
|
+
const CATEGORY_COLOR: Record<EdgeCategory, string> = {
|
|
214
|
+
network: "var(--color-brand)",
|
|
215
|
+
structural: "var(--color-accent)",
|
|
216
|
+
}
|
|
217
|
+
// Drift is an alarm and overrides the category colour; otherwise the type's
|
|
218
|
+
// colour stays so the kind is always legible (pending only animates).
|
|
219
|
+
function edgeColor(cat: EdgeCategory, o: EdgeObserved): string {
|
|
220
|
+
return o === "drift" ? "var(--color-danger)" : CATEGORY_COLOR[cat]
|
|
221
|
+
}
|
|
222
|
+
// Dash encodes BOTH axes: structural is dotted at rest; pending/drift switch to
|
|
223
|
+
// a marching dash. Network applied = solid.
|
|
224
|
+
function edgeDash(cat: EdgeCategory, o: EdgeObserved): string {
|
|
225
|
+
if (o !== "applied") return "6 5"
|
|
226
|
+
return cat === "structural" ? "1 6" : "0"
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const flowEdges = computed(() =>
|
|
230
|
+
props.edges.map((e) => {
|
|
231
|
+
const obs = e.observed ?? "applied"
|
|
232
|
+
const cat = edgeCategory(e)
|
|
233
|
+
const color = edgeColor(cat, obs)
|
|
234
|
+
return {
|
|
235
|
+
id: e.id,
|
|
236
|
+
source: e.source,
|
|
237
|
+
target: e.target,
|
|
238
|
+
type: "default",
|
|
239
|
+
animated: obs !== "applied",
|
|
240
|
+
label: e.kind,
|
|
241
|
+
labelBgStyle: { fill: "var(--color-surface)" },
|
|
242
|
+
labelStyle: { fill: "var(--color-muted-strong)", fontSize: "10px" },
|
|
243
|
+
style: {
|
|
244
|
+
stroke: color,
|
|
245
|
+
strokeWidth: cat === "structural" ? 1.5 : 2,
|
|
246
|
+
strokeDasharray: edgeDash(cat, obs),
|
|
247
|
+
strokeLinecap: "round" as const,
|
|
248
|
+
},
|
|
249
|
+
markerEnd: { type: MarkerType.ArrowClosed, color },
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const {
|
|
255
|
+
onNodeClick,
|
|
256
|
+
onNodeContextMenu,
|
|
257
|
+
onEdgeContextMenu,
|
|
258
|
+
onPaneClick,
|
|
259
|
+
onConnect,
|
|
260
|
+
onNodeDragStop,
|
|
261
|
+
onMoveEnd,
|
|
262
|
+
getSelectedNodes,
|
|
263
|
+
fitView,
|
|
264
|
+
setViewport,
|
|
265
|
+
} = useVueFlow()
|
|
266
|
+
onNodeClick(({ node }) => emit("node-select", node.id))
|
|
267
|
+
// Drag settled → surface the node's final position so the host can persist it.
|
|
268
|
+
onNodeDragStop(({ node }) =>
|
|
269
|
+
emit("node-drag-stop", { id: node.id, position: { ...node.position } }),
|
|
270
|
+
)
|
|
271
|
+
// Pan/zoom settled → surface the viewport so the host can persist it per user.
|
|
272
|
+
onMoveEnd(({ flowTransform }) => {
|
|
273
|
+
if (flowTransform)
|
|
274
|
+
emit("viewport-change", {
|
|
275
|
+
zoom: flowTransform.zoom,
|
|
276
|
+
x: flowTransform.x,
|
|
277
|
+
y: flowTransform.y,
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
// Vue Flow has no onSelectionChange hook in this version; the selected set is
|
|
281
|
+
// reactive on the store, so watch it and surface the ids.
|
|
282
|
+
watch(
|
|
283
|
+
() => getSelectedNodes.value.map((n) => n.id),
|
|
284
|
+
(ids) => emit("selection-change", ids),
|
|
285
|
+
)
|
|
286
|
+
onNodeContextMenu(({ node, event }) => {
|
|
287
|
+
// Suppress the browser menu and surface the node + cursor so the host can
|
|
288
|
+
// render its own actions (open page, focus, filter, copy…).
|
|
289
|
+
const e = event as MouseEvent
|
|
290
|
+
e.preventDefault?.()
|
|
291
|
+
emit("node-contextmenu", { id: node.id, x: e.clientX, y: e.clientY })
|
|
292
|
+
})
|
|
293
|
+
// Legend rows mirror the edge encoding exactly (same colours/dashes).
|
|
294
|
+
const legendItems = [
|
|
295
|
+
{ label: "Network", color: CATEGORY_COLOR.network, width: 2, dash: "0" },
|
|
296
|
+
{ label: "Depends-on", color: CATEGORY_COLOR.structural, width: 1.5, dash: "1 5" },
|
|
297
|
+
{ label: "Pending", color: "var(--color-brand)", width: 2, dash: "6 5" },
|
|
298
|
+
{ label: "Drift", color: "var(--color-danger)", width: 2, dash: "6 5" },
|
|
299
|
+
] as const
|
|
300
|
+
|
|
301
|
+
onEdgeContextMenu(({ edge, event }) => {
|
|
302
|
+
// Right-click an edge → host renders edge actions (change transport, remove…).
|
|
303
|
+
const e = event as MouseEvent
|
|
304
|
+
e.preventDefault?.()
|
|
305
|
+
emit("edge-contextmenu", { id: edge.id, x: e.clientX, y: e.clientY })
|
|
306
|
+
})
|
|
307
|
+
onPaneClick(() => emit("pane-click"))
|
|
308
|
+
onConnect((conn) => emit("connect", conn))
|
|
309
|
+
|
|
310
|
+
// Restore a saved viewport (pan/zoom). The host calls this after loading the
|
|
311
|
+
// user's persisted CanvasViewport on mount.
|
|
312
|
+
async function applyViewport(v: CanvasViewport): Promise<void> {
|
|
313
|
+
await setViewport({ x: v.x, y: v.y, zoom: v.zoom })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
defineExpose({ fitView, setViewport: applyViewport })
|
|
317
|
+
</script>
|
|
318
|
+
|
|
319
|
+
<template>
|
|
320
|
+
<VueFlow
|
|
321
|
+
:nodes="flowNodes"
|
|
322
|
+
:edges="flowEdges"
|
|
323
|
+
:node-types="nodeTypes"
|
|
324
|
+
:nodes-connectable="connectable"
|
|
325
|
+
:selection-on-drag="selectable"
|
|
326
|
+
:pan-on-drag="selectable ? [1, 2] : true"
|
|
327
|
+
:selection-key-code="selectable ? true : null"
|
|
328
|
+
multi-selection-key-code="Shift"
|
|
329
|
+
:selection-mode="SelectionMode.Partial"
|
|
330
|
+
:default-viewport="{ zoom: 0.9 }"
|
|
331
|
+
:min-zoom="0.3"
|
|
332
|
+
:max-zoom="2"
|
|
333
|
+
fit-view-on-init
|
|
334
|
+
class="lp-topology"
|
|
335
|
+
>
|
|
336
|
+
<Background :gap="22" :size="1.4" pattern-color="var(--color-line-strong)" />
|
|
337
|
+
<MiniMap pannable zoomable node-color="var(--color-surface-soft)" mask-color="rgba(0,0,0,0.55)" />
|
|
338
|
+
<Controls />
|
|
339
|
+
|
|
340
|
+
<!-- Edge-type legend: makes the colour/dash encoding self-explanatory. -->
|
|
341
|
+
<Panel v-if="legend" position="top-right">
|
|
342
|
+
<div
|
|
343
|
+
class="flex flex-col gap-1.5 rounded-card border border-line bg-surface-overlay/90 px-3 py-2 text-[11px] backdrop-blur"
|
|
344
|
+
>
|
|
345
|
+
<div
|
|
346
|
+
v-for="l in legendItems"
|
|
347
|
+
:key="l.label"
|
|
348
|
+
class="flex items-center gap-2 text-muted"
|
|
349
|
+
>
|
|
350
|
+
<svg width="22" height="6" class="shrink-0" aria-hidden="true">
|
|
351
|
+
<line
|
|
352
|
+
x1="0"
|
|
353
|
+
y1="3"
|
|
354
|
+
x2="22"
|
|
355
|
+
y2="3"
|
|
356
|
+
:stroke="l.color"
|
|
357
|
+
:stroke-width="l.width"
|
|
358
|
+
:stroke-dasharray="l.dash"
|
|
359
|
+
stroke-linecap="round"
|
|
360
|
+
/>
|
|
361
|
+
</svg>
|
|
362
|
+
{{ l.label }}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</Panel>
|
|
366
|
+
</VueFlow>
|
|
367
|
+
</template>
|
|
368
|
+
|
|
369
|
+
<!-- Styling lives in src/canvas.css (shipped via @leavepulse/ui/canvas.css) so
|
|
370
|
+
the Vue Flow base CSS + chrome theming reaches consuming apps reliably. -->
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
TooltipContent,
|
|
4
|
+
TooltipPortal,
|
|
5
|
+
TooltipProvider,
|
|
6
|
+
TooltipRoot,
|
|
7
|
+
TooltipTrigger,
|
|
8
|
+
} from "reka-ui"
|
|
9
|
+
import { computed } from "vue"
|
|
10
|
+
import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
|
|
11
|
+
|
|
12
|
+
export type UptimeStatus = "operational" | "degraded" | "down" | "maintenance" | "empty"
|
|
13
|
+
|
|
14
|
+
export interface UptimeSegment {
|
|
15
|
+
/** Health of this slice. `empty` renders a faded placeholder (no data). */
|
|
16
|
+
status: UptimeStatus
|
|
17
|
+
/** Optional rich tooltip body; falls back to a humanised status. */
|
|
18
|
+
label?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
/** Oldest → newest. Each entry is one slice of the timeline. */
|
|
24
|
+
segments: UptimeSegment[]
|
|
25
|
+
/** Caption shown under the left edge (oldest point). */
|
|
26
|
+
startLabel?: string
|
|
27
|
+
/** Caption shown under the right edge (newest point). */
|
|
28
|
+
endLabel?: string
|
|
29
|
+
/** Show the computed uptime % above the bar. */
|
|
30
|
+
showUptime?: boolean
|
|
31
|
+
/** Override the headline shown next to the uptime %. */
|
|
32
|
+
title?: string
|
|
33
|
+
/**
|
|
34
|
+
* Per-status colours. Any CSS colour (hex, rgb, `var(--token)`). Unset
|
|
35
|
+
* statuses fall back to the theme tokens below. Pass e.g.
|
|
36
|
+
* `{ degraded: '#ffb84d' }` to recolour just one state.
|
|
37
|
+
*/
|
|
38
|
+
colors?: Partial<Record<UptimeStatus, string>>
|
|
39
|
+
}>(),
|
|
40
|
+
{ showUptime: true },
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const defaultColor: Record<UptimeStatus, string> = {
|
|
44
|
+
operational: "var(--color-brand)",
|
|
45
|
+
degraded: "var(--color-accent)",
|
|
46
|
+
down: "var(--color-danger)",
|
|
47
|
+
maintenance: "var(--color-action)",
|
|
48
|
+
empty: "var(--color-surface-soft)",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const colorFor = (status: UptimeStatus): string =>
|
|
52
|
+
props.colors?.[status] ?? defaultColor[status]
|
|
53
|
+
|
|
54
|
+
const statusLabel: Record<UptimeStatus, string> = {
|
|
55
|
+
operational: "Operational",
|
|
56
|
+
degraded: "Degraded",
|
|
57
|
+
down: "Down",
|
|
58
|
+
maintenance: "Maintenance",
|
|
59
|
+
empty: "No data",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Uptime counts only slices with data; operational + maintenance are "up".
|
|
63
|
+
const uptimePct = computed(() => {
|
|
64
|
+
const tracked = props.segments.filter((s) => s.status !== "empty")
|
|
65
|
+
if (!tracked.length) return null
|
|
66
|
+
const up = tracked.filter((s) => s.status === "operational" || s.status === "maintenance").length
|
|
67
|
+
return (up / tracked.length) * 100
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const uptimeText = computed(() => {
|
|
71
|
+
const v = uptimePct.value
|
|
72
|
+
if (v == null) return "—"
|
|
73
|
+
// Trim trailing zeros: 100, 99.98, 99.9.
|
|
74
|
+
return `${Number(v.toFixed(2))}%`
|
|
75
|
+
})
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div class="flex flex-col gap-1.5">
|
|
80
|
+
<div v-if="showUptime || title" class="flex items-baseline justify-between gap-3 text-xs">
|
|
81
|
+
<span v-if="title" class="font-medium text-ink">{{ title }}</span>
|
|
82
|
+
<span v-else />
|
|
83
|
+
<span v-if="showUptime" class="tabular-nums text-muted-strong">
|
|
84
|
+
{{ uptimeText }}<span class="text-muted"> uptime</span>
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<TooltipProvider :delay-duration="120">
|
|
89
|
+
<div class="flex h-7 items-stretch gap-0.5">
|
|
90
|
+
<TooltipRoot v-for="(seg, i) in segments" :key="i">
|
|
91
|
+
<TooltipTrigger as-child>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
class="min-w-0 flex-1 rounded-xs outline-none transition-[scale,opacity] duration-150 hover:scale-y-110 focus-visible:ring-2 focus-visible:ring-ring"
|
|
95
|
+
:class="seg.status === 'empty' ? 'opacity-50' : ''"
|
|
96
|
+
:style="{ backgroundColor: colorFor(seg.status) }"
|
|
97
|
+
:aria-label="seg.label ?? statusLabel[seg.status]"
|
|
98
|
+
/>
|
|
99
|
+
</TooltipTrigger>
|
|
100
|
+
<TooltipPortal>
|
|
101
|
+
<TooltipContent
|
|
102
|
+
:side-offset="6"
|
|
103
|
+
:class="[PANEL_SURFACE, TOOLTIP_ANIM, 'z-(--z-tooltip) px-2.5 py-1.5 text-xs text-ink']"
|
|
104
|
+
>
|
|
105
|
+
<span class="flex items-center gap-1.5">
|
|
106
|
+
<span
|
|
107
|
+
class="size-1.5 shrink-0 rounded-full"
|
|
108
|
+
:style="{ backgroundColor: colorFor(seg.status) }"
|
|
109
|
+
/>
|
|
110
|
+
{{ seg.label ?? statusLabel[seg.status] }}
|
|
111
|
+
</span>
|
|
112
|
+
</TooltipContent>
|
|
113
|
+
</TooltipPortal>
|
|
114
|
+
</TooltipRoot>
|
|
115
|
+
</div>
|
|
116
|
+
</TooltipProvider>
|
|
117
|
+
|
|
118
|
+
<div
|
|
119
|
+
v-if="startLabel || endLabel"
|
|
120
|
+
class="flex items-center justify-between text-xs text-muted"
|
|
121
|
+
>
|
|
122
|
+
<span>{{ startLabel }}</span>
|
|
123
|
+
<span>{{ endLabel }}</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tiny, dependency-free syntax highlighter for LpCodeBlock. NOT a full parser —
|
|
3
|
+
* a single regex tokenizer that covers strings, comments, annotations, numbers,
|
|
4
|
+
* keywords, booleans and punctuation well enough for snippets (config, API
|
|
5
|
+
* examples, CLI, JVM build files). Languages are just extra keyword sets over
|
|
6
|
+
* the same tokenizer; JS/TS, Python, Rust, and the JVM trio (Kotlin/Java/Groovy)
|
|
7
|
+
* share most of it. For pixel-perfect IDE highlighting a consumer can pass
|
|
8
|
+
* pre-highlighted HTML instead; this keeps the kit zero-dependency and
|
|
9
|
+
* synchronous (shiki is heavy and async). Tokens map to kit colour classes so
|
|
10
|
+
* highlighting follows the theme.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type CodeLang =
|
|
14
|
+
| "ts"
|
|
15
|
+
| "js"
|
|
16
|
+
| "json"
|
|
17
|
+
| "bash"
|
|
18
|
+
| "html"
|
|
19
|
+
| "python"
|
|
20
|
+
| "rust"
|
|
21
|
+
| "yaml"
|
|
22
|
+
| "toml"
|
|
23
|
+
| "kotlin"
|
|
24
|
+
| "java"
|
|
25
|
+
| "groovy"
|
|
26
|
+
| "plain"
|
|
27
|
+
|
|
28
|
+
export interface Token {
|
|
29
|
+
text: string
|
|
30
|
+
/** CSS class for this token, or "" for plain text. */
|
|
31
|
+
cls: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Token colours (kit semantic tokens). Kept muted so code reads calmly.
|
|
35
|
+
const C = {
|
|
36
|
+
comment: "text-muted italic",
|
|
37
|
+
string: "text-action",
|
|
38
|
+
number: "text-accent",
|
|
39
|
+
keyword: "text-brand",
|
|
40
|
+
boolean: "text-accent",
|
|
41
|
+
property: "text-muted-strong",
|
|
42
|
+
func: "text-brand",
|
|
43
|
+
punct: "text-muted",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const KEYWORDS = new Set([
|
|
47
|
+
// JS/TS
|
|
48
|
+
"const", "let", "var", "function", "return", "if", "else", "for", "while",
|
|
49
|
+
"import", "export", "from", "default", "class", "extends", "new", "await",
|
|
50
|
+
"async", "type", "interface", "enum", "public", "private", "readonly",
|
|
51
|
+
"as", "in", "of", "try", "catch", "finally", "throw", "switch", "case",
|
|
52
|
+
"break", "continue", "typeof", "instanceof", "void", "yield",
|
|
53
|
+
// shell
|
|
54
|
+
"echo", "cd", "sudo", "npm", "npx", "git", "curl",
|
|
55
|
+
// python
|
|
56
|
+
"def", "elif", "lambda", "pass", "with", "global", "nonlocal", "del",
|
|
57
|
+
"raise", "except", "and", "or", "not", "is", "assert", "yield", "match",
|
|
58
|
+
// rust
|
|
59
|
+
"fn", "mut", "impl", "trait", "struct", "mod", "pub", "use", "crate",
|
|
60
|
+
"self", "Self", "move", "ref", "where", "dyn", "unsafe", "loop", "macro",
|
|
61
|
+
"use", "super", "static",
|
|
62
|
+
// kotlin
|
|
63
|
+
"fun", "val", "object", "companion", "data", "sealed", "override", "open",
|
|
64
|
+
"internal", "lateinit", "init", "when", "is", "vararg", "inline", "reified",
|
|
65
|
+
"suspend", "operator", "infix", "tailrec", "external", "annotation",
|
|
66
|
+
"constructor", "by", "get", "set", "field", "it", "run", "let", "apply",
|
|
67
|
+
"also", "with",
|
|
68
|
+
// java (extra, beyond shared C-family already above)
|
|
69
|
+
"package", "implements", "abstract", "final", "synchronized", "volatile",
|
|
70
|
+
"transient", "native", "strictfp", "protected", "throws", "boolean", "int",
|
|
71
|
+
"long", "short", "byte", "char", "float", "double", "String", "record",
|
|
72
|
+
// groovy adds few over java: def, trait already covered; add its extras
|
|
73
|
+
"def", "each", "closure",
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const BOOLEANS = new Set([
|
|
77
|
+
"true", "false", "null", "undefined", // js
|
|
78
|
+
"None", "True", "False", // python
|
|
79
|
+
"Some", "Ok", "Err", // rust common variants read as constants
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
// One master regex, alternation ordered so longer/greedier rules win first.
|
|
83
|
+
// Order: comments → strings → numbers → identifiers → punctuation.
|
|
84
|
+
const MASTER = new RegExp(
|
|
85
|
+
[
|
|
86
|
+
"(\\/\\/[^\\n]*|#[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)", // 1 comment
|
|
87
|
+
"(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`)", // 2 string
|
|
88
|
+
"(@[A-Za-z_$][\\w$]*)", // 3 annotation (@Override, @ConfigSerializable — JVM)
|
|
89
|
+
"(\\b\\d[\\d_]*(?:\\.\\d+)?(?:e[+-]?\\d+)?[fFlLdD]?\\b)", // 4 number (JVM suffixes)
|
|
90
|
+
"([A-Za-z_$][\\w$]*)", // 5 identifier (keyword/bool/func/prop/plain)
|
|
91
|
+
"([{}()\\[\\];:,.<>=+\\-*/%!&|?]+)", // 6 punctuation
|
|
92
|
+
].join("|"),
|
|
93
|
+
"g",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
function classifyIdent(name: string, before: string, after: string): string {
|
|
97
|
+
if (KEYWORDS.has(name)) return C.keyword
|
|
98
|
+
if (BOOLEANS.has(name)) return C.boolean
|
|
99
|
+
if (after.startsWith("(")) return C.func // call/def
|
|
100
|
+
if (before.endsWith(".")) return C.property // member access
|
|
101
|
+
return ""
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Tokenize one line into coloured spans. JSON keys get the property colour. */
|
|
105
|
+
export function tokenizeLine(line: string, lang: CodeLang): Token[] {
|
|
106
|
+
const out: Token[] = []
|
|
107
|
+
let last = 0
|
|
108
|
+
let m: RegExpExecArray | null
|
|
109
|
+
MASTER.lastIndex = 0
|
|
110
|
+
while ((m = MASTER.exec(line)) !== null) {
|
|
111
|
+
if (m.index > last) out.push({ text: line.slice(last, m.index), cls: "" })
|
|
112
|
+
const [full, comment, str, annotation, num, ident, punct] = m
|
|
113
|
+
if (comment != null) out.push({ text: full, cls: C.comment })
|
|
114
|
+
else if (annotation != null) out.push({ text: full, cls: C.func })
|
|
115
|
+
else if (str != null) {
|
|
116
|
+
// JSON: a quoted token immediately followed by ":" is a key.
|
|
117
|
+
const isKey = lang === "json" && line.slice(m.index + full.length).trimStart().startsWith(":")
|
|
118
|
+
out.push({ text: full, cls: isKey ? C.property : C.string })
|
|
119
|
+
} else if (num != null) out.push({ text: full, cls: C.number })
|
|
120
|
+
else if (ident != null) {
|
|
121
|
+
const after = line.slice(m.index + full.length).trimStart()
|
|
122
|
+
const before = line.slice(0, m.index).trimEnd()
|
|
123
|
+
// YAML (`key:`) / TOML (`key =`) leading key gets the property colour, like
|
|
124
|
+
// JSON keys — only when it's the first token of the line (the key position).
|
|
125
|
+
const isConfigKey =
|
|
126
|
+
(lang === "yaml" && before === "" && after.startsWith(":")) ||
|
|
127
|
+
(lang === "toml" && before === "" && after.startsWith("="))
|
|
128
|
+
out.push({
|
|
129
|
+
text: full,
|
|
130
|
+
cls: isConfigKey ? C.property : classifyIdent(ident, before, after),
|
|
131
|
+
})
|
|
132
|
+
} else if (punct != null) out.push({ text: full, cls: C.punct })
|
|
133
|
+
last = m.index + full.length
|
|
134
|
+
}
|
|
135
|
+
if (last < line.length) out.push({ text: line.slice(last), cls: "" })
|
|
136
|
+
return out
|
|
137
|
+
}
|