SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
+190
-89
@@ -1,131 +1,232 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=3" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ops-shell" x-data="opsTopology()" x-init="init()">
|
||||
<header class="ops-header">
|
||||
<div class="ops-shell hm-neo" x-data="opsPage()" x-init="init()">
|
||||
<header class="ops-header herman-hero">
|
||||
<div>
|
||||
<h1>IT Ops Infrastructure</h1>
|
||||
<p class="ops-subtitle">Realtime Proxmox + service health topology</p>
|
||||
<h1>IT Ops · Dell R340 <span class="live-badge">Live</span></h1>
|
||||
<p class="hero-sub">PowerEdge R340 → Proxmox VE → VM 106 → online services · SysOps agent</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadTopology()" :disabled="loading">↻ Topology</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="refreshInfra()" :disabled="loading">Snapshot</button>
|
||||
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">↻ Verversen</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="snapshot()" :disabled="loading">Snapshot</button>
|
||||
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-purple">Goedkeuringen →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="ops-kpis">
|
||||
<div class="ops-kpi"><div class="label">Health</div><div class="value" x-text="summary.health || 'unknown'"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Nodes</div><div class="value" x-text="summary.counts?.total ?? 0"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Online</div><div class="value" x-text="summary.counts?.online ?? 0"></div></div>
|
||||
<div class="ops-kpi"><div class="label">Offline</div><div class="value" x-text="summary.counts?.offline ?? 0"></div></div>
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
<div class="hm-dash-two-col" style="margin-bottom:1rem">
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Maintenance severity</h4><small>open items</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-maint"></canvas></div>
|
||||
<div id="viz-ops-maint-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
|
||||
<div class="hm-chart-head"><h4>Backup status</h4><small>Gitea commits</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-ops-backup"></canvas></div>
|
||||
<div id="viz-ops-backup-alt" class="viz-alt"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="ops-kpis hm-neo-kpi-row" style="grid-template-columns:repeat(5,1fr)">
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Health</div><div class="hm-neo-kpi-value" x-text="summary.health || '—'"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Online nodes</div><div class="hm-neo-kpi-value" x-text="summary.counts?.online ?? 0"></div><div class="hm-eq hm-eq-lime"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Services</div><div class="hm-neo-kpi-value" x-text="meta.services_online ?? '—'"></div><div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Open maintenance</div><div class="hm-neo-kpi-value" x-text="maintenance.length"></div><div class="hm-eq hm-eq-purple"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
<div class="hm-neo-kpi"><div class="hm-neo-kpi-label">Laatste backup</div><div class="hm-neo-kpi-value" style="font-size:0.95rem" x-text="lastBackupLabel()"></div><div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
|
||||
</section>
|
||||
|
||||
<div class="ops-panels-grid">
|
||||
<section class="panel ops-side-panel">
|
||||
<h2>Maintenance · SysOps</h2>
|
||||
<p class="page-tip">Dagelijks scan — updates, schijf, Docker health</p>
|
||||
<template x-if="!maintenance.length">
|
||||
<p class="empty-state">Geen open items 🎉</p>
|
||||
</template>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="n in maintenance" :key="n.id">
|
||||
<div class="ops-maint-item" :class="'sev-'+(n.severity||'info')">
|
||||
<strong x-text="n.title"></strong>
|
||||
<small x-text="(n.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
<p x-text="n.body" x-show="n.body"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel ops-side-panel">
|
||||
<h2>Config backups · Gitea</h2>
|
||||
<p class="page-tip">SysOps vraagt dagelijks goedkeuring — na ✓ push naar Gitea</p>
|
||||
<template x-if="pendingBackup">
|
||||
<div class="ops-maint-item sev-warning">
|
||||
<strong>⏳ Wacht op goedkeuring</strong>
|
||||
<p x-text="pendingBackup.title"></p>
|
||||
<a href="/agents?tab=approvals" class="btn btn-sm btn-pulse-green">Keur goed →</a>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="b in backups.slice(0,5)" :key="b.id">
|
||||
<div class="ops-maint-item" :class="b.status==='success'?'sev-info':'sev-warning'">
|
||||
<strong x-text="b.status + (b.commit_ref ? ' · '+b.commit_ref : '')"></strong>
|
||||
<small x-text="(b.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
<p x-text="b.message"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener" class="btn btn-sm" style="margin-top:0.5rem">Gitea repo →</a>
|
||||
</section>
|
||||
|
||||
<section class="panel ops-side-panel" style="grid-column:1/-1">
|
||||
<h2>SysOps verslag · activiteitenlog</h2>
|
||||
<p class="page-tip">Elke deploy, Gitea-push en maintenance-scan — Herman leest dit in de dagelijkse briefing.</p>
|
||||
<div class="ops-maint-list">
|
||||
<template x-for="a in sysopsActivity" :key="a.id">
|
||||
<div class="ops-maint-item" :class="a.status==='success'?'sev-info':'sev-warning'">
|
||||
<strong x-text="a.title"></strong>
|
||||
<small x-text="(a.action_type||'')+' · '+(a.created_at||'').substring(0,16).replace('T',' ')"></small>
|
||||
<p x-text="a.body" x-show="a.body"></p>
|
||||
<small x-show="a.commit_ref" style="color:#38bdf8" x-text="'commit '+a.commit_ref"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="!sysopsActivity.length" class="empty-state">Nog geen SysOps-activiteit gelogd.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel topology-card">
|
||||
<svg viewBox="0 0 1200 600" class="topology-canvas">
|
||||
<g class="topology-lines">
|
||||
<template x-for="edge in edges" :key="edge.id">
|
||||
<line :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
|
||||
</template>
|
||||
</g>
|
||||
<g>
|
||||
<template x-for="edge in edges" :key="'pulse-'+edge.id">
|
||||
<line class="pulse-line" :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"></line>
|
||||
</template>
|
||||
</g>
|
||||
<template x-for="node in nodes" :key="node.id">
|
||||
<g class="ops-node" :class="node.css" :transform="'translate('+node.x+','+node.y+')'">
|
||||
<circle r="36"></circle>
|
||||
<text text-anchor="middle" y="-2" x-text="node.label"></text>
|
||||
<text class="sub" text-anchor="middle" y="16" x-text="node.status"></text>
|
||||
</g>
|
||||
</template>
|
||||
</svg>
|
||||
<div class="ops-legend">
|
||||
<span><i class="dot dot-online"></i> Online / bereikbaar</span>
|
||||
<span><i class="dot dot-degraded"></i> Onbekend</span>
|
||||
<span><i class="dot dot-offline"></i> Offline</span>
|
||||
<span>Klik service-node → opent URL</span>
|
||||
</div>
|
||||
<svg id="ops-topology-canvas" viewBox="0 0 1200 620" class="topology-canvas"></svg>
|
||||
</section>
|
||||
|
||||
<p class="ops-meta">Last update: <span x-text="lastUpdate || '—'"></span></p>
|
||||
<p class="ops-meta">Last update: <span id="ops-meta-time">—</span></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard-layout.js?v=2"></script>
|
||||
<script src="/static/js/page-viz.js?v=1"></script>
|
||||
<script src="/static/js/ops-topology.js?v=3"></script>
|
||||
<script>
|
||||
function opsTopology() {
|
||||
function opsPage() {
|
||||
return {
|
||||
loading: false,
|
||||
summary: { counts: {} },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastUpdate: null,
|
||||
meta: {},
|
||||
maintenance: [],
|
||||
backups: [],
|
||||
sysopsActivity: [],
|
||||
pendingBackup: null,
|
||||
timer: null,
|
||||
positions: {
|
||||
'proxmox-host': { x: 600, y: 90 },
|
||||
'vm105-hermes': { x: 340, y: 230 },
|
||||
'vm106-command': { x: 860, y: 230 },
|
||||
'svc-gitea': { x: 190, y: 390 },
|
||||
'svc-ollama': { x: 480, y: 390 },
|
||||
'svc-cockpit': { x: 700, y: 390 },
|
||||
'svc-tools-api': { x: 860, y: 390 },
|
||||
'svc-email-agent': { x: 1020, y: 390 }
|
||||
renderViz(mode) {
|
||||
if (!window.VizEngine) return;
|
||||
const sevMap = {};
|
||||
(this.maintenance || []).forEach(n => {
|
||||
const s = n.severity || 'info';
|
||||
sevMap[s] = (sevMap[s] || 0) + 1;
|
||||
});
|
||||
let sevRows = Object.keys(sevMap).map(k => ({ label: k, value: sevMap[k], raw: sevMap[k] }));
|
||||
if (!sevRows.length) sevRows = [{ label: 'ok', value: 0, raw: 0 }];
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-ops-maint'),
|
||||
document.getElementById('viz-ops-maint-alt'),
|
||||
'ops-maint', 'Maintenance', sevRows, 'hm-eq-purple'
|
||||
);
|
||||
const stMap = {};
|
||||
(this.backups || []).forEach(b => {
|
||||
const s = b.status || 'unknown';
|
||||
stMap[s] = (stMap[s] || 0) + 1;
|
||||
});
|
||||
let stRows = Object.keys(stMap).map(k => ({ label: k, value: stMap[k], raw: stMap[k] }));
|
||||
if (!stRows.length) stRows = [{ label: 'geen', value: 0, raw: 0 }];
|
||||
VizEngine.renderFromRows(mode,
|
||||
document.getElementById('chart-ops-backup'),
|
||||
document.getElementById('viz-ops-backup-alt'),
|
||||
'ops-backup', 'Backups', stRows, 'hm-eq-cyan'
|
||||
);
|
||||
},
|
||||
init() {
|
||||
this.loadStatus();
|
||||
this.loadTopology();
|
||||
this.timer = setInterval(() => this.loadTopology(), 30000);
|
||||
async init() {
|
||||
await this.loadAll();
|
||||
await this.draw();
|
||||
if (window.PageViz) {
|
||||
window.PageViz.init('ops', (mode) => this.renderViz(mode));
|
||||
}
|
||||
this.timer = setInterval(() => this.refresh(false), 30000);
|
||||
},
|
||||
lastBackupLabel() {
|
||||
const ok = this.backups.find(b => b.status === 'success');
|
||||
if (!ok) return '—';
|
||||
return (ok.commit_ref || ok.status) + ' · ' + (ok.created_at || '').substring(0, 10);
|
||||
},
|
||||
async loadAll() {
|
||||
await Promise.all([this.loadStatus(), this.loadMaintenance(), this.loadBackups(), this.loadSysopsActivity(), this.loadPendingApproval()]);
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
},
|
||||
async loadStatus() {
|
||||
try {
|
||||
const data = await fetch('/api/ops/status').then(r => r.json());
|
||||
this.summary = data || { counts: {} };
|
||||
this.lastUpdate = data.generated_at || new Date().toISOString();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
this.meta = (data.topology && data.topology.meta) || data.sources || {};
|
||||
} catch (e) { console.warn(e); }
|
||||
},
|
||||
async loadMaintenance() {
|
||||
try {
|
||||
const r = await fetch('/api/ops/maintenance?limit=12').then(x => x.json());
|
||||
this.maintenance = (r.items || []).filter(n => !n.resolved);
|
||||
} catch (e) { this.maintenance = []; }
|
||||
},
|
||||
async loadBackups() {
|
||||
try {
|
||||
const r = await fetch('/api/ops/backups?limit=8').then(x => x.json());
|
||||
this.backups = r.items || [];
|
||||
} catch (e) { this.backups = []; }
|
||||
},
|
||||
async loadSysopsActivity() {
|
||||
try {
|
||||
const r = await fetch('/api/ops/sysops-activity?limit=20').then(x => x.json());
|
||||
this.sysopsActivity = r.items || [];
|
||||
} catch (e) { this.sysopsActivity = []; }
|
||||
},
|
||||
async loadPendingApproval() {
|
||||
try {
|
||||
const r = await fetch('/api/agents/approvals?status=pending&limit=20').then(x => x.json());
|
||||
this.pendingBackup = (r.items || []).find(i => i.agent_key === 'sysops' && i.action_type === 'config_backup') || null;
|
||||
} catch (e) { this.pendingBackup = null; }
|
||||
},
|
||||
async draw() {
|
||||
if (window.OpsTopology) {
|
||||
await window.OpsTopology.render('ops-topology-canvas', document.getElementById('ops-meta-time'));
|
||||
}
|
||||
},
|
||||
async loadTopology() {
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await fetch('/api/ops/topology').then(r => r.json());
|
||||
this.toGraph(data);
|
||||
await this.loadStatus();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
await this.loadAll();
|
||||
await this.draw();
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast('IT Ops bijgewerkt', 'success');
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
async refreshInfra() {
|
||||
async snapshot() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await fetch('/api/ops/refresh', { method: 'POST' });
|
||||
await this.loadTopology();
|
||||
await this.refresh(false);
|
||||
if (window.Cockpit) Cockpit.toast('Infra snapshot opgeslagen', 'success');
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot mislukt', 'error');
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
toGraph(payload) {
|
||||
const outNodes = [];
|
||||
const outEdges = [];
|
||||
const flatten = (n, parentId = null) => {
|
||||
const pos = this.positions[n.id] || { x: 80 + outNodes.length * 120, y: 520 };
|
||||
const status = (n.status || 'unknown').toLowerCase();
|
||||
const kind = (n.type || 'service').toLowerCase();
|
||||
const css = `node-${kind} status-${status}`;
|
||||
outNodes.push({ id: n.id, label: n.label, status: n.status || 'unknown', x: pos.x, y: pos.y, css });
|
||||
if (parentId) {
|
||||
const p = this.positions[parentId];
|
||||
if (p) {
|
||||
outEdges.push({ id: `${parentId}-${n.id}`, x1: p.x, y1: p.y + 36, x2: pos.x, y2: pos.y - 36 });
|
||||
}
|
||||
}
|
||||
(n.children || []).forEach(child => flatten(child, n.id));
|
||||
};
|
||||
(payload.nodes || []).forEach(root => flatten(root, null));
|
||||
this.nodes = outNodes;
|
||||
this.edges = outEdges;
|
||||
this.lastUpdate = payload.generated_at || new Date().toISOString();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user