Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/ops-topology.css?v=1" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ops-shell" x-data="opsTopology()" x-init="init()">
|
||||
<header class="ops-header">
|
||||
<div>
|
||||
<h1>IT Ops Infrastructure</h1>
|
||||
<p class="ops-subtitle">Realtime Proxmox + service health topology</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>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<p class="ops-meta">Last update: <span x-text="lastUpdate || '—'"></span></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function opsTopology() {
|
||||
return {
|
||||
loading: false,
|
||||
summary: { counts: {} },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastUpdate: 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 }
|
||||
},
|
||||
init() {
|
||||
this.loadStatus();
|
||||
this.loadTopology();
|
||||
this.timer = setInterval(() => this.loadTopology(), 30000);
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
async loadTopology() {
|
||||
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;
|
||||
}
|
||||
},
|
||||
async refreshInfra() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await fetch('/api/ops/refresh', { method: 'POST' });
|
||||
await this.loadTopology();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
} 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>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user