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.""" """Tests for dashboard.views."""
import json
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from django.core.cache import cache 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): 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) self.assertIn("m1.small", content)
@patch("dashboard.views.collect_context") @patch("dashboard.views.collect_context")
@patch("dashboard.views.get_mock_context")
@patch("dashboard.views.settings") @patch("dashboard.views.settings")
def test_index_with_real_path_uses_cache_and_collect_context( def test_index_without_mock_returns_skeleton_and_does_not_call_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(
self, mock_settings, mock_collect_context self, mock_settings, mock_collect_context
): ):
mock_settings.USE_MOCK_DATA = False 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("/") request = self.factory.get("/")
response = index(request) response = index(request)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
mock_collect_context.assert_not_called() mock_collect_context.assert_not_called()
content = response.content.decode() content = response.content.decode()
self.assertIn("from-cache", content) self.assertIn('data-dashboard="skeleton"', content)
self.assertIn("", content)
class CollectContextTest(TestCase): class CollectContextTest(TestCase):
@@ -143,3 +124,122 @@ class CollectContextTest(TestCase):
import json import json
self.assertIsInstance(context["audits"][0]["migrations"], str) self.assertIsInstance(context["audits"][0]["migrations"], str)
self.assertEqual(json.loads(context["audits"][0]["host_labels"]), ["h0", "h1"]) 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 = [ urlpatterns = [
path('', views.index, name='index'), 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.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from dashboard.openstack_utils.connect import get_connection from dashboard.openstack_utils.connect import get_connection
from dashboard.openstack_utils.flavor import get_flavor_list from dashboard.openstack_utils.flavor import get_flavor_list
@@ -129,15 +130,123 @@ def collect_context():
audit["cpu_projected"] = json.dumps(audit["cpu_projected"]) audit["cpu_projected"] = json.dumps(audit["cpu_projected"])
return context 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): def index(request):
if getattr(settings, "USE_MOCK_DATA", False): if getattr(settings, "USE_MOCK_DATA", False):
context = get_mock_context() context = get_mock_context()
return render(request, "index.html", context) return render(request, "index.html", context)
context = _skeleton_context()
return render(request, "index.html", context)
cache_key = "dashboard_context"
def api_stats(request):
cache_key = "dashboard_stats"
cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120) cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120)
context = cache.get(cache_key) data = cache.get(cache_key)
if context is None: if data is None:
context = collect_context() data = collect_stats()
cache.set(cache_key, context, timeout=cache_ttl) cache.set(cache_key, data, timeout=cache_ttl)
return render(request, "index.html", context) 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 // Color utilities
const getCSSVar = (varName) => { const getCSSVar = (varName) => {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();

View File

@@ -21,7 +21,7 @@
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<div class="px-1 flex gap-3 pr-10"> <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"> <label class="swap swap-rotate">
<input type="checkbox" class="theme-controller" value="dark" /> <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"> <svg class="swap-off fill-current w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">

File diff suppressed because it is too large Load Diff