327 lines
14 KiB
HTML
327 lines
14 KiB
HTML
|
|
{% extends "base.html" %}
|
||
|
|
{% import "macros/ui.html" as m %}
|
||
|
|
{% block title %}{{ ws_name }} · Clusters{% endblock %}
|
||
|
|
{% block nav_workspaces %}btn-active{% endblock %}
|
||
|
|
|
||
|
|
{% block content %}
|
||
|
|
{{ 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) }}
|
||
|
|
|
||
|
|
{# ── Controls bar ──────────────────────────────────────────────────────────── #}
|
||
|
|
<div class="flex items-center gap-2 mb-4">
|
||
|
|
<input type="text" id="search-input" placeholder="search…"
|
||
|
|
oninput="applySearch()"
|
||
|
|
class="input input-sm input-bordered w-48 font-mono text-xs" />
|
||
|
|
<button id="auto-btn" class="btn btn-sm btn-ghost font-mono text-xs"
|
||
|
|
onclick="toggleAuto()" title="Auto-refresh every 30 s">auto</button>
|
||
|
|
<a href="/ui/workspaces/{{ ws_name }}/clusters/services"
|
||
|
|
class="btn btn-sm btn-ghost font-mono text-xs gap-1">
|
||
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M5 12h14M12 5l7 7-7 7"/>
|
||
|
|
</svg>
|
||
|
|
services
|
||
|
|
</a>
|
||
|
|
<span class="text-xs text-base-content/40 ml-auto" id="refresh-ts"></span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{# ── Live node metrics (cluster top) ──────────────────────────────────────── #}
|
||
|
|
{% if top %}
|
||
|
|
{% if top.nodes %}
|
||
|
|
<div class="mb-6">
|
||
|
|
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-1">Node Metrics</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="table table-xs font-mono" id="nodes-table">
|
||
|
|
<thead>
|
||
|
|
<tr class="text-base-content/40 text-xs uppercase tracking-wider">
|
||
|
|
<th>Node</th>
|
||
|
|
<th class="text-right">CPU</th>
|
||
|
|
<th class="text-right">CPU%</th>
|
||
|
|
<th class="text-right">Mem</th>
|
||
|
|
<th class="text-right">Mem%</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for n in top.nodes %}
|
||
|
|
<tr class="hover" data-name="{{ n.node }}">
|
||
|
|
<td class="font-semibold text-primary">{{ n.node }}</td>
|
||
|
|
<td class="text-right tabular-nums">{{ n.cpu }}</td>
|
||
|
|
<td class="text-right tabular-nums">
|
||
|
|
{% set pct = n.cpu_pct | replace(from="%", to="") | int(default=0) %}
|
||
|
|
<span class="{% if pct >= 80 %}text-error{% elif pct >= 60 %}text-warning{% else %}text-success{% endif %}">
|
||
|
|
{{ n.cpu_pct }}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="text-right tabular-nums">{{ n.mem }}</td>
|
||
|
|
<td class="text-right tabular-nums">
|
||
|
|
{% set mpct = n.mem_pct | replace(from="%", to="") | int(default=0) %}
|
||
|
|
<span class="{% if mpct >= 80 %}text-error{% elif mpct >= 60 %}text-warning{% else %}text-success{% endif %}">
|
||
|
|
{{ n.mem_pct }}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs text-base-content/30 mt-1">
|
||
|
|
{{ top.nodes | length }} node(s) — kubectl top (live)
|
||
|
|
·
|
||
|
|
<button class="link link-accent text-xs" onclick="loadTopPods()">show top pods</button>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
{% else %}
|
||
|
|
<div class="alert alert-ghost mb-4 py-2">
|
||
|
|
<span class="text-xs text-base-content/40">Node metrics unavailable — metrics-server may not be reachable or no control-plane resolved.</span>
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
{# ── Pod top panel (loaded on demand) ─────────────────────────────────────── #}
|
||
|
|
<div id="pod-top-panel" class="hidden mb-6">
|
||
|
|
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-1">Top Pods by Memory</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="table table-xs font-mono">
|
||
|
|
<thead>
|
||
|
|
<tr class="text-base-content/40 text-xs uppercase">
|
||
|
|
<th>Namespace</th><th>Pod</th>
|
||
|
|
<th class="text-right">CPU</th><th class="text-right">Mem</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody id="pod-top-body"></tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{# ── Cluster summary badge (from .cluster-state.json cache) ──────────────── #}
|
||
|
|
{% if cluster_summary %}
|
||
|
|
<div class="flex items-center gap-3 mb-4 font-mono text-xs text-base-content/50 bg-base-200 rounded-box px-4 py-2 flex-wrap">
|
||
|
|
{% if cluster_summary.cp_ip %}<span class="text-primary font-semibold">CP: {{ cluster_summary.cp_ip }}</span>{% endif %}
|
||
|
|
{% if cluster_summary.nodes_total and cluster_summary.nodes_total > 0 %}
|
||
|
|
<span>{{ cluster_summary.nodes_ready }}/{{ cluster_summary.nodes_total }} nodes</span>
|
||
|
|
{% endif %}
|
||
|
|
{% if cluster_summary.pods_total and cluster_summary.pods_total > 0 %}
|
||
|
|
<span>{{ cluster_summary.pods_running }}/{{ cluster_summary.pods_total }} pods</span>
|
||
|
|
{% endif %}
|
||
|
|
{% if cluster_summary.namespaces and cluster_summary.namespaces > 0 %}
|
||
|
|
<span>{{ cluster_summary.namespaces }} ns</span>
|
||
|
|
{% endif %}
|
||
|
|
{% if cluster_summary.deployments and cluster_summary.deployments > 0 %}
|
||
|
|
<span>{{ cluster_summary.deployments }} dply</span>
|
||
|
|
{% endif %}
|
||
|
|
{% if cluster_summary.pvcs_bound and cluster_summary.pvcs_bound > 0 %}
|
||
|
|
<span>{{ cluster_summary.pvcs_bound }} pvcs</span>
|
||
|
|
{% endif %}
|
||
|
|
{% if cluster_summary.version %}<span class="text-base-content/30">{{ cluster_summary.version }}</span>{% endif %}
|
||
|
|
{% if cluster_summary.last_sync %}<span class="text-base-content/20 ml-auto">cache {{ cluster_summary.last_sync | truncate(length=16, end="") }}</span>{% endif %}
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
{# ── Cluster-mode components — matches `prvng cl l` ───────────────────────── #}
|
||
|
|
{% if cluster_components %}
|
||
|
|
<div class="mb-6">
|
||
|
|
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-1">Cluster Components</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="table table-sm font-mono" id="comp-table">
|
||
|
|
<thead>
|
||
|
|
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
|
||
|
|
<th>#</th>
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Namespace</th>
|
||
|
|
<th>Version</th>
|
||
|
|
<th>Class</th>
|
||
|
|
<th>Resources</th>
|
||
|
|
<th>Placement</th>
|
||
|
|
<th>Deploy</th>
|
||
|
|
<th title="Live: pod count or hcloud status">Live</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for comp in cluster_components %}
|
||
|
|
<tr class="hover" data-name="{{ comp.name }}">
|
||
|
|
<td class="text-base-content/30 tabular-nums text-xs">{{ loop.index0 }}</td>
|
||
|
|
<td>
|
||
|
|
<a href="/ui/workspaces/{{ ws_name }}/components/{{ comp.name }}"
|
||
|
|
class="text-primary font-semibold hover:underline">{{ comp.name }}</a>
|
||
|
|
</td>
|
||
|
|
<td class="text-base-content/60 text-xs">
|
||
|
|
{% if comp.namespace %}{{ comp.namespace }}{% else %}<span class="text-base-content/20">—</span>{% endif %}
|
||
|
|
</td>
|
||
|
|
<td class="text-base-content/60 text-xs">
|
||
|
|
{% if comp.version %}{{ comp.version }}{% else %}<span class="text-base-content/20">—</span>{% endif %}
|
||
|
|
</td>
|
||
|
|
<td class="text-base-content/70 text-xs">
|
||
|
|
{% if comp.class %}
|
||
|
|
<span class="badge badge-ghost badge-xs">{{ comp.class }}</span>
|
||
|
|
{% else %}<span class="text-base-content/20">—</span>{% endif %}
|
||
|
|
</td>
|
||
|
|
<td class="text-base-content/60 text-xs whitespace-nowrap">
|
||
|
|
{% if comp.resources and comp.resources != "—" %}{{ comp.resources }}{% else %}<span class="text-base-content/20">—</span>{% endif %}
|
||
|
|
</td>
|
||
|
|
<td class="text-base-content/60 text-xs">
|
||
|
|
{% if comp.placement and comp.placement != "—" %}{{ comp.placement }}{% else %}<span class="text-base-content/20">—</span>{% endif %}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
{% if comp.deploy == "completed" %}
|
||
|
|
<span class="badge badge-success badge-xs">completed</span>
|
||
|
|
{% elif comp.deploy == "pending" %}
|
||
|
|
<span class="badge badge-ghost badge-xs">pending</span>
|
||
|
|
{% elif comp.deploy == "failed" %}
|
||
|
|
<span class="badge badge-error badge-xs">failed</span>
|
||
|
|
{% elif comp.deploy %}
|
||
|
|
<span class="badge badge-outline badge-xs">{{ comp.deploy }}</span>
|
||
|
|
{% else %}
|
||
|
|
<span class="text-base-content/20">—</span>
|
||
|
|
{% endif %}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
{% if comp.live and comp.live != "" %}
|
||
|
|
<span class="badge badge-xs font-mono badge-success">{{ comp.live }}</span>
|
||
|
|
{% elif comp.ncl_state and comp.ncl_state != "" %}
|
||
|
|
{% if comp.ncl_state == "completed" %}
|
||
|
|
<span class="badge badge-success badge-xs">{{ comp.ncl_state }}</span>
|
||
|
|
{% elif comp.ncl_state == "running" %}
|
||
|
|
<span class="badge badge-warning badge-xs">{{ comp.ncl_state }}</span>
|
||
|
|
{% elif comp.ncl_state == "failed" %}
|
||
|
|
<span class="badge badge-error badge-xs">{{ comp.ncl_state }}</span>
|
||
|
|
{% else %}
|
||
|
|
<span class="badge badge-ghost badge-xs">{{ comp.ncl_state }}</span>
|
||
|
|
{% endif %}
|
||
|
|
{% else %}
|
||
|
|
<span class="text-base-content/20">—</span>
|
||
|
|
{% endif %}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs text-base-content/30 mt-2">{{ cluster_components | length }} component(s)</p>
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
{# ── Cluster definitions (clusters/*.ncl) ─────────────────────────────────── #}
|
||
|
|
{% if clusters %}
|
||
|
|
{% if clusters.items %}
|
||
|
|
<div class="mb-2">
|
||
|
|
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-1">Cluster Definitions</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="table table-sm font-mono" id="clusters-table">
|
||
|
|
<thead>
|
||
|
|
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Workspace</th>
|
||
|
|
<th>Env</th>
|
||
|
|
<th>Labels</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{% for cl in clusters.items %}
|
||
|
|
<tr class="hover" data-name="{{ cl.name }}">
|
||
|
|
<td class="font-semibold text-primary">{{ cl.name }}</td>
|
||
|
|
<td class="text-base-content/60 text-xs">{{ cl.workspace | default(value="—") }}</td>
|
||
|
|
<td class="text-base-content/50 text-xs">{{ cl.env | default(value="—") }}</td>
|
||
|
|
<td>
|
||
|
|
{% if cl.config.labels %}
|
||
|
|
{% for k, v in cl.config.labels %}
|
||
|
|
<span class="badge badge-ghost badge-xs">{{ k }}={{ v }}</span>
|
||
|
|
{% endfor %}
|
||
|
|
{% endif %}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs text-base-content/30 mt-1">{{ clusters.total | default(value=clusters.items | length) }} cluster definition(s)</p>
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
{% if not cluster_components and not clusters %}
|
||
|
|
<div class="alert alert-warning">
|
||
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-2.194-.833-2.964 0L3.232 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||
|
|
</svg>
|
||
|
|
<span>Cluster data unavailable — no cluster provider configured.</span>
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block scripts %}
|
||
|
|
<script>
|
||
|
|
var WS_NAME = {{ ws_name | json_encode() | safe }};
|
||
|
|
var _autoTimer = null;
|
||
|
|
|
||
|
|
function applySearch() {
|
||
|
|
const q = document.getElementById('search-input').value.toLowerCase();
|
||
|
|
for (const tbl of ['nodes-table', 'comp-table', 'clusters-table']) {
|
||
|
|
const t = document.getElementById(tbl);
|
||
|
|
if (!t) continue;
|
||
|
|
t.querySelectorAll('tbody tr').forEach(row => {
|
||
|
|
const name = (row.dataset.name || '').toLowerCase();
|
||
|
|
row.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleAuto() {
|
||
|
|
const btn = document.getElementById('auto-btn');
|
||
|
|
if (_autoTimer) {
|
||
|
|
clearInterval(_autoTimer);
|
||
|
|
_autoTimer = null;
|
||
|
|
btn.classList.remove('btn-accent');
|
||
|
|
} else {
|
||
|
|
_autoTimer = setInterval(refreshTop, 30000);
|
||
|
|
btn.classList.add('btn-accent');
|
||
|
|
refreshTop();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refreshTop() {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/ui/workspaces/${WS_NAME}/clusters/top`);
|
||
|
|
if (!res.ok) return;
|
||
|
|
const data = await res.json();
|
||
|
|
const tbody = document.querySelector('#nodes-table tbody');
|
||
|
|
if (!tbody || !data.nodes) return;
|
||
|
|
tbody.innerHTML = data.nodes.map(n => {
|
||
|
|
const cp = parseInt(n.cpu_pct) || 0;
|
||
|
|
const mp = parseInt(n.mem_pct) || 0;
|
||
|
|
const cpCls = cp >= 80 ? 'text-error' : cp >= 60 ? 'text-warning' : 'text-success';
|
||
|
|
const mpCls = mp >= 80 ? 'text-error' : mp >= 60 ? 'text-warning' : 'text-success';
|
||
|
|
return `<tr class="hover" data-name="${n.node}">
|
||
|
|
<td class="font-semibold text-primary">${n.node}</td>
|
||
|
|
<td class="text-right tabular-nums">${n.cpu}</td>
|
||
|
|
<td class="text-right tabular-nums"><span class="${cpCls}">${n.cpu_pct}</span></td>
|
||
|
|
<td class="text-right tabular-nums">${n.mem}</td>
|
||
|
|
<td class="text-right tabular-nums"><span class="${mpCls}">${n.mem_pct}</span></td>
|
||
|
|
</tr>`;
|
||
|
|
}).join('');
|
||
|
|
document.getElementById('refresh-ts').textContent = new Date().toLocaleTimeString();
|
||
|
|
applySearch();
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadTopPods() {
|
||
|
|
const panel = document.getElementById('pod-top-panel');
|
||
|
|
const tbody = document.getElementById('pod-top-body');
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/ui/workspaces/${WS_NAME}/clusters/top?pods=true`);
|
||
|
|
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="4" class="text-error">failed</td></tr>'; return; }
|
||
|
|
const data = await res.json();
|
||
|
|
tbody.innerHTML = (data.pods || []).map(p =>
|
||
|
|
`<tr class="hover">
|
||
|
|
<td class="text-base-content/60 text-xs">${p.namespace}</td>
|
||
|
|
<td class="font-mono text-xs">${p.pod}</td>
|
||
|
|
<td class="text-right tabular-nums text-xs">${p.cpu}</td>
|
||
|
|
<td class="text-right tabular-nums text-xs">${p.mem}</td>
|
||
|
|
</tr>`
|
||
|
|
).join('') || '<tr><td colspan="4" class="text-base-content/30">no pods</td></tr>';
|
||
|
|
panel.classList.remove('hidden');
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|