provisioning-platform/crates/provisioning-daemon/ui/templates/pages/workspace_dag.html

342 lines
16 KiB
HTML
Raw Normal View History

{% extends "base.html" %}
{% import "macros/ui.html" as m %}
{% block title %}{{ ws_name }} · DAG{% endblock %}
{% block nav_workspaces %}btn-active{% endblock %}
{% block main_class %}p-0{% endblock %}
{% block breadcrumb_extra %}
<span class="text-base-content/20">/</span>
<span class="font-mono text-base-content/60 text-xs">dag</span>
{% endblock %}
{% block head %}
<script src="/public/vendor/cytoscape.min.js"></script>
{% endblock %}
{% block content %}
<style>
#dag-root { display: flex; height: calc(100vh - 64px - 48px); min-height: 400px; }
#cy-wrap { flex: 1 1 auto; min-width: 0; position: relative; }
#cy { width: 100%; height: 100%; }
#dag-controls {
position: absolute; top: 14px; right: 14px; z-index: 10;
display: flex; flex-direction: column; gap: 4px;
}
#dag-controls button {
width: 30px; height: 30px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.6);
color: #fff; font-size: 16px; cursor: pointer;
}
#dag-controls button:hover { background: rgba(80,80,80,0.8); }
#dag-sidebar {
width: 260px; flex-shrink: 0; overflow-y: auto;
background: oklch(var(--b2, 20% 0 0));
border-left: 1px solid oklch(var(--b3, 15% 0 0));
padding: 1rem; font-size: 0.75rem;
}
</style>
{{ m::ws_tabs(ws_name=ws_name, active=ws_active_tab, ws_envs=ws_envs, ws_has_clusters=ws_has_clusters, ws_has_workflows=ws_has_workflows, current_env=current_env) }}
<div id="dag-root">
<div id="cy-wrap">
<div id="cy"></div>
<div id="dag-controls">
<button onclick="cy.fit()" title="Fit"></button>
<button onclick="cy.zoom(cy.zoom()*1.25)" title="Zoom in">+</button>
<button onclick="cy.zoom(cy.zoom()*0.8)" title="Zoom out"></button>
<button onclick="resetLayout()" title="Re-layout"></button>
</div>
</div>
<div id="dag-sidebar">
<p class="text-base-content/40 text-xs mb-3">Click a node to inspect.</p>
<div id="node-detail"></div>
</div>
</div>
<script>
var DAG = {{ dag_json | safe }};
var cy = null;
var DAG_WS = {{ ws_name | json_encode() | safe }};
function nodeColor(n) {
var kind = (n.kind || '').toLowerCase();
if (kind === 'host') return '#6366f1';
if (kind === 'cluster') return '#f59e0b';
if (kind === 'taskserv') return '#3b82f6';
return '#6b7280';
}
function buildElements() {
var nodes = (DAG.nodes || []).map(function(n) {
return { data: {
id: n.id,
label: n.label || n.id,
kind: n.kind || 'taskserv',
description: n.description || '',
color: nodeColor(n),
health_gate: n.health_gate || false,
server: n.server || '',
ws: n.ws || DAG_WS,
taskserv_name: n.taskserv_name || '',
comp_name: n.comp_name || '',
namespace: n.namespace || '',
comp_class: n.class || '',
resources: n.resources || '',
placement: n.placement || '',
}};
});
var edges = (DAG.edges || []).map(function(e) {
return { data: { id: e.id, source: e.source, target: e.target, kind: e.kind || '' } };
});
return nodes.concat(edges);
}
function renderHostPanel(d) {
var serverUrl = '/ui/workspaces/' + d.ws + '/servers/' + encodeURIComponent(d.server);
return '<div class="mb-3 flex items-center gap-2">' +
'<span class="badge badge-xs font-mono" style="background:#6366f1;color:#fff">host</span>' +
(d.health_gate ? '<span class="badge badge-xs badge-info">health-gate</span>' : '') +
'</div>' +
'<p class="font-mono font-semibold text-sm mb-2">' + d.label + '</p>' +
(d.description ? '<p class="text-base-content/60 text-xs mb-3">' + d.description + '</p>' : '') +
'<div class="flex flex-col gap-1.5">' +
'<a href="' + serverUrl + '" class="btn btn-xs btn-outline font-mono gap-1" onclick="window._spaNavigate && window._spaNavigate(this.href,true); return false;">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>' +
'Server detail</a>' +
'<button onclick="FloatPanels.openTerminal(\'ssh-' + d.server + '\',\'ssh: ' + d.server + '\',\'/ui/workspaces/' + d.ws + '/servers/' + encodeURIComponent(d.server) + '/terminal\')" ' +
'class="btn btn-xs btn-ghost font-mono gap-1 border border-base-content/10">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>' +
'terminal</button>' +
'</div>';
}
var dagCurrentNode = null;
function renderClusterPanel(d) {
var comp = d.comp_name || d.taskserv_name || d.label;
var ns = d.namespace || '';
return '<div class="mb-3 flex items-center gap-2">' +
'<span class="badge badge-xs font-mono" style="background:#f59e0b;color:#000">cluster</span>' +
(d.health_gate ? '<span class="badge badge-xs badge-info">health-gate</span>' : '') +
'</div>' +
'<p class="font-mono font-semibold text-sm mb-1">' + d.label + '</p>' +
(d.server ? '<p class="text-base-content/40 text-xs mb-2">on ' + d.server + '</p>' : '') +
(d.description ? '<p class="text-base-content/60 text-xs mb-3">' + d.description + '</p>' : '') +
'<div class="flex flex-col gap-1.5">' +
'<button onclick="dagShowClusterInfo(dagCurrentNode)" class="btn btn-xs btn-outline font-mono gap-1">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>' +
'Cluster info</button>' +
'<button onclick="dagLoadResources(\'' + comp + '\',\'' + ns + '\')" class="btn btn-xs btn-ghost font-mono gap-1 border border-base-content/10">' +
'<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>' +
'Live Deploy</button>' +
'</div>' +
'<div id="dag-res-out" class="mt-3 text-xs font-mono"></div>';
}
function dagShowClusterInfo(d) {
var out = document.getElementById('dag-res-out');
if (!out || !d) return;
var comp = d.comp_name || d.taskserv_name || d.label;
var row = function(label, val) {
return '<div class="flex gap-2 py-1 border-b border-base-300/20">' +
'<span class="text-base-content/40 w-20 flex-shrink-0">' + label + '</span>' +
'<span class="font-mono truncate">' + val + '</span></div>';
};
var html = '<div class="divide-y divide-base-300/20">';
html += row('component', comp);
if (d.namespace) html += row('namespace', d.namespace);
if (d.server) html += row('server', d.server);
if (d.comp_class) html += row('class', '<span class="badge badge-ghost badge-xs">' + d.comp_class + '</span>');
if (d.resources && d.resources !== '—') html += row('resources', d.resources);
if (d.placement && d.placement !== '—') html += row('placement', d.placement);
html += '</div>';
out.innerHTML = html;
}
function renderTaskPanel(d) {
return '<div class="mb-3 flex items-center gap-2">' +
'<span class="badge badge-xs font-mono" style="background:#3b82f6;color:#fff">' + (d.kind || 'taskserv') + '</span>' +
(d.health_gate ? '<span class="badge badge-xs badge-info">health-gate</span>' : '') +
'</div>' +
'<p class="font-mono font-semibold text-sm mb-1">' + d.label + '</p>' +
(d.server ? '<p class="text-base-content/40 text-xs mb-2">on ' + d.server + '</p>' : '') +
(d.description ? '<p class="text-base-content/60 text-xs mb-2">' + d.description + '</p>' : '');
}
var RES_KIND_ORDER = ['deployment','statefulset','pod','service','persistentvolumeclaim','ingress','secret'];
var RES_KIND_LABELS = {
deployment: 'Deployments', statefulset: 'StatefulSets', pod: 'Pods',
service: 'Services', persistentvolumeclaim: 'PVCs', ingress: 'Ingress', secret: 'Secrets'
};
var dagResComp = '';
var dagResNs = '';
function dagRenderResources(data) {
var out = document.getElementById('dag-res-out');
if (!out) return;
var resources = data.resources || {};
if (data.error) { out.innerHTML = '<p class="text-error">' + data.error + '</p>'; return; }
var html = '<p class="text-base-content/40 mb-2">ns: ' + (data.namespace || '?') + '</p>';
var kinds = RES_KIND_ORDER.filter(function(k) { return resources[k] && resources[k].length; });
if (!kinds.length) { out.innerHTML = html + '<p class="text-base-content/30">no resources</p>'; return; }
var baseUrl = '/ui/workspaces/' + DAG_WS + '/components/' + encodeURIComponent(dagResComp) + '/pods/' + encodeURIComponent(dagResNs) + '/';
kinds.forEach(function(kind) {
var items = resources[kind] || [];
html += '<div class="mb-2"><p class="text-base-content/40 uppercase tracking-wider mb-1" style="font-size:0.6rem">' +
(RES_KIND_LABELS[kind] || kind) + ' (' + items.length + ')</p>';
items.forEach(function(r) {
html += '<div class="flex items-start gap-1 py-0.5 border-b border-base-300/30">';
if (kind === 'pod') {
var cls = r.phase === 'Running' ? 'text-success' : r.phase === 'Failed' ? 'text-error' : 'text-warning';
var pn = encodeURIComponent(r.name);
var dUrl = baseUrl + pn + '/describe';
var lUrl = baseUrl + pn + '/logs';
var eUrl = baseUrl + pn + '/exec';
html += '<span class="w-1.5 h-1.5 rounded-full mt-1.5 flex-shrink-0 bg-current ' + cls + '"></span>' +
'<span class="truncate flex-1">' + r.name + '</span>' +
'<span class="ml-1 ' + cls + ' flex-shrink-0 text-right" style="min-width:3rem">' + r.phase + '</span>' +
'<span class="ml-1 text-base-content/30 flex-shrink-0">' + (r.ready||'') + '</span>' +
'<div class="flex gap-0.5 ml-1 flex-shrink-0">' +
'<button title="describe" onclick="FloatPanels.openContent(\'desc-' + r.name + '\',\'describe: ' + r.name + '\',\'' + dUrl + '\')" class="btn btn-xs btn-ghost px-1 font-mono" style="font-size:0.6rem">desc</button>' +
'<button title="logs" onclick="FloatPanels.openContent(\'logs-' + r.name + '\',\'logs: ' + r.name + '\',\'' + lUrl + '\',{logControls:true})" class="btn btn-xs btn-ghost px-1 font-mono" style="font-size:0.6rem">log</button>' +
'<button title="exec" onclick="FloatPanels.openTerminal(\'exec-' + r.name + '\',\'exec: ' + r.name + '\',\'' + eUrl + '\')" class="btn btn-xs btn-ghost px-1 font-mono" style="font-size:0.6rem">sh</button>' +
'</div>';
} else if (kind === 'service') {
html += '<span class="badge badge-xs badge-ghost mr-1 flex-shrink-0">' + (r.type||'') + '</span>' +
'<span class="truncate flex-1">' + r.name + '</span>' +
'<span class="ml-1 text-base-content/30 flex-shrink-0 text-right" style="max-width:80px;overflow:hidden;text-overflow:ellipsis">' + (r.ports||'') + '</span>';
} else if (kind === 'persistentvolumeclaim') {
var pvCls = r.status === 'Bound' ? 'text-success' : 'text-warning';
html += '<span class="truncate flex-1">' + r.name + '</span>' +
'<span class="ml-1 ' + pvCls + ' flex-shrink-0">' + (r.status||'') + '</span>' +
'<span class="ml-1 text-base-content/30 flex-shrink-0">' + (r.capacity||'') + '</span>';
} else if (kind === 'ingress') {
html += '<span class="truncate flex-1">' + r.name + '</span>' +
'<span class="ml-1 text-base-content/40 flex-shrink-0 truncate" style="max-width:110px">' + (r.hosts||'') + '</span>';
} else if (kind === 'secret') {
html += '<span class="truncate flex-1">' + r.name + '</span>' +
'<span class="ml-1 text-base-content/30 flex-shrink-0">' + (r.keys||0) + ' keys</span>';
} else if (kind === 'deployment' || kind === 'statefulset') {
html += '<span class="truncate flex-1">' + r.name + '</span>' +
'<span class="ml-1 text-base-content/50 flex-shrink-0">' + (r.ready||'') + '</span>';
} else {
html += '<span class="truncate">' + r.name + '</span>';
}
html += '</div>';
});
html += '</div>';
});
out.innerHTML = html;
}
function dagLoadResources(comp, ns) {
var out = document.getElementById('dag-res-out');
if (!out) return;
dagResComp = comp;
dagResNs = ns || '';
out.innerHTML = '<p class="text-base-content/40">loading…</p>';
var url = '/ui/workspaces/' + DAG_WS + '/components/' + encodeURIComponent(comp) + '/resources';
if (ns) url += '?ns=' + encodeURIComponent(ns);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.namespace) dagResNs = data.namespace;
dagRenderResources(data);
})
.catch(function() {
var o = document.getElementById('dag-res-out');
if (o) o.innerHTML = '<p class="text-error">error</p>';
});
}
function initCytoscape() {
cy = cytoscape({
container: document.getElementById('cy'),
elements: buildElements(),
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)',
'label': 'data(label)',
'color': '#f1f5f9',
'font-size': '8px',
'font-family': 'ui-monospace, monospace',
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': '56px',
'text-overflow-wrap': 'anywhere',
'width': 72, 'height': 72,
'border-width': 2,
'border-color': 'rgba(255,255,255,0.15)',
}
},
{
selector: 'node[?health_gate]',
style: { 'border-width': 3, 'border-color': '#a5b4fc', 'border-style': 'double' }
},
{
selector: 'edge',
style: {
'width': 1.5,
'line-color': 'rgba(156,163,175,0.4)',
'target-arrow-color': 'rgba(156,163,175,0.6)',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 0.8,
}
},
{
selector: 'node:selected',
style: { 'border-color': '#818cf8', 'border-width': 3 }
},
],
layout: { name: 'cose', animate: false, nodeRepulsion: 8000, idealEdgeLength: 100, gravity: 0.25 },
});
cy.on('tap', 'node', function(evt) {
var d = evt.target.data();
dagCurrentNode = d;
var html = '';
if (d.kind === 'host') {
html = renderHostPanel(d);
} else if (d.kind === 'cluster') {
html = renderClusterPanel(d);
} else {
html = renderTaskPanel(d);
}
document.getElementById('node-detail').innerHTML = html;
});
cy.on('tap', function(evt) {
if (evt.target === cy) document.getElementById('node-detail').innerHTML = '<p class="text-base-content/40 text-xs">Click a node to inspect.</p>';
});
}
function resetLayout() {
if (cy) cy.layout({ name: 'cose', animate: true, nodeRepulsion: 8000, idealEdgeLength: 100 }).run();
}
function runDag() {
initCytoscape();
if ((DAG.nodes || []).length === 0) {
document.getElementById('node-detail').innerHTML =
'<p class="text-base-content/40 text-xs">No DAG data.<br>Add <code class="font-mono">dag.ncl</code> to an infra env.</p>';
}
}
if (typeof cytoscape !== 'undefined') {
// Defer to next animation frame so the browser completes layout for #cy before
// Cytoscape measures the container — SPA innerHTML swap doesn't trigger a sync layout.
requestAnimationFrame(runDag);
} else {
var s = document.createElement('script');
s.src = '/public/vendor/cytoscape.min.js';
s.onload = runDag;
document.head.appendChild(s);
}
</script>
{% endblock %}