Add API endpoints for stats and audits, implement data collection functions, and enhance index view with skeleton context
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
@@ -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})
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
1521
templates/index.html
1521
templates/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user