Add API endpoints for stats and audits, implement data collection functions, and enhance index view with skeleton context

This commit is contained in:
2026-02-06 17:21:00 +03:00
parent e3a9500352
commit 30c08d497f
6 changed files with 1141 additions and 677 deletions

View File

@@ -1,10 +1,18 @@
"""Tests for dashboard.views."""
import json
from unittest.mock import patch, MagicMock
from django.test import TestCase, RequestFactory
from django.core.cache import cache
from dashboard.views import index, collect_context
from dashboard.views import (
index,
collect_context,
collect_stats,
collect_audits,
api_stats,
api_audits,
)
def _minimal_render_context(region_name="test", first_flavor_name="f1", vm_count=1):
@@ -44,45 +52,18 @@ class IndexViewTest(TestCase):
self.assertIn("m1.small", content)
@patch("dashboard.views.collect_context")
@patch("dashboard.views.get_mock_context")
@patch("dashboard.views.settings")
def test_index_with_real_path_uses_cache_and_collect_context(
self, mock_settings, mock_get_mock_context, mock_collect_context
):
mock_settings.USE_MOCK_DATA = False
mock_settings.DASHBOARD_CACHE_TTL = 120
mock_collect_context.return_value = _minimal_render_context(
region_name="cached-region", first_flavor_name="f1"
)
cache.clear()
request = self.factory.get("/")
with patch.object(cache, "get", return_value=None):
with patch.object(cache, "set") as mock_cache_set:
response = index(request)
self.assertEqual(response.status_code, 200)
mock_collect_context.assert_called_once()
mock_cache_set.assert_called_once()
mock_get_mock_context.assert_not_called()
content = response.content.decode()
self.assertIn("cached-region", content)
self.assertIn("f1", content)
@patch("dashboard.views.collect_context")
@patch("dashboard.views.settings")
def test_index_with_cache_hit_does_not_call_collect_context(
def test_index_without_mock_returns_skeleton_and_does_not_call_collect_context(
self, mock_settings, mock_collect_context
):
mock_settings.USE_MOCK_DATA = False
mock_settings.DASHBOARD_CACHE_TTL = 120
cached = _minimal_render_context(region_name="from-cache", first_flavor_name="cached-flavor", vm_count=2)
cache.clear()
cache.set("dashboard_context", cached, timeout=120)
request = self.factory.get("/")
response = index(request)
self.assertEqual(response.status_code, 200)
mock_collect_context.assert_not_called()
content = response.content.decode()
self.assertIn("from-cache", content)
self.assertIn('data-dashboard="skeleton"', content)
self.assertIn("", content)
class CollectContextTest(TestCase):
@@ -143,3 +124,122 @@ class CollectContextTest(TestCase):
import json
self.assertIsInstance(context["audits"][0]["migrations"], str)
self.assertEqual(json.loads(context["audits"][0]["host_labels"]), ["h0", "h1"])
class ApiStatsTest(TestCase):
"""Tests for api_stats view."""
def setUp(self):
self.factory = RequestFactory()
@patch("dashboard.views._fetch_prometheus_metrics")
@patch("dashboard.views.get_flavor_list")
@patch("dashboard.views.get_connection")
def test_api_stats_returns_json_with_expected_keys(
self, mock_get_connection, mock_get_flavor_list, mock_fetch_metrics
):
conn = MagicMock()
conn._compute_region = "api-region"
mock_get_connection.return_value = conn
mock_get_flavor_list.return_value = {
"first_common_flavor": {"name": "m1.small", "count": 3},
"second_common_flavor": {"name": "", "count": 0},
"third_common_flavor": {"name": "", "count": 0},
}
mock_fetch_metrics.return_value = {
"hosts_total": 2,
"pcpu_total": 4,
"pcpu_usage": 1.0,
"vcpu_allocated": 8,
"vcpu_overcommit_max": 2.0,
"pram_total": 16 * 1024**3,
"pram_usage": 4 * 1024**3,
"vram_allocated": 12 * 1024**3,
"vram_overcommit_max": 1.5,
"vm_count": 2,
"vm_active": 2,
}
cache.clear()
request = self.factory.get("/api/stats/")
with patch("dashboard.views.settings") as mock_settings:
mock_settings.DASHBOARD_CACHE_TTL = 120
response = api_stats(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
data = json.loads(response.content)
self.assertEqual(data["region"]["name"], "api-region")
self.assertEqual(data["region"]["hosts_total"], 2)
self.assertIn("pcpu", data)
self.assertIn("pram", data)
self.assertIn("vcpu", data)
self.assertIn("vram", data)
self.assertIn("vm", data)
self.assertIn("flavors", data)
self.assertEqual(data["flavors"]["first_common_flavor"]["name"], "m1.small")
@patch("dashboard.views.collect_stats")
@patch("dashboard.views.settings")
def test_api_stats_uses_cache(self, mock_settings, mock_collect_stats):
mock_settings.DASHBOARD_CACHE_TTL = 120
cached = {"region": {"name": "cached", "hosts_total": 1}, "pcpu": {}, "pram": {}, "vcpu": {}, "vram": {}, "vm": {}, "flavors": {}}
cache.clear()
cache.set("dashboard_stats", cached, timeout=120)
request = self.factory.get("/api/stats/")
response = api_stats(request)
mock_collect_stats.assert_not_called()
self.assertEqual(json.loads(response.content)["region"]["name"], "cached")
class ApiAuditsTest(TestCase):
"""Tests for api_audits view."""
def setUp(self):
self.factory = RequestFactory()
@patch("dashboard.views.get_audits")
@patch("dashboard.views.get_connection")
def test_api_audits_returns_json_audits_list(
self, mock_get_connection, mock_get_audits
):
mock_get_connection.return_value = MagicMock()
mock_get_audits.return_value = [
{
"id": "audit-1",
"name": "Test Audit",
"created_at": "2025-02-01T10:00:00",
"strategy": "Balanced",
"goal": "BALANCED",
"scope": "Full Cluster",
"cpu_weight": "1.0",
"ram_weight": "1.0",
"migrations": [{"instanceName": "i1", "source": "h0", "destination": "h1", "flavor": "m1.small", "impact": "Low"}],
"host_labels": ["h0", "h1"],
"cpu_current": [30.0, 40.0],
"cpu_projected": [35.0, 35.0],
}
]
cache.clear()
request = self.factory.get("/api/audits/")
with patch("dashboard.views.settings") as mock_settings:
mock_settings.DASHBOARD_CACHE_TTL = 120
response = api_audits(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
data = json.loads(response.content)
self.assertIn("audits", data)
self.assertEqual(len(data["audits"]), 1)
self.assertEqual(data["audits"][0]["name"], "Test Audit")
self.assertIsInstance(data["audits"][0]["migrations"], str)
self.assertIsInstance(data["audits"][0]["host_labels"], str)
@patch("dashboard.views.collect_audits")
@patch("dashboard.views.settings")
def test_api_audits_uses_cache(self, mock_settings, mock_collect_audits):
mock_settings.DASHBOARD_CACHE_TTL = 120
cached = [{"id": "cached-1", "name": "Cached Audit", "migrations": "[]", "host_labels": "[]", "cpu_current": "[]", "cpu_projected": "[]"}]
cache.clear()
cache.set("dashboard_audits", cached, timeout=120)
request = self.factory.get("/api/audits/")
response = api_audits(request)
mock_collect_audits.assert_not_called()
self.assertEqual(json.loads(response.content)["audits"][0]["name"], "Cached Audit")

View File

@@ -3,4 +3,6 @@ from . import views
urlpatterns = [
path('', views.index, name='index'),
path('api/stats/', views.api_stats),
path('api/audits/', views.api_audits),
]

View File

@@ -3,6 +3,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from django.conf import settings
from django.core.cache import cache
from django.http import JsonResponse
from django.shortcuts import render
from dashboard.openstack_utils.connect import get_connection
from dashboard.openstack_utils.flavor import get_flavor_list
@@ -129,15 +130,123 @@ def collect_context():
audit["cpu_projected"] = json.dumps(audit["cpu_projected"])
return context
def collect_stats():
"""Build stats dict: region, pcpu, pram, vcpu, vram, vm, flavors (no audits)."""
connection = get_connection()
region_name = connection._compute_region
flavors = get_flavor_list(connection=connection)
metrics = _fetch_prometheus_metrics()
hosts_total = metrics.get("hosts_total") or 1
pcpu_total = metrics.get("pcpu_total", 0)
pcpu_usage = metrics.get("pcpu_usage", 0)
vcpu_allocated = metrics.get("vcpu_allocated", 0)
vcpu_overcommit_max = metrics.get("vcpu_overcommit_max", 0)
pram_total = metrics.get("pram_total", 0)
pram_usage = metrics.get("pram_usage", 0)
vram_allocated = metrics.get("vram_allocated", 0)
vram_overcommit_max = metrics.get("vram_overcommit_max", 0)
vm_count = metrics.get("vm_count", 0)
vm_active = metrics.get("vm_active", 0)
vcpu_total = pcpu_total * vcpu_overcommit_max
vram_total = pram_total * vram_overcommit_max
return {
"region": {"name": region_name, "hosts_total": hosts_total},
"pcpu": {
"total": pcpu_total,
"usage": pcpu_usage,
"free": pcpu_total - pcpu_usage,
"used_percentage": (pcpu_usage / pcpu_total * 100) if pcpu_total else 0,
},
"vcpu": {
"total": vcpu_total,
"allocated": vcpu_allocated,
"free": vcpu_total - vcpu_allocated,
"allocated_percentage": (vcpu_allocated / vcpu_total * 100) if vcpu_total else 0,
"overcommit_ratio": (vcpu_allocated / pcpu_total) if pcpu_total else 0,
"overcommit_max": vcpu_overcommit_max,
},
"pram": {
"total": pram_total,
"usage": pram_usage,
"free": pram_total - pram_usage,
"used_percentage": (pram_usage / pram_total * 100) if pram_total else 0,
},
"vram": {
"total": vram_total,
"allocated": vram_allocated,
"free": vram_total - vram_allocated,
"allocated_percentage": (vram_allocated / vram_total * 100) if vram_total else 0,
"overcommit_ratio": (vram_allocated / pram_total) if pram_total else 0,
"overcommit_max": vram_overcommit_max,
},
"vm": {
"count": vm_count,
"active": vm_active,
"stopped": vm_count - vm_active,
"avg_cpu": vcpu_allocated / vm_count if vm_count else 0,
"avg_ram": vram_allocated / vm_count if vm_count else 0,
"density": vm_count / hosts_total if hosts_total else 0,
},
"flavors": flavors,
}
def collect_audits():
"""Build audits list with serialized fields for frontend."""
connection = get_connection()
audits = get_audits(connection=connection)
for audit in audits:
audit["migrations"] = json.dumps(audit["migrations"])
audit["host_labels"] = json.dumps(audit["host_labels"])
audit["cpu_current"] = json.dumps(audit["cpu_current"])
audit["cpu_projected"] = json.dumps(audit["cpu_projected"])
return audits
def _skeleton_context():
"""Minimal context for skeleton-only index render."""
empty_flavors = {
"first_common_flavor": {"name": "", "count": 0},
"second_common_flavor": None,
"third_common_flavor": None,
}
return {
"skeleton": True,
"region": {"name": "", "hosts_total": 0},
"pcpu": {"total": 0, "usage": 0, "free": 0, "used_percentage": 0},
"pram": {"total": 0, "usage": 0, "free": 0, "used_percentage": 0},
"vcpu": {"total": 0, "allocated": 0, "free": 0, "allocated_percentage": 0, "overcommit_ratio": 0, "overcommit_max": 0},
"vram": {"total": 0, "allocated": 0, "free": 0, "allocated_percentage": 0, "overcommit_ratio": 0, "overcommit_max": 0},
"vm": {"count": 0, "active": 0, "stopped": 0, "avg_cpu": 0, "avg_ram": 0, "density": 0},
"flavors": empty_flavors,
"audits": [],
}
def index(request):
if getattr(settings, "USE_MOCK_DATA", False):
context = get_mock_context()
return render(request, "index.html", context)
cache_key = "dashboard_context"
cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120)
context = cache.get(cache_key)
if context is None:
context = collect_context()
cache.set(cache_key, context, timeout=cache_ttl)
context = _skeleton_context()
return render(request, "index.html", context)
def api_stats(request):
cache_key = "dashboard_stats"
cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120)
data = cache.get(cache_key)
if data is None:
data = collect_stats()
cache.set(cache_key, data, timeout=cache_ttl)
return JsonResponse(data)
def api_audits(request):
cache_key = "dashboard_audits"
cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120)
audits = cache.get(cache_key)
if audits is None:
audits = collect_audits()
cache.set(cache_key, audits, timeout=cache_ttl)
return JsonResponse({"audits": audits})

View File

@@ -1,3 +1,13 @@
// Format bytes to GB (matches Django convert_bytes filter default)
function formatBytes(bytes, targetUnit = 'GB') {
if (bytes == null || isNaN(Number(bytes))) return '0';
const b = Number(bytes);
const factors = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 ** 3, TB: 1024 ** 4 };
const unit = (targetUnit || 'GB').toUpperCase();
const factor = factors[unit] || factors.GB;
return (b / factor).toFixed(1);
}
// Color utilities
const getCSSVar = (varName) => {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();

View File

@@ -21,7 +21,7 @@
</div>
<div class="navbar-end">
<div class="px-1 flex gap-3 pr-10">
<span class="badge badge-primary badge-lg">{{ region.name }}</span>
<span id="regionBadge" class="badge badge-primary badge-lg">{{ region.name }}</span>
<label class="swap swap-rotate">
<input type="checkbox" class="theme-controller" value="dark" />
<svg class="swap-off fill-current w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">

View File

@@ -10,12 +10,32 @@
{% block content %}
<!-- MAIN DASHBOARD -->
<div class="p-4 space-y-4">
<div class="p-4 space-y-4" {% if skeleton %}data-dashboard="skeleton"{% endif %}>
<!-- QUICK STATS ROW -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- CPU Utilization -->
<div class="card bg-base-100 shadow-sm hover:shadow transition-shadow">
<div class="card bg-base-100 shadow-sm hover:shadow transition-shadow" id="statsPcpuCard">
<div class="card-body p-4">
{% if skeleton %}
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-medium text-base-content/70">CPU Utilization</h3>
<div class="text-xs text-base-content/40 mt-0.5 animate-pulse"><span data-stats="pcpu.usage"></span> / <span data-stats="pcpu.total"></span> CPU</div>
</div>
<div class="text-lg font-bold text-primary animate-pulse" data-stats="pcpu.used_percentage">—%</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs">
<span class="text-base-content/60">Used</span>
<span class="font-medium animate-pulse" data-stats="pcpu.usage_val"></span>
</div>
<progress class="progress progress-primary w-full animate-pulse" data-stats="pcpu.progress" value="0" max="100"></progress>
<div class="flex justify-between text-xs">
<span class="text-base-content/60">Free</span>
<span class="font-medium animate-pulse" data-stats="pcpu.free"></span>
</div>
</div>
{% else %}
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-medium text-base-content/70">CPU Utilization</h3>
@@ -34,12 +54,33 @@
<span class="font-medium">{{ pcpu.free }} CPU</span>
</div>
</div>
{% endif %}
</div>
</div>
<!-- RAM Utilization -->
<div class="card bg-base-100 shadow-sm hover:shadow transition-shadow">
<div class="card bg-base-100 shadow-sm hover:shadow transition-shadow" id="statsPramCard">
<div class="card-body p-4">
{% if skeleton %}
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-medium text-base-content/70">RAM Utilization</h3>
<div class="text-xs text-base-content/40 mt-0.5 animate-pulse"><span data-stats="pram.usage_gb"></span> / <span data-stats="pram.total_gb"></span> GB</div>
</div>
<div class="text-lg font-bold text-secondary animate-pulse" data-stats="pram.used_percentage">—%</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs">
<span class="text-base-content/60">Used</span>
<span class="font-medium animate-pulse" data-stats="pram.usage_gb_val"></span>
</div>
<progress class="progress progress-secondary w-full animate-pulse" data-stats="pram.progress" value="0" max="100"></progress>
<div class="flex justify-between text-xs">
<span class="text-base-content/60">Free</span>
<span class="font-medium animate-pulse" data-stats="pram.free_gb"></span>
</div>
</div>
{% else %}
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-medium text-base-content/70">RAM Utilization</h3>
@@ -58,12 +99,45 @@
<span class="font-medium">{{ pram.free|convert_bytes }} GB</span>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Instance Summary -->
<div class="card bg-base-100 shadow-sm hover:shadow transition-shadow">
<div class="card bg-base-100 shadow-sm hover:shadow transition-shadow" id="statsVmCard">
<div class="card-body p-4">
{% if skeleton %}
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-medium text-base-content/70">Instances</h3>
<div class="text-xs text-base-content/40 mt-0.5 animate-pulse"><span data-stats="vm.active"></span> active / <span data-stats="vm.stopped"></span> stopped</div>
</div>
<div class="text-lg font-bold text-accent animate-pulse" data-stats="vm.count"></div>
</div>
<div class="space-y-3">
<div class="flex justify-between items-center text-xs">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-success"></div>
<span>Most Used Flavor</span>
</div>
<span class="font-medium animate-pulse" data-stats="flavors.first_name"></span>
</div>
<div class="flex justify-between items-center text-xs">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-info"></div>
<span>Avg. vCPU/VM</span>
</div>
<span class="font-medium animate-pulse" data-stats="vm.avg_cpu"></span>
</div>
<div class="flex justify-between items-center text-xs">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-warning"></div>
<span>Density</span>
</div>
<span class="font-medium animate-pulse" data-stats="vm.density"></span>
</div>
</div>
{% else %}
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="text-sm font-medium text-base-content/70">Instances</h3>
@@ -94,6 +168,7 @@
<span class="font-medium">{{ vm.density|floatformat:1 }}/host</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -101,7 +176,7 @@
<!-- DETAILED OVERVIEW -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Resource Allocation -->
<div class="card bg-base-100 shadow-sm">
<div class="card bg-base-100 shadow-sm" id="statsAllocationCard">
<div class="card-body p-4">
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -109,7 +184,30 @@
</svg>
Resource Allocation
</h3>
{% if skeleton %}
<div class="mb-4">
<div class="flex justify-between text-xs mb-1">
<span class="text-base-content/70">CPU Allocation</span>
<span class="font-medium animate-pulse" data-stats="vcpu.allocated_total">— / — vCPU</span>
</div>
<div class="flex items-center gap-2">
<progress class="progress progress-primary flex-1 animate-pulse" data-stats="vcpu.progress" value="0" max="100"></progress>
<span class="text-xs font-medium w-12 text-right animate-pulse" data-stats="vcpu.allocated_percentage">—%</span>
</div>
<div class="flex justify-between text-xs mt-1 animate-pulse" data-stats="vcpu.overcommit"></div>
</div>
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-base-content/70">RAM Allocation</span>
<span class="font-medium animate-pulse" data-stats="vram.allocated_total">— / — GB</span>
</div>
<div class="flex items-center gap-2">
<progress class="progress progress-secondary flex-1 animate-pulse" data-stats="vram.progress" value="0" max="100"></progress>
<span class="text-xs font-medium w-12 text-right animate-pulse" data-stats="vram.allocated_percentage">—%</span>
</div>
<div class="flex justify-between text-xs mt-1 animate-pulse" data-stats="vram.overcommit"></div>
</div>
{% else %}
<!-- CPU Allocation -->
<div class="mb-4">
<div class="flex justify-between text-xs mb-1">
@@ -141,11 +239,12 @@
<span class="text-base-content/50">{{ vram.allocated_percentage|floatformat:1 }}% allocated</span>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Flavor Distribution -->
<div class="card bg-base-100 shadow-sm">
<div class="card bg-base-100 shadow-sm" id="statsFlavorsCard">
<div class="card-body p-4">
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -154,7 +253,24 @@
</svg>
Top Flavors
</h3>
{% if skeleton %}
<div class="space-y-3">
<div class="bg-base-200/50 rounded-lg p-3">
<div class="flex justify-between items-center mb-1">
<span class="text-sm font-medium animate-pulse" data-stats="flavors.first_name"></span>
<span class="text-xs badge badge-primary animate-pulse" data-stats="flavors.first_count">— instances</span>
</div>
<div class="flex justify-between text-xs">
<span class="text-base-content/60">Share</span>
<span class="font-medium animate-pulse" data-stats="flavors.first_share">—%</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between items-center text-sm animate-pulse h-5 bg-base-200/50 rounded w-full max-w-[80%]"></div>
<div class="flex justify-between items-center text-sm animate-pulse h-5 bg-base-200/50 rounded w-full max-w-[60%]"></div>
</div>
</div>
{% else %}
<div class="space-y-3">
<!-- Most Common -->
<div class="bg-base-200/50 rounded-lg p-3">
@@ -191,12 +307,13 @@
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- AUDIT CONTROL -->
<div class="card bg-base-100 shadow-sm">
<div class="card bg-base-100 shadow-sm" id="auditSection">
<div class="card-body p-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<div>
@@ -204,7 +321,7 @@
<div class="text-xs text-base-content/50 mt-0.5">Select an audit to analyze resource distribution</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-base-content/50">{{ audits|length }} available</span>
<span class="text-xs text-base-content/50" id="auditsCount">{% if skeleton %}Loading…{% else %}{{ audits|length }} available{% endif %}</span>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-xs btn-ghost">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -239,7 +356,10 @@
</div>
<div class="flex flex-col md:flex-row gap-3">
<select id="auditSelector" class="select select-bordered select-sm flex-1">
<select id="auditSelector" class="select select-bordered select-sm flex-1" {% if skeleton %}disabled{% endif %}>
{% if skeleton %}
<option value="">Loading…</option>
{% else %}
{% for audit in audits %}
<option value="{{ audit.id }}"
data-cpu="{{ audit.cpu_weight }}"
@@ -250,6 +370,7 @@
{{ audit.name }} ({{ audit.created_at|date:"M d" }})
</option>
{% endfor %}
{% endif %}
</select>
<button onclick="loadSelectedAudit()" class="btn btn-primary btn-sm">
Load Analysis
@@ -335,17 +456,10 @@
{% block script %}
<script>
// Update audit preview
document.getElementById('auditSelector').addEventListener('change', function(e) {
const option = this.options[this.selectedIndex];
document.getElementById('previewCpu').textContent = option.dataset.cpu || '1.0';
document.getElementById('previewRam').textContent = option.dataset.ram || '1.0';
document.getElementById('previewScope').textContent = option.dataset.scope || 'Full Cluster';
document.getElementById('previewStrategy').textContent = option.dataset.strategy || 'Balanced';
});
const SKELETON_MODE = {{ skeleton|yesno:"true,false" }};
// Audit data
const auditData = {
let auditData = {
{% if not skeleton %}
{% for audit in audits %}
"{{ audit.id }}": {
name: "{{ audit.name }}",
@@ -357,12 +471,126 @@
}
}{% if not forloop.last %},{% endif %}
{% endfor %}
{% endif %}
};
// Chart instances
document.getElementById('auditSelector').addEventListener('change', function(e) {
const option = this.options[this.selectedIndex];
if (!option) return;
document.getElementById('previewCpu').textContent = option.dataset.cpu || '1.0';
document.getElementById('previewRam').textContent = option.dataset.ram || '1.0';
document.getElementById('previewScope').textContent = option.dataset.scope || 'Full Cluster';
document.getElementById('previewStrategy').textContent = option.dataset.strategy || 'Balanced';
});
let cpuHostChart = null;
let cpuProjectedChart = null;
function setStat(key, text) {
var el = document.querySelector('[data-stats="' + key + '"]');
if (el) { el.textContent = text; el.classList.remove('animate-pulse'); }
}
function setProgress(key, value) {
var el = document.querySelector('[data-stats="' + key + '"]');
if (el && el.tagName === 'PROGRESS') { el.value = value; el.classList.remove('animate-pulse'); }
}
function renderStats(data) {
if (!data) return;
var el = function(k) { return document.querySelector('[data-stats="' + k + '"]'); };
var regionBadge = document.getElementById('regionBadge');
if (regionBadge) regionBadge.textContent = data.region && data.region.name ? data.region.name : '—';
setStat('pcpu.usage', Number((data.pcpu && data.pcpu.usage) || 0).toFixed(1));
setStat('pcpu.total', String((data.pcpu && data.pcpu.total) || 0));
setStat('pcpu.used_percentage', Number((data.pcpu && data.pcpu.used_percentage) || 0).toFixed(1) + '%');
setStat('pcpu.usage_val', Number((data.pcpu && data.pcpu.usage) || 0).toFixed(1) + ' CPU');
setProgress('pcpu.progress', (data.pcpu && data.pcpu.used_percentage) || 0);
setStat('pcpu.free', String((data.pcpu && data.pcpu.free) || 0));
var pramUsageGb = formatBytes(data.pram && data.pram.usage, 'GB');
var pramTotalGb = formatBytes(data.pram && data.pram.total, 'GB');
var pramFreeGb = formatBytes(data.pram && data.pram.free, 'GB');
setStat('pram.usage_gb', pramUsageGb);
setStat('pram.total_gb', pramTotalGb);
setStat('pram.used_percentage', Number((data.pram && data.pram.used_percentage) || 0).toFixed(1) + '%');
setStat('pram.usage_gb_val', pramUsageGb + ' GB');
setProgress('pram.progress', (data.pram && data.pram.used_percentage) || 0);
setStat('pram.free_gb', pramFreeGb + ' GB');
setStat('vm.active', String(data.vm && data.vm.active));
setStat('vm.stopped', String(data.vm && data.vm.stopped));
setStat('vm.count', String(data.vm && data.vm.count));
setStat('flavors.first_name', data.flavors && data.flavors.first_common_flavor ? data.flavors.first_common_flavor.name : '—');
setStat('vm.avg_cpu', Number((data.vm && data.vm.avg_cpu) || 0).toFixed(1));
setStat('vm.density', Number((data.vm && data.vm.density) || 0).toFixed(1) + '/host');
setStat('vcpu.allocated_total', ((data.vcpu && data.vcpu.allocated) || 0) + ' / ' + ((data.vcpu && data.vcpu.total) || 0) + ' vCPU');
setProgress('vcpu.progress', (data.vcpu && data.vcpu.allocated_percentage) || 0);
setStat('vcpu.allocated_percentage', Number((data.vcpu && data.vcpu.allocated_percentage) || 0).toFixed(1) + '%');
var vcpuOver = el('vcpu.overcommit');
if (vcpuOver) {
vcpuOver.textContent = 'overcommit: ' + Number((data.vcpu && data.vcpu.overcommit_ratio) || 0).toFixed(1) + ' / ' + Number((data.vcpu && data.vcpu.overcommit_max) || 0).toFixed(1) + ' — ' + Number((data.vcpu && data.vcpu.allocated_percentage) || 0).toFixed(1) + '% allocated';
vcpuOver.classList.remove('animate-pulse');
}
var vramAllocGb = formatBytes(data.vram && data.vram.allocated, 'GB');
var vramTotalGb = formatBytes(data.vram && data.vram.total, 'GB');
setStat('vram.allocated_total', vramAllocGb + ' / ' + vramTotalGb + ' GB');
setProgress('vram.progress', (data.vram && data.vram.allocated_percentage) || 0);
setStat('vram.allocated_percentage', Number((data.vram && data.vram.allocated_percentage) || 0).toFixed(1) + '%');
var vramOver = el('vram.overcommit');
if (vramOver) {
vramOver.textContent = 'overcommit: ' + Number((data.vram && data.vram.overcommit_ratio) || 0).toFixed(1) + ' / ' + Number((data.vram && data.vram.overcommit_max) || 0).toFixed(1) + ' — ' + Number((data.vram && data.vram.allocated_percentage) || 0).toFixed(1) + '% allocated';
vramOver.classList.remove('animate-pulse');
}
setStat('flavors.first_count', (data.flavors && data.flavors.first_common_flavor ? data.flavors.first_common_flavor.count : 0) + ' instances');
var vmCount = data.vm && data.vm.count ? data.vm.count : 0;
var firstCount = data.flavors && data.flavors.first_common_flavor ? data.flavors.first_common_flavor.count : 0;
setStat('flavors.first_share', (vmCount ? Math.round(firstCount / vmCount * 100) : 0) + '%');
document.querySelectorAll('[data-stats]').forEach(function(n) { n.classList.remove('animate-pulse'); });
}
function renderAudits(auditsList) {
if (!auditsList || !auditsList.length) {
var countEl = document.getElementById('auditsCount');
if (countEl) countEl.textContent = '0 available';
var sel = document.getElementById('auditSelector');
if (sel) { sel.disabled = false; sel.innerHTML = '<option value="">No audits</option>'; }
return;
}
auditData = {};
auditsList.forEach(function(a) {
auditData[a.id] = {
name: a.name,
migrations: typeof a.migrations === 'string' ? JSON.parse(a.migrations) : a.migrations,
hostData: {
labels: typeof a.host_labels === 'string' ? JSON.parse(a.host_labels) : a.host_labels,
current: typeof a.cpu_current === 'string' ? JSON.parse(a.cpu_current) : a.cpu_current,
projected: typeof a.cpu_projected === 'string' ? JSON.parse(a.cpu_projected) : a.cpu_projected
}
};
});
var sel = document.getElementById('auditSelector');
if (sel) {
sel.disabled = false;
sel.innerHTML = '';
auditsList.forEach(function(audit) {
var opt = document.createElement('option');
opt.value = audit.id;
opt.setAttribute('data-cpu', audit.cpu_weight || '1.0');
opt.setAttribute('data-ram', audit.ram_weight || '1.0');
opt.setAttribute('data-scope', audit.scope || 'Full Cluster');
opt.setAttribute('data-strategy', audit.strategy || 'Balanced');
opt.setAttribute('data-goal', audit.goal || '');
var dateStr = audit.created_at ? new Date(audit.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
opt.textContent = audit.name + ' (' + dateStr + ')';
sel.appendChild(opt);
});
}
var countEl = document.getElementById('auditsCount');
if (countEl) countEl.textContent = auditsList.length + ' available';
if (auditsList.length > 0) {
document.getElementById('auditSelector').dispatchEvent(new Event('change'));
loadSelectedAudit();
}
}
// Load selected audit
function loadSelectedAudit() {
const auditId = document.getElementById('auditSelector').value;
@@ -614,12 +842,27 @@
return { mean, std };
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
const initialAudit = "{% if audits %}{{ audits.0.id }}{% endif %}";
if (initialAudit && auditData[initialAudit]) {
document.getElementById('auditSelector').dispatchEvent(new Event('change'));
loadSelectedAudit();
document.addEventListener('DOMContentLoaded', function() {
if (SKELETON_MODE) {
Promise.all([
fetch('/api/stats/').then(function(r) { return r.ok ? r.json() : Promise.reject(r); }),
fetch('/api/audits/').then(function(r) { return r.ok ? r.json() : Promise.reject(r); })
]).then(function(results) {
renderStats(results[0]);
renderAudits(results[1].audits);
}).catch(function(err) {
var msg = err.status ? 'Failed to load data (' + err.status + ')' : 'Failed to load data';
var countEl = document.getElementById('auditsCount');
if (countEl) countEl.textContent = msg;
fetch('/api/stats/').then(function(r) { return r.ok ? r.json() : null; }).then(function(d) { if (d) renderStats(d); });
fetch('/api/audits/').then(function(r) { return r.ok ? r.json() : null; }).then(function(d) { if (d && d.audits) renderAudits(d.audits); });
});
} else {
var initialAudit = "{% if audits %}{{ audits.0.id }}{% endif %}";
if (initialAudit && auditData[initialAudit]) {
document.getElementById('auditSelector').dispatchEvent(new Event('change'));
loadSelectedAudit();
}
}
});
</script>