
Sealos Canvas
- 11 installs
- 1 repo stars
- Updated June 18, 2026
- zjy365/sealos-skills
Runs a local read-only HTML topology UI on localhost that visualizes a project's Sealos-deployed Kubernetes resources.
About
Reads .sealos/state.json and live Kubernetes resources, starts a temporary localhost server, and returns a view-only topology canvas URL. A developer uses it to inspect or visualize resources already deployed by Sealos Skills.
- View-only: uses read commands like kubectl get, never deploys or mutates
- Hides Secret data and full ConfigMap contents in the rendered canvas
Sealos Canvas by the numbers
- 11 all-time installs (skills.sh)
- Ranked #841 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zjy365/sealos-skills --skill sealos-canvasAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 1 |
| Last updated | June 18, 2026 |
| Repository | zjy365/sealos-skills ↗ |
What it does
Runs a local read-only HTML topology UI on localhost that visualizes a project's Sealos-deployed Kubernetes resources.
Files
Sealos Canvas
Overview
Render the current repository's deployed Sealos resources as a locally hosted HTML canvas. This skill is view-only: it reads .sealos/state.json and Kubernetes resources, starts a temporary 127.0.0.1 UI server, and returns the local URL to the user.
Hard Rules
1. Do not deploy, update, restart, patch, delete, or apply resources. 2. Only use read commands such as kubectl get and kubectl config view. 3. Use the Sealos kubeconfig at ~/.sealos/kubeconfig. 4. Do not display Secret data or full ConfigMap contents. 5. If the project has no .sealos/state.json with last_deploy, stop and tell the user to deploy first with /sealos-deploy. 6. If kubeconfig or live resource access is unavailable, report the script message and stop.
Workflow
1. Resolve the project
Use the current working directory unless the user provides a local path:
WORK_DIR="$(pwd)"Confirm this is the intended repository before generating output.
2. Start the local canvas UI
Run:
node "<SKILL_DIR>/scripts/generate-canvas.mjs" --work-dir "$WORK_DIR"Keep this process running while the user is viewing the canvas. The script writes JSON to stdout after the local server starts.
If ok is false, show the message to the user and end the flow. Do not run fallback discovery, do not deploy, and do not create any other artifact.
If ok is true, use local_url as the primary output.
3. Open the local URL
Open the returned URL with the browser:
http://127.0.0.1:<port>/index.htmlThen summarize:
- local UI URL
- app URL
- node count
- edge count
Stop the server process when the user is done viewing the page or when the current task ends.
Output Contract
Success:
{
"ok": true,
"local_url": "http://127.0.0.1:63220/index.html",
"html_path": "/abs/path/.sealos/canvas/index.html",
"node_count": 5,
"edge_count": 4,
"app_url": "https://example.sealos.run"
}Stop condition:
{
"ok": false,
"reason": "not_deployed",
"message": "This project has not been deployed by Sealos Skills yet. Run /sealos-deploy first, then run /sealos-canvas again."
}Visual Target
The locally hosted UI should feel like a topology canvas, not a table:
1. Top bar with app name, namespace, deployed app URL, generated time, and local UI status. 2. Dark dotted-grid canvas with deterministic resource-card layout. 3. Resource cards for app, ingress, services, pods, config, secrets, and volumes. 4. Dashed or solid SVG connector lines between related resources. 5. PVC/volume references attached as strips on the related card when possible. 6. Detail panel for the selected resource. 7. Events panel with recent related Kubernetes events. 8. Status colors for ready, sleeping, warning, and failed states. 9. Lightweight pan, zoom, fit, and reset controls.
Theme extraction is best effort. Reuse the user's repo accent color, font, and radius when easy to detect, but preserve operational readability.
Script
scripts/generate-canvas.mjs is the deterministic entrypoint. It:
1. Reads .sealos/state.json. 2. Verifies ~/.sealos/kubeconfig and kubectl. 3. Reads live namespace resources with kubectl get. 4. Builds a sanitized canvasModel with app, nodes, edges, events, and theme. 5. Renders assets/canvas-template.html into an internal .sealos/canvas/index.html cache. 6. Starts a temporary local HTTP server and prints local_url.
Use --no-serve only for tests or CI checks that should generate HTML without keeping a server process alive.
interface:
display_name: "Sealos: Sealos Canvas"
short_description: "Run a local Sealos resource canvas UI."
default_prompt: "Use $sealos-canvas to run a local read-only UI for this repo's deployed Sealos resources."
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<link rel="icon" href="data:,">
<style>
:root {
--bg: #111019;
--panel: #171620;
--panel-2: #1d1c28;
--panel-3: #242230;
--line: rgba(255, 255, 255, 0.08);
--border: rgba(255, 255, 255, 0.12);
--muted: #a09dab;
--quiet: #7f7a8e;
--text: #f7f5fb;
--accent: #6c55ff;
--radius: 8px;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ready: #74d7a6;
--warning: #f4be5e;
--failed: #ff7a90;
--sleeping: #6ad6aa;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--shadow-card: 0 18px 48px rgba(0, 0, 0, 0.22);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
color: var(--text);
font-family: var(--font);
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background:
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.14) 1px, transparent 0) 0 0 / 32px 32px,
var(--bg);
}
button,
a {
color: inherit;
font: inherit;
}
button {
border: 0;
touch-action: manipulation;
}
.app-shell {
display: grid;
grid-template-rows: 68px minmax(0, 1fr) 132px;
grid-template-columns: minmax(0, 1fr) 344px;
width: 100vw;
height: 100vh;
}
.topbar {
grid-column: 1 / 3;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 20px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: rgba(17, 16, 25, 0.95);
z-index: 30;
}
.identity {
display: flex;
min-width: 0;
align-items: center;
}
.identity-text {
min-width: 0;
}
h1 {
margin: 0;
color: var(--text);
font-size: 17px;
line-height: 1.2;
font-weight: 720;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-line {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
margin-top: 5px;
color: var(--muted);
font-size: 12px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
}
.summary-line span,
.summary-line a {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-line a {
color: rgba(247, 245, 251, 0.86);
text-decoration: none;
}
.pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: 20px;
padding: 0 7px;
border: 1px solid rgba(116, 215, 166, 0.34);
border-radius: 999px;
color: var(--ready);
background: rgba(116, 215, 166, 0.08);
font-size: 10px;
font-weight: 760;
letter-spacing: 0.01em;
}
.toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.03);
}
.toolbar button {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 6px;
color: rgba(247, 245, 251, 0.78);
background: transparent;
cursor: pointer;
transition: background-color 160ms var(--ease), color 160ms var(--ease), transform 160ms var(--ease);
}
@media (hover: hover) {
.toolbar button:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.075);
}
}
.toolbar button:active {
transform: scale(0.96);
}
.toolbar button:focus-visible,
.node:focus-visible {
outline: 2px solid rgba(108, 85, 255, 0.86);
outline-offset: 2px;
}
.canvas-region {
position: relative;
grid-column: 1;
grid-row: 2;
min-width: 0;
min-height: 0;
overflow: hidden;
cursor: grab;
}
.canvas-region.dragging {
cursor: grabbing;
}
.world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.edges {
position: absolute;
inset: 0;
overflow: visible;
pointer-events: none;
}
.edge {
fill: none;
stroke: rgba(174, 169, 191, 0.42);
stroke-width: 1.45;
stroke-dasharray: 6 8;
stroke-linecap: round;
}
.edge.strong {
stroke: rgba(116, 215, 166, 0.58);
stroke-dasharray: none;
}
.edge-label-bg {
fill: rgba(17, 16, 25, 0.92);
stroke: var(--line);
stroke-width: 1px;
}
.arrow-head {
fill: rgba(174, 169, 191, 0.68);
}
.edge-label {
fill: rgba(231, 228, 240, 0.78);
font-size: 11px;
font-weight: 680;
paint-order: stroke;
}
.node {
position: absolute;
width: 390px;
min-height: 166px;
border: 1px solid var(--border);
border-radius: var(--radius);
color: inherit;
background: var(--panel);
box-shadow: var(--shadow-card);
overflow: hidden;
text-align: left;
cursor: pointer;
transition: border-color 160ms var(--ease), background-color 160ms var(--ease);
}
.node.small {
width: 330px;
min-height: 128px;
}
.node.selected {
border-color: rgba(108, 85, 255, 0.62);
background: var(--panel-2);
}
.node.warning {
border-color: rgba(244, 190, 94, 0.42);
}
.node.failed {
border-color: rgba(255, 122, 144, 0.48);
}
.node-header {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 13px;
align-items: center;
padding: 24px 24px 13px;
}
.icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
color: var(--accent);
}
.icon svg {
width: 22px;
height: 22px;
stroke: currentColor;
}
.title {
min-width: 0;
}
.title strong {
display: block;
color: var(--text);
font-size: 20px;
line-height: 1.2;
font-weight: 740;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
margin-top: 5px;
color: var(--quiet);
font-size: 13px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-body {
padding: 16px 24px 22px;
}
.status {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 30px;
padding: 0 10px;
border: 1px solid rgba(116, 215, 166, 0.28);
border-radius: 999px;
color: var(--ready);
background: rgba(116, 215, 166, 0.08);
font-size: 13px;
line-height: 1.3;
font-weight: 680;
}
.status.warning {
color: var(--warning);
border-color: rgba(244, 190, 94, 0.32);
background: rgba(244, 190, 94, 0.08);
}
.status.failed {
color: var(--failed);
border-color: rgba(255, 122, 144, 0.32);
background: rgba(255, 122, 144, 0.08);
}
.status.sleeping {
color: var(--sleeping);
border-color: rgba(106, 214, 170, 0.32);
background: rgba(106, 214, 170, 0.08);
}
.status svg {
width: 17px;
height: 17px;
stroke: currentColor;
}
.meta {
display: grid;
gap: 8px;
margin-top: 18px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.meta-row {
display: flex;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.055);
}
.meta-row span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(234, 231, 240, 0.84);
}
.attachments {
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.attachment {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
min-height: 52px;
color: var(--muted);
font-size: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.attachment:first-child {
border-top: 0;
}
.attachment .attachment-icon {
display: grid;
place-items: center;
height: 100%;
background: rgba(255, 255, 255, 0.04);
}
.attachment svg {
width: 20px;
height: 20px;
stroke: currentColor;
}
.attachment span:last-child {
padding-right: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-panel {
grid-column: 2;
grid-row: 2 / 4;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--line);
background: rgba(17, 16, 25, 0.92);
overflow: auto;
}
.panel-section {
padding: 18px 18px 20px;
border-bottom: 1px solid var(--line);
}
.panel-section h2 {
margin: 0 0 12px;
color: rgba(247, 245, 251, 0.88);
font-size: 13px;
line-height: 1.2;
font-weight: 760;
}
.detail-title {
display: grid;
gap: 6px;
}
.detail-title strong {
font-size: 22px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.detail-title span {
color: var(--quiet);
font-size: 13px;
overflow-wrap: anywhere;
}
.detail-list {
display: grid;
gap: 0;
margin: 0;
}
.detail-row {
display: grid;
gap: 4px;
padding: 11px 0;
border-top: 1px solid rgba(255, 255, 255, 0.065);
font-size: 12px;
line-height: 1.35;
}
.detail-row:first-child {
border-top: 0;
}
.detail-row dt {
color: var(--muted);
letter-spacing: 0.01em;
}
.detail-row dd {
margin: 0;
color: rgba(247, 245, 251, 0.88);
overflow-wrap: anywhere;
}
.events-panel {
grid-column: 1;
grid-row: 3;
min-width: 0;
border-top: 1px solid var(--line);
background: rgba(17, 16, 25, 0.88);
overflow: hidden;
}
.events-panel h2 {
margin: 0;
padding: 13px 18px 0;
color: rgba(247, 245, 251, 0.88);
font-size: 13px;
font-weight: 760;
}
.events {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(260px, 360px);
gap: 10px;
overflow-x: auto;
padding: 12px 18px 14px;
}
.event {
display: grid;
gap: 5px;
min-height: 74px;
padding: 12px 12px 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.028);
font-size: 12px;
line-height: 1.35;
}
.event strong {
color: var(--text);
font-weight: 680;
}
.event span {
color: var(--muted);
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-rows: auto minmax(0, 1fr) 210px;
grid-template-columns: 1fr;
}
.topbar {
grid-template-columns: 1fr;
align-items: start;
}
.topbar,
.canvas-region,
.events-panel {
grid-column: 1;
}
.detail-panel {
display: none;
}
.toolbar {
justify-self: start;
}
.summary-line {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 5px 8px;
white-space: normal;
}
.summary-line span,
.summary-line a {
white-space: nowrap;
}
.summary-line a {
grid-column: 1 / -1;
}
#generated-at {
display: none;
}
}
</style>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="identity">
<div class="identity-text">
<h1 id="app-title"></h1>
<div class="summary-line">
<span class="pill" id="app-status"></span>
<span id="app-namespace"></span>
<a id="app-url" href="#" target="_blank" rel="noreferrer"></a>
<span id="generated-at"></span>
</div>
</div>
</div>
<nav class="toolbar" aria-label="Canvas controls">
<button type="button" data-action="fit" title="Fit view" aria-label="Fit view">⌖</button>
<button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
<button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
<button type="button" data-action="reset" title="Reset view" aria-label="Reset view">↺</button>
</nav>
</header>
<main class="canvas-region" id="viewport" aria-label="Sealos resource topology canvas">
<section class="world" id="world">
<svg class="edges" id="edges" aria-hidden="true">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path class="arrow-head" d="M 0 0 L 10 5 L 0 10 z"></path>
</marker>
</defs>
</svg>
<div id="nodes"></div>
</section>
</main>
<aside class="detail-panel" aria-label="Selected resource details">
<section class="panel-section">
<h2>Selected Resource</h2>
<div class="detail-title">
<strong id="detail-name"></strong>
<span id="detail-subtitle"></span>
</div>
</section>
<section class="panel-section">
<h2>Safe Summary</h2>
<dl class="detail-list" id="detail-list"></dl>
</section>
</aside>
<aside class="events-panel" aria-label="Recent events">
<h2>Recent Events</h2>
<div class="events" id="events"></div>
</aside>
</div>
<script id="canvas-model" type="application/json">__CANVAS_MODEL__</script>
<script>
const model = JSON.parse(document.getElementById('canvas-model').textContent);
const root = document.documentElement;
root.style.setProperty('--accent', model.theme.accent);
root.style.setProperty('--radius', model.theme.radius);
root.style.setProperty('--font', model.theme.font);
const icons = {
app: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M4 8h16v9a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V8Z"/><path d="M8 8a4 4 0 0 1 8 0"/><path d="M8 8V6m8 2V6"/></svg>',
ingress: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M12 3v18"/><path d="M5 8h14"/><path d="M5 16h14"/><path d="M7 4h10l4 4v8l-4 4H7l-4-4V8l4-4Z"/></svg>',
service: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M12 3 3 8l9 5 9-5-9-5Z"/><path d="m3 13 9 5 9-5"/><path d="m3 18 9 5 9-5"/></svg>',
pod: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M12 2 4 6.5v11L12 22l8-4.5v-11L12 2Z"/><path d="M12 8v8"/><path d="m8 10 4-2 4 2"/><path d="m8 14 4 2 4-2"/></svg>',
config: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/><circle cx="8" cy="5" r="2"/><circle cx="16" cy="12" r="2"/><circle cx="10" cy="19" r="2"/></svg>',
secret: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M7 11V8a5 5 0 0 1 10 0v3"/><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M12 15v2"/></svg>',
volume: '<svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><path d="M4 17 7 7h10l3 10"/><path d="M4 17h16v3H4z"/><path d="M9 13h6"/></svg>',
ready: '<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>',
warning: '<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="m12 3 10 18H2L12 3Z"/><path d="M12 9v5"/><path d="M12 18h.01"/></svg>',
failed: '<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
sleeping: '<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M21 12.8A8.5 8.5 0 1 1 11.2 3 6.5 6.5 0 0 0 21 12.8Z"/><path d="M16 4h4l-4 5h4"/></svg>'
};
const viewport = document.getElementById('viewport');
const world = document.getElementById('world');
const edgesSvg = document.getElementById('edges');
const nodesEl = document.getElementById('nodes');
let selectedNode = model.nodes.find((node) => node.id === 'app') || model.nodes[0];
let state = { x: 0, y: 0, scale: 1 };
let drag = null;
function escapeHtml(value = '') {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function setText(id, text) {
document.getElementById(id).textContent = text || '';
}
function renderHeader() {
setText('app-title', model.app.name);
setText('app-status', model.app.status);
setText('app-namespace', model.app.namespace);
setText('generated-at', `Generated ${new Date(model.generatedAt).toLocaleString()}`);
const appUrl = document.getElementById('app-url');
appUrl.textContent = model.app.url || 'No public URL';
if (model.app.url) appUrl.href = model.app.url;
}
function renderEdges() {
edgesSvg.setAttribute('width', model.layout.width);
edgesSvg.setAttribute('height', model.layout.height);
edgesSvg.setAttribute('viewBox', `0 0 ${model.layout.width} ${model.layout.height}`);
const defs = edgesSvg.querySelector('defs').outerHTML;
const edgeMarkup = model.edges.map((edge) => {
const from = model.nodes.find((node) => node.id === edge.from);
const to = model.nodes.find((node) => node.id === edge.to);
if (!from || !to) return '';
const start = { x: from.x + from.width, y: from.y + Math.min(86, from.height / 2) };
const end = { x: to.x, y: to.y + Math.min(86, to.height / 2) };
if (from.x > to.x) {
start.x = from.x;
end.x = to.x + to.width;
}
const midX = start.x + (end.x - start.x) * 0.5;
const pathData = `M ${start.x} ${start.y} C ${midX} ${start.y}, ${midX} ${end.y}, ${end.x} ${end.y}`;
const labelX = (start.x + end.x) / 2;
const labelY = (start.y + end.y) / 2 - 8;
const safeLabel = escapeHtml(edge.label || '');
const labelWidth = Math.max(48, safeLabel.length * 7 + 22);
return `<path class="edge ${edge.strong ? 'strong' : ''}" marker-end="url(#arrow)" d="${pathData}"></path>
<rect class="edge-label-bg" x="${labelX - labelWidth / 2}" y="${labelY - 16}" width="${labelWidth}" height="22" rx="6"></rect>
<text class="edge-label" x="${labelX}" y="${labelY}" text-anchor="middle">${escapeHtml(edge.label || '')}</text>`;
}).join('');
edgesSvg.innerHTML = defs + edgeMarkup;
}
function renderNodes() {
world.style.width = `${model.layout.width}px`;
world.style.height = `${model.layout.height}px`;
nodesEl.innerHTML = model.nodes.map((node) => {
const meta = (node.meta || []).filter(([key, value]) => key && value).slice(0, 5)
.map(([key, value]) => `<div class="meta-row"><span>${escapeHtml(key)}</span><span title="${escapeHtml(value)}">${escapeHtml(value)}</span></div>`).join('');
const attachments = (node.attachments || [])
.map((attachment) => `<div class="attachment"><span class="attachment-icon">${icons[attachment.icon] || icons.volume}</span><span>${escapeHtml(attachment.label)}</span></div>`).join('');
const className = ['node', node.width < 390 ? 'small' : '', node.status, node.id === selectedNode?.id ? 'selected' : ''].filter(Boolean).join(' ');
return `<button class="${className}" data-node-id="${escapeHtml(node.id)}" style="left:${node.x}px;top:${node.y}px;width:${node.width}px;min-height:${node.height}px">
<header class="node-header">
<span class="icon">${icons[node.icon] || icons.app}</span>
<div class="title">
<strong title="${escapeHtml(node.title)}">${escapeHtml(node.title)}</strong>
<div class="subtitle" title="${escapeHtml(node.subtitle || node.kind)}">${escapeHtml(node.subtitle || node.kind)}</div>
</div>
</header>
<div class="node-body">
<div class="status ${escapeHtml(node.status)}">${icons[node.status] || icons.ready}<span>${escapeHtml(node.statusText)}</span></div>
${meta ? `<div class="meta">${meta}</div>` : ''}
</div>
${attachments ? `<div class="attachments">${attachments}</div>` : ''}
</button>`;
}).join('');
nodesEl.querySelectorAll('[data-node-id]').forEach((button) => {
button.addEventListener('click', () => {
selectedNode = model.nodes.find((node) => node.id === button.dataset.nodeId) || selectedNode;
renderNodes();
renderDetails();
});
});
}
function renderDetails() {
if (!selectedNode) return;
setText('detail-name', selectedNode.title);
setText('detail-subtitle', `${selectedNode.kind} · ${selectedNode.statusText}`);
const rows = [
['Kind', selectedNode.kind],
['Status', selectedNode.statusText],
['Subtitle', selectedNode.subtitle],
...(selectedNode.meta || []),
...(selectedNode.attachments || []).map((attachment) => ['Attachment', attachment.label])
].filter(([, value]) => value);
document.getElementById('detail-list').innerHTML = rows.map(([key, value]) => `
<div class="detail-row">
<dt>${escapeHtml(key)}</dt>
<dd>${escapeHtml(value)}</dd>
</div>
`).join('');
}
function renderEvents() {
const eventsEl = document.getElementById('events');
if (!model.events.length) {
eventsEl.innerHTML = '<div class="event"><strong>No recent events</strong><span>No related Kubernetes events were returned.</span></div>';
return;
}
eventsEl.innerHTML = model.events.map((event) => `
<div class="event">
<strong>${escapeHtml(event.reason)} · ${escapeHtml(event.type)}</strong>
<span>${escapeHtml(event.involved)} ${event.time ? `· ${escapeHtml(event.time)}` : ''}</span>
<span>${escapeHtml(event.message)}</span>
</div>
`).join('');
}
function applyTransform() {
world.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`;
}
function fit() {
const margin = 110;
const sx = (viewport.clientWidth - margin) / model.layout.width;
const sy = (viewport.clientHeight - margin) / model.layout.height;
state.scale = Math.max(0.42, Math.min(1.12, sx, sy));
state.x = (viewport.clientWidth - model.layout.width * state.scale) / 2;
state.y = (viewport.clientHeight - model.layout.height * state.scale) / 2;
applyTransform();
}
function reset() {
state = { x: 90, y: 80, scale: 0.9 };
applyTransform();
}
function zoom(delta) {
const next = Math.max(0.35, Math.min(1.7, state.scale + delta));
const cx = viewport.clientWidth / 2;
const cy = viewport.clientHeight / 2;
const wx = (cx - state.x) / state.scale;
const wy = (cy - state.y) / state.scale;
state.scale = next;
state.x = cx - wx * state.scale;
state.y = cy - wy * state.scale;
applyTransform();
}
viewport.addEventListener('pointerdown', (event) => {
if (event.target.closest('.node')) return;
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, ox: state.x, oy: state.y };
viewport.setPointerCapture(event.pointerId);
viewport.classList.add('dragging');
});
viewport.addEventListener('pointermove', (event) => {
if (!drag || drag.id !== event.pointerId) return;
state.x = drag.ox + event.clientX - drag.x;
state.y = drag.oy + event.clientY - drag.y;
applyTransform();
});
viewport.addEventListener('pointerup', (event) => {
if (!drag || drag.id !== event.pointerId) return;
drag = null;
viewport.classList.remove('dragging');
});
viewport.addEventListener('wheel', (event) => {
event.preventDefault();
zoom(event.deltaY > 0 ? -0.08 : 0.08);
}, { passive: false });
document.querySelector('[data-action="fit"]').addEventListener('click', fit);
document.querySelector('[data-action="zoom-out"]').addEventListener('click', () => zoom(-0.12));
document.querySelector('[data-action="zoom-in"]').addEventListener('click', () => zoom(0.12));
document.querySelector('[data-action="reset"]').addEventListener('click', reset);
window.addEventListener('resize', fit);
renderHeader();
renderEdges();
renderNodes();
renderDetails();
renderEvents();
fit();
</script>
</body>
</html>
{
"skill_name": "sealos-canvas",
"evals": [
{
"id": 0,
"prompt": "/sealos-canvas in a repository without .sealos/state.json",
"expected_output": "Stops with a message telling the user to run /sealos-deploy first and does not generate HTML",
"files": [],
"assertions": [
{
"name": "detects-not-deployed",
"description": "Returns ok=false with reason not_deployed"
},
{
"name": "does-not-create-canvas",
"description": "Does not create .sealos/canvas/index.html"
}
]
},
{
"id": 1,
"prompt": "/sealos-canvas in a deployed repo when ~/.sealos/kubeconfig is unavailable",
"expected_output": "Stops with a kubeconfig unavailable message and does not attempt deploy/update behavior",
"files": [],
"assertions": [
{
"name": "requires-kubeconfig",
"description": "Returns ok=false with reason kubeconfig_missing"
},
{
"name": "view-only",
"description": "Does not run kubectl apply, patch, delete, rollout, restart, or set image commands"
}
]
},
{
"id": 2,
"prompt": "/sealos-canvas in a deployed repo with mock Kubernetes resources",
"expected_output": "Starts a local canvas UI, returns local_url, and renders app, URL, resource cards, volume strip, details, and SVG edges",
"files": [],
"assertions": [
{
"name": "returns-local-url",
"description": "Returns ok=true with a http://127.0.0.1 local_url"
},
{
"name": "renders-topology",
"description": "Local UI contains resource nodes and connector edges"
},
{
"name": "includes-volume-strip",
"description": "PVC or volume references are rendered as attached strips"
},
{
"name": "includes-detail-panel",
"description": "Local UI includes a selected resource detail panel"
}
]
},
{
"id": 3,
"prompt": "/sealos-canvas with Secret resources in the namespace",
"expected_output": "Shows only Secret names/types/reference relationships and never writes Secret data values to HTML",
"files": [],
"assertions": [
{
"name": "does-not-render-secret-data",
"description": "HTML does not contain Secret .data values"
},
{
"name": "renders-secret-safely",
"description": "Secret nodes show names and metadata only"
}
]
}
]
}
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SKILL_DIR = path.dirname(__dirname)
const TEMPLATE_PATH = path.join(SKILL_DIR, 'assets', 'canvas-template.html')
const SAFE_RESOURCE_KINDS = [
'deployment',
'pod',
'service',
'ingress',
'persistentvolumeclaim',
'event'
]
function main() {
const args = parseArgs(process.argv.slice(2))
const workDir = path.resolve(args.workDir || process.cwd())
const statePath = path.join(workDir, '.sealos', 'state.json')
const state = readJsonIfExists(statePath)
const lastDeploy = state?.last_deploy
if (!lastDeploy?.app_name || !lastDeploy?.namespace) {
return printStop('not_deployed', 'This project has not been deployed by Sealos Skills yet. Run /sealos-deploy first, then run /sealos-canvas again.')
}
let resources
const fixturePath = process.env.SEALOS_CANVAS_KUBE_FIXTURE
if (fixturePath) {
resources = readJson(path.resolve(fixturePath))
} else {
const kubeconfig = path.join(os.homedir(), '.sealos', 'kubeconfig')
if (!fs.existsSync(kubeconfig)) {
return printStop('kubeconfig_missing', 'Sealos kubeconfig was not found at ~/.sealos/kubeconfig. Run /sealos-deploy first, then run /sealos-canvas again.')
}
const kubectl = findKubectl()
if (!kubectl) {
return printStop('kubectl_missing', 'kubectl is required to view deployed Sealos resources. Install kubectl, then run /sealos-canvas again.')
}
resources = readLiveResources({ kubectl, kubeconfig, namespace: lastDeploy.namespace })
}
const theme = extractTheme(workDir)
const graph = buildGraph(lastDeploy, resources)
const canvasModel = buildCanvasModel({ graph, theme, lastDeploy })
const html = renderHtml({ canvasModel })
const outputDir = path.join(workDir, '.sealos', 'canvas')
const outputPath = path.join(outputDir, 'index.html')
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(outputPath, html)
if (args.serve) {
return serveCanvas({ host: args.host, port: args.port, outputDir, outputPath, graph, lastDeploy })
}
return printJson({
ok: true,
html_path: outputPath,
node_count: graph.nodes.length,
edge_count: graph.edges.length,
app_url: lastDeploy.url || ''
})
}
function parseArgs(argv) {
const args = { serve: true, host: '127.0.0.1', port: 0 }
for (let index = 0; index < argv.length; index++) {
const item = argv[index]
if (item === '--work-dir') {
args.workDir = argv[++index]
} else if (item === '--host') {
args.host = argv[++index]
} else if (item === '--port') {
args.port = Number(argv[++index])
} else if (item === '--no-serve') {
args.serve = false
} else if (item === '--help' || item === '-h') {
process.stdout.write('Usage: node generate-canvas.mjs --work-dir <repo-dir> [--host 127.0.0.1] [--port 0] [--no-serve]\n')
process.exit(0)
}
}
return args
}
function readJsonIfExists(filePath) {
if (!fs.existsSync(filePath)) return null
try {
return readJson(filePath)
} catch {
return null
}
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function printStop(reason, message) {
printJson({ ok: false, reason, message })
}
function printJson(data) {
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
}
function serveCanvas({ host, port, outputDir, outputPath, graph, lastDeploy }) {
const server = http.createServer((request, response) => {
const url = new URL(request.url || '/', `http://${host}`)
const pathname = url.pathname === '/' ? '/index.html' : url.pathname
if (pathname !== '/index.html') {
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
response.end('Not found')
return
}
fs.createReadStream(path.join(outputDir, 'index.html'))
.on('error', () => {
response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
response.end('Canvas HTML is unavailable')
})
.on('open', () => {
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store'
})
})
.pipe(response)
})
server.on('error', (error) => {
printJson({
ok: false,
reason: 'server_start_failed',
message: `Failed to start local Sealos canvas server: ${error.message}`
})
process.exitCode = 1
})
server.listen(port, host, () => {
const address = server.address()
const actualPort = typeof address === 'object' && address ? address.port : port
const localUrl = `http://${host}:${actualPort}/index.html`
printJson({
ok: true,
local_url: localUrl,
html_path: outputPath,
node_count: graph.nodes.length,
edge_count: graph.edges.length,
app_url: lastDeploy.url || ''
})
})
const shutdown = () => {
server.close(() => process.exit(0))
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
}
function findKubectl() {
const candidates = ['kubectl', path.join(os.homedir(), '.agents', 'bin', 'kubectl')]
for (const candidate of candidates) {
try {
execFileSync(candidate, ['version', '--client=true'], { stdio: 'ignore', timeout: 10000 })
return candidate
} catch {
// Try the next candidate.
}
}
return null
}
function readLiveResources({ kubectl, kubeconfig, namespace }) {
const env = { ...process.env, KUBECONFIG: kubeconfig }
const resources = {}
for (const kind of SAFE_RESOURCE_KINDS) {
try {
const stdout = execFileSync(
kubectl,
['--insecure-skip-tls-verify', '--request-timeout=8s', 'get', kind, '-n', namespace, '-o', 'json'],
{ env, encoding: 'utf8', timeout: 12000, maxBuffer: 12 * 1024 * 1024 }
)
resources[toResourceKey(kind)] = JSON.parse(stdout)
} catch (error) {
resources[toResourceKey(kind)] = { apiVersion: 'v1', items: [], error: readableExecError(error) }
}
}
resources.configmaps = readConfigMapSummaries({ kubectl, env, namespace })
resources.secrets = readSecretSummaries({ kubectl, env, namespace })
return resources
}
function readConfigMapSummaries({ kubectl, env, namespace }) {
try {
const template = '{{range .items}}{{.metadata.name}}{{"\\t"}}{{len .data}}{{"\\n"}}{{end}}'
const stdout = execFileSync(
kubectl,
['--insecure-skip-tls-verify', '--request-timeout=8s', 'get', 'configmap', '-n', namespace, '-o', `go-template=${template}`],
{ env, encoding: 'utf8', timeout: 12000, maxBuffer: 1024 * 1024 }
)
return {
apiVersion: 'v1',
items: stdout.trim().split('\n').filter(Boolean).map((line) => {
const [name, keyCount] = line.split('\t')
return { metadata: { name }, dataKeyCount: Number(keyCount || 0) }
})
}
} catch (error) {
return { apiVersion: 'v1', items: [], error: readableExecError(error) }
}
}
function readSecretSummaries({ kubectl, env, namespace }) {
try {
const template = '{{range .items}}{{.metadata.name}}{{"\\t"}}{{.type}}{{"\\n"}}{{end}}'
const stdout = execFileSync(
kubectl,
['--insecure-skip-tls-verify', '--request-timeout=8s', 'get', 'secret', '-n', namespace, '-o', `go-template=${template}`],
{ env, encoding: 'utf8', timeout: 12000, maxBuffer: 1024 * 1024 }
)
return {
apiVersion: 'v1',
items: stdout.trim().split('\n').filter(Boolean).map((line) => {
const [name, type] = line.split('\t')
return { metadata: { name }, type: type || 'Opaque' }
})
}
} catch (error) {
return { apiVersion: 'v1', items: [], error: readableExecError(error) }
}
}
function readableExecError(error) {
const text = String(error.stderr || error.message || error)
return text.trim().slice(0, 500)
}
function toResourceKey(kind) {
const map = {
deployment: 'deployments',
pod: 'pods',
service: 'services',
ingress: 'ingresses',
persistentvolumeclaim: 'persistentvolumeclaims',
configmap: 'configmaps',
secret: 'secrets',
event: 'events'
}
return map[kind] || kind
}
function extractTheme(workDir) {
const theme = {
accent: '#6c55ff',
radius: '8px',
font: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
}
const files = [
'tailwind.config.js',
'tailwind.config.ts',
'src/app/globals.css',
'app/globals.css',
'src/styles/globals.css',
'src/styles/theme.css',
'styles/globals.css',
'package.json'
].map((item) => path.join(workDir, item))
for (const filePath of files) {
if (!fs.existsSync(filePath) || fs.statSync(filePath).size > 200000) continue
const content = fs.readFileSync(filePath, 'utf8')
const accent = findAccent(content)
if (accent) theme.accent = accent
const radius = content.match(/--radius:\s*([^;\n]+)/)?.[1]?.trim()
|| content.match(/borderRadius:\s*\{[\s\S]*?(?:DEFAULT|lg):\s*['"]([^'"]+)['"]/)?.[1]
if (radius && radius.length < 32) theme.radius = normalizeRadius(radius)
const font = content.match(/--font-(?:sans|body):\s*([^;\n]+)/)?.[1]?.trim()
|| content.match(/fontFamily:\s*\{[\s\S]*?sans:\s*\[([^\]]+)/)?.[1]?.replaceAll("'", '').replaceAll('"', '').trim()
if (font && font.length < 120) theme.font = `${font}, ${theme.font}`
}
return theme
}
function findAccent(content) {
const patterns = [
/--(?:primary|accent|brand):\s*(#[0-9a-fA-F]{3,8})/,
/(?:primary|accent|brand):\s*['"](#[0-9a-fA-F]{3,8})['"]/
]
for (const pattern of patterns) {
const match = content.match(pattern)
if (match) return match[1] || match[0]
}
return null
}
function normalizeRadius(value) {
if (value.includes('var(') || value.includes('calc(')) return '8px'
if (/^\d+(\.\d+)?(px|rem|em)$/.test(value)) {
const match = value.match(/^(\d+(?:\.\d+)?)(px|rem|em)$/)
if (!match) return '8px'
if (match[2] === 'px') return `${Math.min(Number(match[1]), 8)}px`
if (match[2] === 'rem') return `${Math.min(Number(match[1]), 0.5)}rem`
return `${Math.min(Number(match[1]), 0.5)}em`
}
return '8px'
}
function buildGraph(lastDeploy, resources) {
const appName = lastDeploy.app_name
const namespace = lastDeploy.namespace
const deployments = items(resources.deployments)
const pods = items(resources.pods)
const services = items(resources.services)
const ingresses = items(resources.ingresses)
const pvcs = items(resources.persistentvolumeclaims)
const configmaps = items(resources.configmaps)
const secrets = items(resources.secrets)
const events = sanitizeEvents(items(resources.events), appName)
const deployment = deployments.find((item) => nameOf(item) === appName)
|| deployments.find((item) => nameOf(item)?.startsWith(appName))
|| deployments.find((item) => includesAppLabel(item, appName))
const deploymentName = nameOf(deployment) || appName
const selector = deployment?.spec?.selector?.matchLabels || {}
const relatedPods = pods.filter((pod) => isOwnedBy(pod, deploymentName) || labelsMatch(pod.metadata?.labels, selector) || nameOf(pod)?.startsWith(deploymentName))
const relatedServices = services.filter((service) => serviceTargetsPods(service, relatedPods) || nameOf(service) === appName || includesAppLabel(service, appName))
const relatedIngresses = ingresses.filter((ingress) => ingressTargetsServices(ingress, relatedServices) || ingressHostsUrl(ingress, lastDeploy.url))
const volumeClaims = collectVolumeClaims(deployment, relatedPods, pvcs)
const configRefs = collectConfigRefs(deployment, relatedPods, configmaps)
const secretRefs = collectSecretRefs(deployment, relatedPods, secrets)
const nodes = []
const edges = []
const appNode = {
id: 'app',
kind: 'Application',
title: appName,
subtitle: lastDeploy.url || `${deploymentName}.${namespace}`,
status: statusForDeployment(deployment),
statusText: statusTextForDeployment(deployment),
icon: 'app',
meta: [
['Namespace', namespace],
['Image', lastDeploy.image || firstContainerImage(deployment) || 'unknown'],
['Updated', lastDeploy.last_updated_at || lastDeploy.deployed_at || 'unknown']
],
attachments: volumeClaims.map((pvc) => ({ icon: 'volume', label: `${nameOf(pvc)} volume` }))
}
nodes.push(appNode)
if (relatedIngresses.length > 0 || lastDeploy.url) {
nodes.push({
id: 'ingress',
kind: 'Ingress',
title: relatedIngresses[0] ? nameOf(relatedIngresses[0]) : 'Public URL',
subtitle: lastDeploy.url || firstIngressHost(relatedIngresses[0]) || 'external access',
status: 'ready',
statusText: 'Published',
icon: 'ingress',
meta: [
['Host', stripProtocol(lastDeploy.url) || firstIngressHost(relatedIngresses[0]) || 'unknown'],
['Rules', String(relatedIngresses.reduce((total, ingress) => total + (ingress.spec?.rules?.length || 0), 0) || 1)]
]
})
edges.push({ from: 'ingress', to: 'app', label: 'routes', strong: true })
}
if (relatedServices.length > 0) {
nodes.push({
id: 'service',
kind: 'Service',
title: relatedServices.map(nameOf).filter(Boolean).join(', '),
subtitle: 'Cluster networking',
status: 'ready',
statusText: `${relatedServices.length} service${relatedServices.length === 1 ? '' : 's'}`,
icon: 'service',
meta: [
['Ports', relatedServices.flatMap((svc) => (svc.spec?.ports || []).map((port) => `${port.port}${port.targetPort ? `->${port.targetPort}` : ''}`)).join(', ') || 'none'],
['Type', [...new Set(relatedServices.map((svc) => svc.spec?.type || 'ClusterIP'))].join(', ')]
]
})
edges.push({ from: 'app', to: 'service', label: 'exposes' })
}
if (relatedPods.length > 0) {
const readyCount = relatedPods.filter(podReady).length
nodes.push({
id: 'pods',
kind: 'Pods',
title: `${readyCount}/${relatedPods.length} pods ready`,
subtitle: relatedPods.map(nameOf).filter(Boolean).slice(0, 2).join(', '),
status: readyCount === relatedPods.length ? 'ready' : readyCount === 0 ? 'failed' : 'warning',
statusText: readyCount === relatedPods.length ? 'Running' : 'Needs attention',
icon: 'pod',
meta: [
['Restart count', String(totalRestarts(relatedPods))],
['Phase', [...new Set(relatedPods.map((pod) => pod.status?.phase || 'Unknown'))].join(', ')]
],
attachments: volumeClaims.map((pvc) => ({ icon: 'volume', label: `${nameOf(pvc)} volume` }))
})
edges.push({ from: 'app', to: 'pods', label: 'runs' })
}
if (configRefs.length > 0) {
nodes.push({
id: 'config',
kind: 'Config',
title: `${configRefs.length} config reference${configRefs.length === 1 ? '' : 's'}`,
subtitle: configRefs.map((item) => item.name).slice(0, 3).join(', '),
status: 'ready',
statusText: 'Referenced',
icon: 'config',
meta: configRefs.slice(0, 4).map((item) => [item.kind, item.detail])
})
edges.push({ from: 'config', to: 'app', label: 'injects' })
}
if (secretRefs.length > 0) {
nodes.push({
id: 'secrets',
kind: 'Secrets',
title: `${secretRefs.length} secret reference${secretRefs.length === 1 ? '' : 's'}`,
subtitle: secretRefs.map((item) => item.name).slice(0, 3).join(', '),
status: 'warning',
statusText: 'Names only',
icon: 'secret',
meta: secretRefs.slice(0, 4).map((item) => [item.kind, item.detail])
})
edges.push({ from: 'secrets', to: 'app', label: 'injects' })
}
if (volumeClaims.length > 0) {
nodes.push({
id: 'storage',
kind: 'Storage',
title: `${volumeClaims.length} persistent volume${volumeClaims.length === 1 ? '' : 's'}`,
subtitle: volumeClaims.map(nameOf).filter(Boolean).join(', '),
status: volumeClaims.every((pvc) => pvc.status?.phase === 'Bound') ? 'ready' : 'warning',
statusText: volumeClaims.every((pvc) => pvc.status?.phase === 'Bound') ? 'Bound' : 'Pending',
icon: 'volume',
meta: volumeClaims.slice(0, 4).map((pvc) => [nameOf(pvc), pvc.spec?.resources?.requests?.storage || pvc.status?.phase || 'volume'])
})
edges.push({ from: 'app', to: 'storage', label: 'mounts' })
}
return layoutGraph({ nodes, edges, events, namespace, appName })
}
function items(resourceList) {
return Array.isArray(resourceList?.items) ? resourceList.items : []
}
function nameOf(resource) {
return resource?.metadata?.name || ''
}
function includesAppLabel(resource, appName) {
const labels = resource?.metadata?.labels || {}
return Object.values(labels).some((value) => String(value).includes(appName))
}
function isOwnedBy(resource, ownerName) {
return (resource?.metadata?.ownerReferences || []).some((owner) => owner.name === ownerName)
}
function labelsMatch(labels = {}, selector = {}) {
const entries = Object.entries(selector)
return entries.length > 0 && entries.every(([key, value]) => labels[key] === value)
}
function serviceTargetsPods(service, pods) {
const selector = service?.spec?.selector || {}
return Object.keys(selector).length > 0 && pods.some((pod) => labelsMatch(pod.metadata?.labels, selector))
}
function ingressTargetsServices(ingress, services) {
const names = new Set(services.map(nameOf))
const backends = []
for (const rule of ingress?.spec?.rules || []) {
for (const pathItem of rule.http?.paths || []) {
if (pathItem.backend?.service?.name) backends.push(pathItem.backend.service.name)
}
}
if (ingress?.spec?.defaultBackend?.service?.name) backends.push(ingress.spec.defaultBackend.service.name)
return backends.some((name) => names.has(name))
}
function ingressHostsUrl(ingress, url) {
const host = stripProtocol(url)
if (!host) return false
return (ingress?.spec?.rules || []).some((rule) => rule.host === host)
}
function firstIngressHost(ingress) {
return ingress?.spec?.rules?.[0]?.host || ''
}
function stripProtocol(url = '') {
return String(url).replace(/^https?:\/\//, '').replace(/\/$/, '')
}
function statusForDeployment(deployment) {
if (!deployment) return 'warning'
const desired = deployment.spec?.replicas ?? 1
const ready = deployment.status?.readyReplicas || 0
if (desired === 0) return 'sleeping'
if (ready >= desired) return 'ready'
if (ready === 0) return 'failed'
return 'warning'
}
function statusTextForDeployment(deployment) {
if (!deployment) return 'Deployment not found'
const desired = deployment.spec?.replicas ?? 1
const ready = deployment.status?.readyReplicas || 0
if (desired === 0) return 'Sleeping'
if (ready >= desired) return 'Running'
if (ready === 0) return 'Unavailable'
return `${ready}/${desired} ready`
}
function firstContainerImage(deployment) {
return deployment?.spec?.template?.spec?.containers?.[0]?.image || ''
}
function podReady(pod) {
return (pod.status?.conditions || []).some((condition) => condition.type === 'Ready' && condition.status === 'True')
}
function totalRestarts(pods) {
return pods.reduce((total, pod) => total + (pod.status?.containerStatuses || []).reduce((sum, container) => sum + (container.restartCount || 0), 0), 0)
}
function collectVolumeClaims(deployment, pods, pvcs) {
const names = new Set()
for (const spec of [deployment?.spec?.template?.spec, ...pods.map((pod) => pod.spec)]) {
for (const volume of spec?.volumes || []) {
if (volume.persistentVolumeClaim?.claimName) names.add(volume.persistentVolumeClaim.claimName)
}
}
return pvcs.filter((pvc) => names.has(nameOf(pvc)))
}
function collectConfigRefs(deployment, pods, configmaps) {
const existing = new Map(configmaps.map((item) => [nameOf(item), item]))
const refs = new Map()
for (const container of allContainers(deployment, pods)) {
for (const envFrom of container.envFrom || []) {
if (envFrom.configMapRef?.name) addConfigRef(refs, existing, envFrom.configMapRef.name, 'ConfigMap', 'envFrom')
}
for (const env of container.env || []) {
if (env.valueFrom?.configMapKeyRef?.name) addConfigRef(refs, existing, env.valueFrom.configMapKeyRef.name, 'ConfigMap', `key ${env.valueFrom.configMapKeyRef.key || env.name}`)
}
}
for (const spec of [deployment?.spec?.template?.spec, ...pods.map((pod) => pod.spec)]) {
for (const volume of spec?.volumes || []) {
if (volume.configMap?.name) addConfigRef(refs, existing, volume.configMap.name, 'ConfigMap', 'mounted volume')
}
}
return [...refs.values()]
}
function addConfigRef(refs, existing, name, kind, detail) {
const config = existing.get(name)
const keyCount = config?.dataKeyCount || 0
refs.set(`${kind}:${name}:${detail}`, { name, kind, detail: keyCount ? `${detail}, ${keyCount} keys` : detail })
}
function collectSecretRefs(deployment, pods, secrets) {
const existing = new Map(secrets.map((item) => [nameOf(item), item]))
const refs = new Map()
for (const container of allContainers(deployment, pods)) {
for (const envFrom of container.envFrom || []) {
if (envFrom.secretRef?.name) addSecretRef(refs, existing, envFrom.secretRef.name, 'Secret', 'envFrom')
}
for (const env of container.env || []) {
if (env.valueFrom?.secretKeyRef?.name) addSecretRef(refs, existing, env.valueFrom.secretKeyRef.name, 'Secret', `key ${env.valueFrom.secretKeyRef.key || env.name}`)
}
}
for (const spec of [deployment?.spec?.template?.spec, ...pods.map((pod) => pod.spec)]) {
for (const volume of spec?.volumes || []) {
if (volume.secret?.secretName) addSecretRef(refs, existing, volume.secret.secretName, 'Secret', 'mounted volume')
}
for (const secret of spec?.imagePullSecrets || []) {
if (secret.name) addSecretRef(refs, existing, secret.name, 'ImagePullSecret', 'image pull')
}
}
return [...refs.values()]
}
function addSecretRef(refs, existing, name, kind, detail) {
const secret = existing.get(name)
refs.set(`${kind}:${name}:${detail}`, { name, kind, detail: secret?.type ? `${detail}, ${secret.type}` : detail })
}
function allContainers(deployment, pods) {
const containers = []
containers.push(...(deployment?.spec?.template?.spec?.containers || []))
for (const pod of pods) containers.push(...(pod.spec?.containers || []))
return containers
}
function sanitizeEvents(events, appName) {
return events
.filter((event) => {
const involved = event.involvedObject?.name || ''
return involved.includes(appName) || String(event.message || '').includes(appName)
})
.sort((a, b) => String(b.lastTimestamp || b.eventTime || b.metadata?.creationTimestamp || '').localeCompare(String(a.lastTimestamp || a.eventTime || a.metadata?.creationTimestamp || '')))
.slice(0, 8)
.map((event) => ({
type: event.type || 'Normal',
reason: event.reason || 'Event',
involved: event.involvedObject?.name || '',
message: String(event.message || '').slice(0, 180),
time: event.lastTimestamp || event.eventTime || event.metadata?.creationTimestamp || ''
}))
}
function layoutGraph(graph) {
const preferred = {
ingress: [80, 80],
app: [520, 120],
service: [520, 390],
pods: [980, 120],
config: [80, 390],
secrets: [80, 630],
storage: [980, 390]
}
const fallback = [[520, 630], [980, 630], [1440, 120], [1440, 390]]
let fallbackIndex = 0
for (const node of graph.nodes) {
const coords = preferred[node.id] || fallback[fallbackIndex++] || [80 + fallbackIndex * 440, 80]
node.x = coords[0]
node.y = coords[1]
node.width = ['config', 'secrets'].includes(node.id) ? 330 : 390
node.height = estimateNodeHeight(node)
}
graph.width = Math.max(1320, ...graph.nodes.map((node) => node.x + node.width + 100))
graph.height = Math.max(820, ...graph.nodes.map((node) => node.y + node.height + 100))
return graph
}
function estimateNodeHeight(node) {
const attachments = node.attachments?.length || 0
const meta = Math.min(node.meta?.length || 0, 4)
return 132 + meta * 24 + attachments * 58
}
function buildCanvasModel({ graph, theme, lastDeploy }) {
return {
generatedAt: new Date().toISOString(),
app: {
name: lastDeploy.app_name,
namespace: lastDeploy.namespace,
url: lastDeploy.url || '',
image: lastDeploy.image || '',
status: graph.nodes.find((node) => node.id === 'app')?.status || 'warning',
updatedAt: lastDeploy.last_updated_at || lastDeploy.deployed_at || ''
},
layout: {
width: graph.width,
height: graph.height
},
nodes: graph.nodes,
edges: graph.edges,
events: graph.events,
theme
}
}
function renderHtml({ canvasModel }) {
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8')
const title = `${canvasModel.app.name} - Sealos Canvas`
return template
.replaceAll('__TITLE__', escapeHtml(title))
.replace('__CANVAS_MODEL__', escapeScriptJson(canvasModel))
}
function escapeHtml(value = '') {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}
function escapeCss(value = '') {
return String(value).replace(/[<>]/g, '')
}
function escapeScriptJson(data) {
return JSON.stringify(data)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029')
}
try {
main()
} catch (error) {
printJson({
ok: false,
reason: 'canvas_generation_failed',
message: `Failed to generate Sealos canvas: ${error.message}`
})
}