Skip to content

Commit 99b3f18

Browse files
feat: add topology, developer portal, and secrets management (Phases 11.4-11.6)
Phase 11.4 - Visual Service Topology: interactive D3.js force-directed graph showing service relationships with live health status and click navigation. Phase 11.5 - Developer Portal: per-project public-facing API portal with OpenAPI docs browser, interactive API console, and self-service API keys. Configurable access control (disabled/public/authenticated). Phase 11.6 - Secrets Management: encrypted secrets store (AES-256-GCM) with ${secrets.NAME} reference resolution in service configs at bundle compile time. 757 tests passing (+44 new).
1 parent 7f5f1be commit 99b3f18

26 files changed

Lines changed: 3345 additions & 6 deletions

File tree

assets/js/app.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ import {LiveSocket} from "phoenix_live_view"
2525
import {hooks as colocatedHooks} from "phoenix-colocated/sentinel_cp"
2626
import topbar from "../vendor/topbar"
2727

28+
import Topology from "./hooks/topology"
29+
2830
// Custom hooks for LiveView components
2931
const Hooks = {
32+
Topology,
3033
DropZone: {
3134
mounted() {
3235
const el = this.el

assets/js/hooks/topology.js

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import * as d3 from "../../vendor/d3.min.js"
2+
3+
const NODE_COLORS = {
4+
service: { enabled: "#6366f1", disabled: "#94a3b8" },
5+
upstream_group: { healthy: "#22c55e", degraded: "#f59e0b" },
6+
auth_policy: { active: "#8b5cf6" },
7+
certificate: { active: "#eab308", expiring_soon: "#f97316", expired: "#ef4444" },
8+
middleware: { enabled: "#06b6d4", disabled: "#94a3b8" },
9+
target: { default: "#a3a3a3" }
10+
}
11+
12+
const NODE_RADIUS = {
13+
service: 24,
14+
upstream_group: 20,
15+
auth_policy: 16,
16+
certificate: 16,
17+
middleware: 16,
18+
target: 8
19+
}
20+
21+
const EDGE_COLORS = {
22+
upstream: "#6366f1",
23+
auth: "#8b5cf6",
24+
tls: "#eab308",
25+
middleware: "#06b6d4",
26+
target: "#a3a3a3"
27+
}
28+
29+
const Topology = {
30+
mounted() {
31+
this.svg = null
32+
this.simulation = null
33+
34+
const data = JSON.parse(this.el.dataset.topology)
35+
this.el.innerHTML = ""
36+
this.renderGraph(data)
37+
38+
this.handleEvent("topology-data", (data) => {
39+
this.renderGraph(data)
40+
})
41+
42+
this._resizeHandler = () => this.handleResize()
43+
window.addEventListener("resize", this._resizeHandler)
44+
},
45+
46+
destroyed() {
47+
if (this.simulation) this.simulation.stop()
48+
window.removeEventListener("resize", this._resizeHandler)
49+
},
50+
51+
handleResize() {
52+
if (this.simulation) {
53+
const data = this._lastData
54+
if (data) this.renderGraph(data)
55+
}
56+
},
57+
58+
renderGraph(data) {
59+
this._lastData = data
60+
61+
const width = this.el.clientWidth
62+
const height = this.el.clientHeight
63+
64+
// Clear previous
65+
this.el.innerHTML = ""
66+
if (this.simulation) this.simulation.stop()
67+
68+
const nodes = this.buildNodes(data)
69+
const links = this.buildLinks(data, nodes)
70+
71+
if (nodes.length === 0) {
72+
this.el.innerHTML = '<div class="flex items-center justify-center h-full text-base-content/50">No services configured yet.</div>'
73+
return
74+
}
75+
76+
const svg = d3.select(this.el)
77+
.append("svg")
78+
.attr("width", width)
79+
.attr("height", height)
80+
.attr("viewBox", [0, 0, width, height])
81+
82+
const g = svg.append("g")
83+
84+
// Zoom
85+
const zoom = d3.zoom()
86+
.scaleExtent([0.3, 3])
87+
.on("zoom", (event) => g.attr("transform", event.transform))
88+
89+
svg.call(zoom)
90+
91+
// Arrow markers
92+
const defs = svg.append("defs")
93+
Object.keys(EDGE_COLORS).forEach(type => {
94+
defs.append("marker")
95+
.attr("id", `arrow-${type}`)
96+
.attr("viewBox", "0 -5 10 10")
97+
.attr("refX", 20)
98+
.attr("refY", 0)
99+
.attr("markerWidth", 6)
100+
.attr("markerHeight", 6)
101+
.attr("orient", "auto")
102+
.append("path")
103+
.attr("d", "M0,-5L10,0L0,5")
104+
.attr("fill", EDGE_COLORS[type])
105+
})
106+
107+
// Links
108+
const link = g.append("g")
109+
.selectAll("line")
110+
.data(links)
111+
.join("line")
112+
.attr("stroke", d => EDGE_COLORS[d.edge_type] || "#999")
113+
.attr("stroke-opacity", 0.5)
114+
.attr("stroke-width", d => d.edge_type === "upstream" ? 2 : 1.5)
115+
.attr("marker-end", d => `url(#arrow-${d.edge_type})`)
116+
117+
// Nodes
118+
const node = g.append("g")
119+
.selectAll("g")
120+
.data(nodes)
121+
.join("g")
122+
.attr("cursor", d => d.type === "target" ? "default" : "pointer")
123+
.call(d3.drag()
124+
.on("start", (event, d) => {
125+
if (!event.active) this.simulation.alphaTarget(0.3).restart()
126+
d.fx = d.x
127+
d.fy = d.y
128+
})
129+
.on("drag", (event, d) => {
130+
d.fx = event.x
131+
d.fy = event.y
132+
})
133+
.on("end", (event, d) => {
134+
if (!event.active) this.simulation.alphaTarget(0)
135+
d.fx = null
136+
d.fy = null
137+
}))
138+
139+
// Draw node shapes
140+
node.each(function(d) {
141+
const el = d3.select(this)
142+
const r = NODE_RADIUS[d.type] || 12
143+
const color = getNodeColor(d)
144+
145+
if (d.type === "service") {
146+
el.append("rect")
147+
.attr("x", -r).attr("y", -r * 0.7)
148+
.attr("width", r * 2).attr("height", r * 1.4)
149+
.attr("rx", 4)
150+
.attr("fill", color)
151+
.attr("stroke", d3.color(color).darker(0.5))
152+
.attr("stroke-width", 1.5)
153+
} else if (d.type === "upstream_group") {
154+
el.append("circle")
155+
.attr("r", r)
156+
.attr("fill", color)
157+
.attr("stroke", d3.color(color).darker(0.5))
158+
.attr("stroke-width", 1.5)
159+
} else if (d.type === "auth_policy") {
160+
el.append("polygon")
161+
.attr("points", diamondPoints(r))
162+
.attr("fill", color)
163+
.attr("stroke", d3.color(color).darker(0.5))
164+
.attr("stroke-width", 1.5)
165+
} else if (d.type === "target") {
166+
el.append("circle")
167+
.attr("r", r)
168+
.attr("fill", color)
169+
.attr("stroke", d3.color(color).darker(0.3))
170+
.attr("stroke-width", 1)
171+
} else {
172+
el.append("rect")
173+
.attr("x", -r).attr("y", -r)
174+
.attr("width", r * 2).attr("height", r * 2)
175+
.attr("rx", 3)
176+
.attr("fill", color)
177+
.attr("stroke", d3.color(color).darker(0.5))
178+
.attr("stroke-width", 1.5)
179+
}
180+
181+
// Label
182+
el.append("text")
183+
.attr("dy", r + 14)
184+
.attr("text-anchor", "middle")
185+
.attr("font-size", d.type === "target" ? "9px" : "11px")
186+
.attr("fill", "currentColor")
187+
.attr("class", "text-base-content/70")
188+
.text(d.name.length > 20 ? d.name.slice(0, 18) + "..." : d.name)
189+
})
190+
191+
// Click handler
192+
node.on("click", (event, d) => {
193+
if (d.type !== "target") {
194+
this.pushEvent("navigate", { type: d.type, id: d.id })
195+
}
196+
})
197+
198+
// Tooltip on hover
199+
node.append("title").text(d => {
200+
let tip = `${d.type}: ${d.name}\nStatus: ${d.status}`
201+
if (d.metadata) {
202+
Object.entries(d.metadata).forEach(([k, v]) => {
203+
if (v != null && typeof v !== "object") tip += `\n${k}: ${v}`
204+
})
205+
}
206+
return tip
207+
})
208+
209+
// Force simulation
210+
this.simulation = d3.forceSimulation(nodes)
211+
.force("link", d3.forceLink(links).id(d => d.id).distance(120))
212+
.force("charge", d3.forceManyBody().strength(-300))
213+
.force("center", d3.forceCenter(width / 2, height / 2))
214+
.force("collision", d3.forceCollide().radius(d => (NODE_RADIUS[d.type] || 12) + 20))
215+
.on("tick", () => {
216+
link
217+
.attr("x1", d => d.source.x)
218+
.attr("y1", d => d.source.y)
219+
.attr("x2", d => d.target.x)
220+
.attr("y2", d => d.target.y)
221+
222+
node.attr("transform", d => `translate(${d.x},${d.y})`)
223+
})
224+
},
225+
226+
buildNodes(data) {
227+
const nodes = []
228+
229+
;(data.services || []).forEach(s => nodes.push({...s}))
230+
;(data.upstream_groups || []).forEach(g => {
231+
nodes.push({...g})
232+
// Add target sub-nodes
233+
;(g.metadata?.targets || []).forEach(t => {
234+
nodes.push({
235+
id: `target-${t.id}`,
236+
name: `${t.host}:${t.port}`,
237+
type: "target",
238+
status: "default",
239+
metadata: { weight: t.weight }
240+
})
241+
})
242+
})
243+
;(data.auth_policies || []).forEach(a => nodes.push({...a}))
244+
;(data.certificates || []).forEach(c => nodes.push({...c}))
245+
;(data.middlewares || []).forEach(m => nodes.push({...m}))
246+
247+
return nodes
248+
},
249+
250+
buildLinks(data, nodes) {
251+
const nodeIds = new Set(nodes.map(n => n.id))
252+
return (data.edges || []).filter(e =>
253+
nodeIds.has(e.source) && nodeIds.has(e.target)
254+
).map(e => ({...e}))
255+
}
256+
}
257+
258+
function getNodeColor(d) {
259+
const colors = NODE_COLORS[d.type] || {}
260+
return colors[d.status] || colors.default || "#94a3b8"
261+
}
262+
263+
function diamondPoints(r) {
264+
return `0,${-r} ${r},0 0,${r} ${-r},0`
265+
}
266+
267+
export default Topology

assets/vendor/d3.min.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)