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

341 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 %}