From 30c08d497f4885eb00d5668d2e61c4aca71d5b56 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Fri, 6 Feb 2026 17:21:00 +0300 Subject: [PATCH] Add API endpoints for stats and audits, implement data collection functions, and enhance index view with skeleton context --- dashboard/tests/test_views.py | 162 +++- dashboard/urls.py | 2 + dashboard/views.py | 121 ++- static/js/utils.js | 10 + templates/base.html | 2 +- templates/index.html | 1521 +++++++++++++++++++-------------- 6 files changed, 1141 insertions(+), 677 deletions(-) diff --git a/dashboard/tests/test_views.py b/dashboard/tests/test_views.py index 09feae3..b57833c 100644 --- a/dashboard/tests/test_views.py +++ b/dashboard/tests/test_views.py @@ -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") diff --git a/dashboard/urls.py b/dashboard/urls.py index 4de1228..da9adb3 100644 --- a/dashboard/urls.py +++ b/dashboard/urls.py @@ -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), ] \ No newline at end of file diff --git a/dashboard/views.py b/dashboard/views.py index 771f379..d215c3c 100644 --- a/dashboard/views.py +++ b/dashboard/views.py @@ -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) + 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) - context = cache.get(cache_key) - if context is None: - context = collect_context() - cache.set(cache_key, context, timeout=cache_ttl) - return render(request, "index.html", context) \ No newline at end of file + 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}) \ No newline at end of file diff --git a/static/js/utils.js b/static/js/utils.js index 132d93b..f02c449 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -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(); diff --git a/templates/base.html b/templates/base.html index a007e07..26a7ccb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,7 +21,7 @@