All checks were successful
CI / ci (push) Successful in 14s
- Introduced a new `stats.py` module to encapsulate dashboard statistics building and cache key constants. - Refactored `views.py` to utilize the new `build_stats` function for constructing metrics context, improving code organization and readability. - Updated Prometheus query handling to streamline metrics fetching with a new `fetch_dashboard_metrics` function. - Enhanced test cases to reflect changes in metrics fetching and context building, ensuring accurate functionality. - Added new HTML templates for displaying detailed resource allocation and flavor statistics on the dashboard.
419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""Tests for dashboard.views."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from django.core.cache import cache
|
|
from django.test import RequestFactory, TestCase
|
|
|
|
from dashboard.views import (
|
|
api_audits,
|
|
api_source_status,
|
|
api_stats,
|
|
collect_context,
|
|
index,
|
|
)
|
|
|
|
|
|
def _minimal_render_context(region_name="test", first_flavor_name="f1", vm_count=1):
|
|
"""Context with all keys the index.html template expects."""
|
|
return {
|
|
"region": {"name": region_name, "hosts_total": 1},
|
|
"pcpu": {"total": 1, "usage": 0, "free": 1, "used_percentage": 0},
|
|
"vcpu": {
|
|
"total": 2,
|
|
"allocated": 1,
|
|
"free": 1,
|
|
"allocated_percentage": 50,
|
|
"overcommit_ratio": 1,
|
|
"overcommit_max": 2,
|
|
},
|
|
"pram": {"total": 1024**3, "usage": 0, "free": 1024**3, "used_percentage": 0},
|
|
"vram": {
|
|
"total": 1024**3,
|
|
"allocated": 0,
|
|
"free": 1024**3,
|
|
"allocated_percentage": 0,
|
|
"overcommit_ratio": 0,
|
|
"overcommit_max": 1,
|
|
},
|
|
"vm": {
|
|
"count": vm_count,
|
|
"active": vm_count,
|
|
"stopped": 0,
|
|
"avg_cpu": 1,
|
|
"avg_ram": 0,
|
|
"density": float(vm_count),
|
|
},
|
|
"flavors": {
|
|
"first_common_flavor": {"name": first_flavor_name, "count": vm_count},
|
|
"second_common_flavor": {"name": "—", "count": 0},
|
|
"third_common_flavor": {"name": "—", "count": 0},
|
|
},
|
|
"audits": [],
|
|
}
|
|
|
|
|
|
class IndexViewTest(TestCase):
|
|
"""Tests for the index view."""
|
|
|
|
def setUp(self):
|
|
self.factory = RequestFactory()
|
|
|
|
@patch("dashboard.views.settings")
|
|
def test_index_use_mock_data_returns_200_and_mock_context(self, mock_settings):
|
|
mock_settings.USE_MOCK_DATA = True
|
|
mock_settings.DASHBOARD_CACHE_TTL = 120
|
|
request = self.factory.get("/")
|
|
response = index(request)
|
|
self.assertEqual(response.status_code, 200)
|
|
# Mock context contains mock-region and flavors; render uses index.html
|
|
content = response.content.decode()
|
|
self.assertIn("mock-region", content)
|
|
self.assertIn("m1.small", content)
|
|
|
|
@patch("dashboard.views.collect_context")
|
|
@patch("dashboard.views.settings")
|
|
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
|
|
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('data-dashboard="skeleton"', content)
|
|
self.assertIn("—", content)
|
|
|
|
|
|
class CollectContextTest(TestCase):
|
|
"""Tests for collect_context with mocked dependencies."""
|
|
|
|
def _make_mock_connection(self, region_name="test-region"):
|
|
conn = MagicMock()
|
|
conn._compute_region = region_name
|
|
return conn
|
|
|
|
@patch("dashboard.views.get_current_cluster_cpu")
|
|
@patch("dashboard.views.fetch_dashboard_metrics")
|
|
@patch("dashboard.views.get_audits")
|
|
@patch("dashboard.views.get_flavor_list")
|
|
@patch("dashboard.views.get_connection")
|
|
def test_collect_context_structure_and_calculation(
|
|
self,
|
|
mock_get_connection,
|
|
mock_get_flavor_list,
|
|
mock_get_audits,
|
|
mock_fetch_metrics,
|
|
mock_get_current_cluster_cpu,
|
|
):
|
|
mock_get_connection.return_value = self._make_mock_connection("my-region")
|
|
mock_get_current_cluster_cpu.return_value = {
|
|
"host_labels": ["h0", "h1"],
|
|
"cpu_current": [30.0, 40.0],
|
|
}
|
|
mock_get_flavor_list.return_value = {
|
|
"first_common_flavor": {"name": "m1.small", "count": 5},
|
|
"second_common_flavor": {"name": "—", "count": 0},
|
|
"third_common_flavor": {"name": "—", "count": 0},
|
|
}
|
|
mock_get_audits.return_value = [
|
|
{
|
|
"migrations": [],
|
|
"host_labels": ["h0", "h1"],
|
|
"cpu_current": [30.0, 40.0],
|
|
"cpu_projected": [35.0, 35.0],
|
|
}
|
|
]
|
|
mock_fetch_metrics.return_value = {
|
|
"hosts_total": 2,
|
|
"pcpu_total": 8,
|
|
"pcpu_usage": 2.5,
|
|
"vcpu_allocated": 16,
|
|
"vcpu_overcommit_max": 2.0,
|
|
"pram_total": 32 * 1024**3,
|
|
"pram_usage": 8 * 1024**3,
|
|
"vram_allocated": 24 * 1024**3,
|
|
"vram_overcommit_max": 1.5,
|
|
"vm_count": 4,
|
|
"vm_active": 4,
|
|
}
|
|
|
|
context = collect_context()
|
|
|
|
self.assertEqual(context["region"]["name"], "my-region")
|
|
self.assertEqual(context["region"]["hosts_total"], 2)
|
|
self.assertEqual(context["pcpu"]["total"], 8)
|
|
self.assertEqual(context["pcpu"]["usage"], 2.5)
|
|
self.assertEqual(context["vcpu"]["total"], 8 * 2.0) # pcpu_total * vcpu_overcommit_max
|
|
self.assertEqual(context["vcpu"]["allocated"], 16)
|
|
self.assertEqual(context["vram"]["total"], 32 * 1024**3 * 1.5)
|
|
self.assertEqual(context["flavors"]["first_common_flavor"]["name"], "m1.small")
|
|
self.assertEqual(len(context["audits"]), 1)
|
|
# Serialized for JS
|
|
self.assertIsInstance(context["audits"][0]["migrations"], str)
|
|
self.assertEqual(json.loads(context["audits"][0]["host_labels"]), ["h0", "h1"])
|
|
self.assertIn("current_cluster", context)
|
|
self.assertEqual(json.loads(context["current_cluster"]["host_labels"]), ["h0", "h1"])
|
|
self.assertEqual(json.loads(context["current_cluster"]["cpu_current"]), [30.0, 40.0])
|
|
|
|
|
|
class ApiStatsTest(TestCase):
|
|
"""Tests for api_stats view."""
|
|
|
|
def setUp(self):
|
|
self.factory = RequestFactory()
|
|
|
|
@patch("dashboard.views.fetch_dashboard_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_current_cluster_cpu")
|
|
@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_current_cluster_cpu
|
|
):
|
|
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],
|
|
}
|
|
]
|
|
mock_get_current_cluster_cpu.return_value = {
|
|
"host_labels": ["h0", "h1"],
|
|
"cpu_current": [30.0, 40.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)
|
|
self.assertIn("current_cluster", data)
|
|
self.assertEqual(data["current_cluster"]["host_labels"], ["h0", "h1"])
|
|
self.assertEqual(data["current_cluster"]["cpu_current"], [30.0, 40.0])
|
|
|
|
@patch("dashboard.views.get_current_cluster_cpu")
|
|
@patch("dashboard.views.collect_audits")
|
|
@patch("dashboard.views.settings")
|
|
def test_api_audits_uses_cache(
|
|
self, mock_settings, mock_collect_audits, mock_get_current_cluster_cpu
|
|
):
|
|
mock_settings.DASHBOARD_CACHE_TTL = 120
|
|
cached_audits = [
|
|
{
|
|
"id": "cached-1",
|
|
"name": "Cached Audit",
|
|
"migrations": "[]",
|
|
"host_labels": "[]",
|
|
"cpu_current": "[]",
|
|
"cpu_projected": "[]",
|
|
}
|
|
]
|
|
cached_cluster = {"host_labels": ["cached-h0"], "cpu_current": [10.0]}
|
|
cache.clear()
|
|
cache.set("dashboard_audits", cached_audits, timeout=120)
|
|
cache.set("dashboard_current_cluster", cached_cluster, timeout=120)
|
|
request = self.factory.get("/api/audits/")
|
|
response = api_audits(request)
|
|
mock_collect_audits.assert_not_called()
|
|
mock_get_current_cluster_cpu.assert_not_called()
|
|
data = json.loads(response.content)
|
|
self.assertEqual(data["audits"][0]["name"], "Cached Audit")
|
|
self.assertEqual(data["current_cluster"], cached_cluster)
|
|
|
|
|
|
class ApiSourceStatusTest(TestCase):
|
|
"""Tests for api_source_status view."""
|
|
|
|
def setUp(self):
|
|
self.factory = RequestFactory()
|
|
|
|
@patch("dashboard.views.settings")
|
|
def test_api_source_status_mock_returns_mock_status(self, mock_settings):
|
|
mock_settings.USE_MOCK_DATA = True
|
|
mock_settings.SOURCE_STATUS_CACHE_TTL = 30
|
|
request = self.factory.get("/api/source-status/")
|
|
response = api_source_status(request)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response["Content-Type"], "application/json")
|
|
data = json.loads(response.content)
|
|
self.assertEqual(data["prometheus"]["status"], "mock")
|
|
self.assertEqual(data["openstack"]["status"], "mock")
|
|
|
|
@patch("dashboard.views.check_openstack")
|
|
@patch("dashboard.views.check_prometheus")
|
|
@patch("dashboard.views.settings")
|
|
def test_api_source_status_both_ok_returns_ok(
|
|
self, mock_settings, mock_check_prometheus, mock_check_openstack
|
|
):
|
|
mock_settings.USE_MOCK_DATA = False
|
|
mock_settings.SOURCE_STATUS_CACHE_TTL = 30
|
|
mock_check_prometheus.return_value = {"status": "ok"}
|
|
mock_check_openstack.return_value = {"status": "ok"}
|
|
cache.clear()
|
|
request = self.factory.get("/api/source-status/")
|
|
response = api_source_status(request)
|
|
self.assertEqual(response.status_code, 200)
|
|
data = json.loads(response.content)
|
|
self.assertEqual(data["prometheus"]["status"], "ok")
|
|
self.assertEqual(data["openstack"]["status"], "ok")
|
|
mock_check_prometheus.assert_called_once()
|
|
mock_check_openstack.assert_called_once()
|
|
|
|
@patch("dashboard.views.check_openstack")
|
|
@patch("dashboard.views.check_prometheus")
|
|
@patch("dashboard.views.settings")
|
|
def test_api_source_status_prometheus_error_returns_error_message(
|
|
self, mock_settings, mock_check_prometheus, mock_check_openstack
|
|
):
|
|
mock_settings.USE_MOCK_DATA = False
|
|
mock_settings.SOURCE_STATUS_CACHE_TTL = 30
|
|
mock_check_prometheus.return_value = {"status": "error", "message": "Connection refused"}
|
|
mock_check_openstack.return_value = {"status": "ok"}
|
|
cache.clear()
|
|
request = self.factory.get("/api/source-status/")
|
|
response = api_source_status(request)
|
|
self.assertEqual(response.status_code, 200)
|
|
data = json.loads(response.content)
|
|
self.assertEqual(data["prometheus"]["status"], "error")
|
|
self.assertEqual(data["prometheus"]["message"], "Connection refused")
|
|
self.assertEqual(data["openstack"]["status"], "ok")
|
|
|
|
@patch("dashboard.views.check_openstack")
|
|
@patch("dashboard.views.check_prometheus")
|
|
@patch("dashboard.views.settings")
|
|
def test_api_source_status_openstack_error_returns_error_message(
|
|
self, mock_settings, mock_check_prometheus, mock_check_openstack
|
|
):
|
|
mock_settings.USE_MOCK_DATA = False
|
|
mock_settings.SOURCE_STATUS_CACHE_TTL = 30
|
|
mock_check_prometheus.return_value = {"status": "ok"}
|
|
mock_check_openstack.return_value = {"status": "error", "message": "Auth failed"}
|
|
cache.clear()
|
|
request = self.factory.get("/api/source-status/")
|
|
response = api_source_status(request)
|
|
self.assertEqual(response.status_code, 200)
|
|
data = json.loads(response.content)
|
|
self.assertEqual(data["prometheus"]["status"], "ok")
|
|
self.assertEqual(data["openstack"]["status"], "error")
|
|
self.assertEqual(data["openstack"]["message"], "Auth failed")
|
|
|
|
@patch("dashboard.views.check_openstack")
|
|
@patch("dashboard.views.check_prometheus")
|
|
@patch("dashboard.views.settings")
|
|
def test_api_source_status_uses_cache(
|
|
self, mock_settings, mock_check_prometheus, mock_check_openstack
|
|
):
|
|
mock_settings.USE_MOCK_DATA = False
|
|
mock_settings.SOURCE_STATUS_CACHE_TTL = 30
|
|
cache.clear()
|
|
cached = {
|
|
"prometheus": {"status": "ok"},
|
|
"openstack": {"status": "ok"},
|
|
}
|
|
cache.set("dashboard_source_status", cached, timeout=30)
|
|
request = self.factory.get("/api/source-status/")
|
|
response = api_source_status(request)
|
|
mock_check_prometheus.assert_not_called()
|
|
mock_check_openstack.assert_not_called()
|
|
data = json.loads(response.content)
|
|
self.assertEqual(data["prometheus"]["status"], "ok")
|
|
self.assertEqual(data["openstack"]["status"], "ok")
|