Refactor Dockerfile for improved build process, add CMD for running server, and update requirements.txt for consistency. Adjust URL patterns in dashboard and enhance base template with theme toggle functionality and print support.

This commit is contained in:
2026-02-07 10:23:23 +03:00
parent 48c9e4ddeb
commit 51b02eb6a4
5 changed files with 402 additions and 402 deletions

View File

@@ -1,37 +1,37 @@
FROM alpine:3 AS build FROM alpine:3 AS build
RUN apk update && \ RUN apk update && \
apk add --no-cache --virtual .build-deps \ apk add --no-cache --virtual .build-deps \
ca-certificates gcc postgresql-dev linux-headers musl-dev \ ca-certificates gcc postgresql-dev linux-headers musl-dev \
libffi-dev jpeg-dev zlib-dev \ libffi-dev jpeg-dev zlib-dev \
git bash build-base python3-dev \ git bash build-base python3-dev \
dos2unix dos2unix
RUN python3 -m venv /venv RUN python3 -m venv /venv
ENV PATH "/venv/bin:$PATH" ENV PATH "/venv/bin:$PATH"
COPY ./requirements.txt / COPY ./requirements.txt /
RUN pip install -r /requirements.txt RUN pip install -r /requirements.txt
COPY ./docker-entrypoint.sh /docker-entrypoint.sh COPY ./docker-entrypoint.sh /docker-entrypoint.sh
RUN dos2unix /docker-entrypoint.sh && \ RUN dos2unix /docker-entrypoint.sh && \
chmod +x /docker-entrypoint.sh chmod +x /docker-entrypoint.sh
FROM alpine:3 FROM alpine:3
ENV LANG C.UTF-8 ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8 ENV LC_ALL C.UTF-8
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV PATH "/venv/bin:$PATH" ENV PATH "/venv/bin:$PATH"
RUN apk add --no-cache --update python3 curl RUN apk add --no-cache --update python3 curl
COPY --from=build /venv /venv COPY --from=build /venv /venv
COPY --from=build /docker-entrypoint.sh /docker-entrypoint.sh COPY --from=build /docker-entrypoint.sh /docker-entrypoint.sh
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app
ENTRYPOINT ["/docker-entrypoint.sh"] ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"] CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]

View File

@@ -1,8 +1,8 @@
from django.urls import path from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('', views.index, name='index'), path('', views.index, name='index'),
path('api/stats/', views.api_stats), path('api/stats/', views.api_stats),
path('api/audits/', views.api_audits), path('api/audits/', views.api_audits),
] ]

View File

@@ -1,252 +1,252 @@
import json import json
from concurrent.futures import ThreadPoolExecutor, as_completed 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.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
from dashboard.prometheus_utils.query import query_prometheus from dashboard.prometheus_utils.query import query_prometheus
from dashboard.openstack_utils.audits import get_audits from dashboard.openstack_utils.audits import get_audits
from dashboard.mock_data import get_mock_context from dashboard.mock_data import get_mock_context
# Prometheus queries run in parallel (query_key -> query string) # Prometheus queries run in parallel (query_key -> query string)
_PROMETHEUS_QUERIES = { _PROMETHEUS_QUERIES = {
"hosts_total": "count(node_exporter_build_info{job='node_exporter_compute'})", "hosts_total": "count(node_exporter_build_info{job='node_exporter_compute'})",
"pcpu_total": "sum(count(node_cpu_seconds_total{job='node_exporter_compute', mode='idle'}) without (cpu,mode))", "pcpu_total": "sum(count(node_cpu_seconds_total{job='node_exporter_compute', mode='idle'}) without (cpu,mode))",
"pcpu_usage": "sum(node_load5{job='node_exporter_compute'})", "pcpu_usage": "sum(node_load5{job='node_exporter_compute'})",
"vcpu_allocated": "sum(libvirt_domain_info_virtual_cpus)", "vcpu_allocated": "sum(libvirt_domain_info_virtual_cpus)",
"vcpu_overcommit_max": "avg(openstack_placement_resource_allocation_ratio{resourcetype='VCPU'})", "vcpu_overcommit_max": "avg(openstack_placement_resource_allocation_ratio{resourcetype='VCPU'})",
"pram_total": "sum(node_memory_MemTotal_bytes{job='node_exporter_compute'})", "pram_total": "sum(node_memory_MemTotal_bytes{job='node_exporter_compute'})",
"pram_usage": "sum(node_memory_Active_bytes{job='node_exporter_compute'})", "pram_usage": "sum(node_memory_Active_bytes{job='node_exporter_compute'})",
"vram_allocated": "sum(libvirt_domain_info_maximum_memory_bytes)", "vram_allocated": "sum(libvirt_domain_info_maximum_memory_bytes)",
"vram_overcommit_max": "avg(avg_over_time(openstack_placement_resource_allocation_ratio{resourcetype='MEMORY_MB'}[5m]))", "vram_overcommit_max": "avg(avg_over_time(openstack_placement_resource_allocation_ratio{resourcetype='MEMORY_MB'}[5m]))",
"vm_count": "sum(libvirt_domain_state_code)", "vm_count": "sum(libvirt_domain_state_code)",
"vm_active": "sum(libvirt_domain_state_code{stateDesc='the domain is running'})", "vm_active": "sum(libvirt_domain_state_code{stateDesc='the domain is running'})",
} }
def _fetch_prometheus_metrics(): def _fetch_prometheus_metrics():
"""Run all Prometheus queries in parallel and return a dict of name -> value.""" """Run all Prometheus queries in parallel and return a dict of name -> value."""
result = {} result = {}
with ThreadPoolExecutor(max_workers=len(_PROMETHEUS_QUERIES)) as executor: with ThreadPoolExecutor(max_workers=len(_PROMETHEUS_QUERIES)) as executor:
future_to_key = { future_to_key = {
executor.submit(query_prometheus, query=q): key executor.submit(query_prometheus, query=q): key
for key, q in _PROMETHEUS_QUERIES.items() for key, q in _PROMETHEUS_QUERIES.items()
} }
for future in as_completed(future_to_key): for future in as_completed(future_to_key):
key = future_to_key[future] key = future_to_key[future]
try: try:
raw = future.result() raw = future.result()
if key in ("pcpu_usage", "vcpu_overcommit_max", "vram_overcommit_max"): if key in ("pcpu_usage", "vcpu_overcommit_max", "vram_overcommit_max"):
result[key] = float(raw) result[key] = float(raw)
else: else:
result[key] = int(raw) result[key] = int(raw)
except (ValueError, TypeError): except (ValueError, TypeError):
result[key] = 0 if key in ("pcpu_usage", "vcpu_overcommit_max", "vram_overcommit_max") else 0 result[key] = 0 if key in ("pcpu_usage", "vcpu_overcommit_max", "vram_overcommit_max") else 0
return result return result
def collect_context(): def collect_context():
connection = get_connection() connection = get_connection()
region_name = connection._compute_region region_name = connection._compute_region
flavors = get_flavor_list(connection=connection) flavors = get_flavor_list(connection=connection)
audits = get_audits(connection=connection) audits = get_audits(connection=connection)
metrics = _fetch_prometheus_metrics() metrics = _fetch_prometheus_metrics()
hosts_total = metrics.get("hosts_total") or 1 hosts_total = metrics.get("hosts_total") or 1
pcpu_total = metrics.get("pcpu_total", 0) pcpu_total = metrics.get("pcpu_total", 0)
pcpu_usage = metrics.get("pcpu_usage", 0) pcpu_usage = metrics.get("pcpu_usage", 0)
vcpu_allocated = metrics.get("vcpu_allocated", 0) vcpu_allocated = metrics.get("vcpu_allocated", 0)
vcpu_overcommit_max = metrics.get("vcpu_overcommit_max", 0) vcpu_overcommit_max = metrics.get("vcpu_overcommit_max", 0)
pram_total = metrics.get("pram_total", 0) pram_total = metrics.get("pram_total", 0)
pram_usage = metrics.get("pram_usage", 0) pram_usage = metrics.get("pram_usage", 0)
vram_allocated = metrics.get("vram_allocated", 0) vram_allocated = metrics.get("vram_allocated", 0)
vram_overcommit_max = metrics.get("vram_overcommit_max", 0) vram_overcommit_max = metrics.get("vram_overcommit_max", 0)
vm_count = metrics.get("vm_count", 0) vm_count = metrics.get("vm_count", 0)
vm_active = metrics.get("vm_active", 0) vm_active = metrics.get("vm_active", 0)
vcpu_total = pcpu_total * vcpu_overcommit_max vcpu_total = pcpu_total * vcpu_overcommit_max
vram_total = pram_total * vram_overcommit_max vram_total = pram_total * vram_overcommit_max
context = { context = {
# <--- Region data ---> # <--- Region data --->
"region": { "region": {
"name": region_name, "name": region_name,
"hosts_total": hosts_total, "hosts_total": hosts_total,
}, },
# <--- CPU data ---> # <--- CPU data --->
# pCPU data # pCPU data
"pcpu": { "pcpu": {
"total": pcpu_total, "total": pcpu_total,
"usage": pcpu_usage, "usage": pcpu_usage,
"free": pcpu_total - pcpu_usage, "free": pcpu_total - pcpu_usage,
"used_percentage": (pcpu_usage / pcpu_total * 100) if pcpu_total else 0, "used_percentage": (pcpu_usage / pcpu_total * 100) if pcpu_total else 0,
}, },
# vCPU data # vCPU data
"vcpu": { "vcpu": {
"total": vcpu_total, "total": vcpu_total,
"allocated": vcpu_allocated, "allocated": vcpu_allocated,
"free": vcpu_total - vcpu_allocated, "free": vcpu_total - vcpu_allocated,
"allocated_percentage": (vcpu_allocated / vcpu_total * 100) if vcpu_total else 0, "allocated_percentage": (vcpu_allocated / vcpu_total * 100) if vcpu_total else 0,
"overcommit_ratio": (vcpu_allocated / pcpu_total) if pcpu_total else 0, "overcommit_ratio": (vcpu_allocated / pcpu_total) if pcpu_total else 0,
"overcommit_max": vcpu_overcommit_max, "overcommit_max": vcpu_overcommit_max,
}, },
# <--- RAM data ---> # <--- RAM data --->
# pRAM data # pRAM data
"pram": { "pram": {
"total": pram_total, "total": pram_total,
"usage": pram_usage, "usage": pram_usage,
"free": pram_total - pram_usage, "free": pram_total - pram_usage,
"used_percentage": (pram_usage / pram_total * 100) if pram_total else 0, "used_percentage": (pram_usage / pram_total * 100) if pram_total else 0,
}, },
# vRAM data # vRAM data
"vram": { "vram": {
"total": vram_total, "total": vram_total,
"allocated": vram_allocated, "allocated": vram_allocated,
"free": vram_total - vram_allocated, "free": vram_total - vram_allocated,
"allocated_percentage": (vram_allocated / vram_total * 100) if vram_total else 0, "allocated_percentage": (vram_allocated / vram_total * 100) if vram_total else 0,
"overcommit_ratio": (vram_allocated / pram_total) if pram_total else 0, "overcommit_ratio": (vram_allocated / pram_total) if pram_total else 0,
"overcommit_max": vram_overcommit_max, "overcommit_max": vram_overcommit_max,
}, },
# <--- VM data ---> # <--- VM data --->
"vm": { "vm": {
"count": vm_count, "count": vm_count,
"active": vm_active, "active": vm_active,
"stopped": vm_count - vm_active, "stopped": vm_count - vm_active,
"avg_cpu": vcpu_allocated / vm_count if vm_count else 0, "avg_cpu": vcpu_allocated / vm_count if vm_count else 0,
"avg_ram": vram_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, "density": vm_count / hosts_total if hosts_total else 0,
}, },
"flavors": flavors, "flavors": flavors,
"audits": audits, "audits": audits,
} }
# Serialize audit list fields for JavaScript so cached context is render-ready # Serialize audit list fields for JavaScript so cached context is render-ready
for audit in context["audits"]: for audit in context["audits"]:
audit["migrations"] = json.dumps(audit["migrations"]) audit["migrations"] = json.dumps(audit["migrations"])
audit["host_labels"] = json.dumps(audit["host_labels"]) audit["host_labels"] = json.dumps(audit["host_labels"])
audit["cpu_current"] = json.dumps(audit["cpu_current"]) audit["cpu_current"] = json.dumps(audit["cpu_current"])
audit["cpu_projected"] = json.dumps(audit["cpu_projected"]) audit["cpu_projected"] = json.dumps(audit["cpu_projected"])
return context return context
def collect_stats(): def collect_stats():
"""Build stats dict: region, pcpu, pram, vcpu, vram, vm, flavors (no audits).""" """Build stats dict: region, pcpu, pram, vcpu, vram, vm, flavors (no audits)."""
connection = get_connection() connection = get_connection()
region_name = connection._compute_region region_name = connection._compute_region
flavors = get_flavor_list(connection=connection) flavors = get_flavor_list(connection=connection)
metrics = _fetch_prometheus_metrics() metrics = _fetch_prometheus_metrics()
hosts_total = metrics.get("hosts_total") or 1 hosts_total = metrics.get("hosts_total") or 1
pcpu_total = metrics.get("pcpu_total", 0) pcpu_total = metrics.get("pcpu_total", 0)
pcpu_usage = metrics.get("pcpu_usage", 0) pcpu_usage = metrics.get("pcpu_usage", 0)
vcpu_allocated = metrics.get("vcpu_allocated", 0) vcpu_allocated = metrics.get("vcpu_allocated", 0)
vcpu_overcommit_max = metrics.get("vcpu_overcommit_max", 0) vcpu_overcommit_max = metrics.get("vcpu_overcommit_max", 0)
pram_total = metrics.get("pram_total", 0) pram_total = metrics.get("pram_total", 0)
pram_usage = metrics.get("pram_usage", 0) pram_usage = metrics.get("pram_usage", 0)
vram_allocated = metrics.get("vram_allocated", 0) vram_allocated = metrics.get("vram_allocated", 0)
vram_overcommit_max = metrics.get("vram_overcommit_max", 0) vram_overcommit_max = metrics.get("vram_overcommit_max", 0)
vm_count = metrics.get("vm_count", 0) vm_count = metrics.get("vm_count", 0)
vm_active = metrics.get("vm_active", 0) vm_active = metrics.get("vm_active", 0)
vcpu_total = pcpu_total * vcpu_overcommit_max vcpu_total = pcpu_total * vcpu_overcommit_max
vram_total = pram_total * vram_overcommit_max vram_total = pram_total * vram_overcommit_max
return { return {
"region": {"name": region_name, "hosts_total": hosts_total}, "region": {"name": region_name, "hosts_total": hosts_total},
"pcpu": { "pcpu": {
"total": pcpu_total, "total": pcpu_total,
"usage": pcpu_usage, "usage": pcpu_usage,
"free": pcpu_total - pcpu_usage, "free": pcpu_total - pcpu_usage,
"used_percentage": (pcpu_usage / pcpu_total * 100) if pcpu_total else 0, "used_percentage": (pcpu_usage / pcpu_total * 100) if pcpu_total else 0,
}, },
"vcpu": { "vcpu": {
"total": vcpu_total, "total": vcpu_total,
"allocated": vcpu_allocated, "allocated": vcpu_allocated,
"free": vcpu_total - vcpu_allocated, "free": vcpu_total - vcpu_allocated,
"allocated_percentage": (vcpu_allocated / vcpu_total * 100) if vcpu_total else 0, "allocated_percentage": (vcpu_allocated / vcpu_total * 100) if vcpu_total else 0,
"overcommit_ratio": (vcpu_allocated / pcpu_total) if pcpu_total else 0, "overcommit_ratio": (vcpu_allocated / pcpu_total) if pcpu_total else 0,
"overcommit_max": vcpu_overcommit_max, "overcommit_max": vcpu_overcommit_max,
}, },
"pram": { "pram": {
"total": pram_total, "total": pram_total,
"usage": pram_usage, "usage": pram_usage,
"free": pram_total - pram_usage, "free": pram_total - pram_usage,
"used_percentage": (pram_usage / pram_total * 100) if pram_total else 0, "used_percentage": (pram_usage / pram_total * 100) if pram_total else 0,
}, },
"vram": { "vram": {
"total": vram_total, "total": vram_total,
"allocated": vram_allocated, "allocated": vram_allocated,
"free": vram_total - vram_allocated, "free": vram_total - vram_allocated,
"allocated_percentage": (vram_allocated / vram_total * 100) if vram_total else 0, "allocated_percentage": (vram_allocated / vram_total * 100) if vram_total else 0,
"overcommit_ratio": (vram_allocated / pram_total) if pram_total else 0, "overcommit_ratio": (vram_allocated / pram_total) if pram_total else 0,
"overcommit_max": vram_overcommit_max, "overcommit_max": vram_overcommit_max,
}, },
"vm": { "vm": {
"count": vm_count, "count": vm_count,
"active": vm_active, "active": vm_active,
"stopped": vm_count - vm_active, "stopped": vm_count - vm_active,
"avg_cpu": vcpu_allocated / vm_count if vm_count else 0, "avg_cpu": vcpu_allocated / vm_count if vm_count else 0,
"avg_ram": vram_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, "density": vm_count / hosts_total if hosts_total else 0,
}, },
"flavors": flavors, "flavors": flavors,
} }
def collect_audits(): def collect_audits():
"""Build audits list with serialized fields for frontend.""" """Build audits list with serialized fields for frontend."""
connection = get_connection() connection = get_connection()
audits = get_audits(connection=connection) audits = get_audits(connection=connection)
for audit in audits: for audit in audits:
audit["migrations"] = json.dumps(audit["migrations"]) audit["migrations"] = json.dumps(audit["migrations"])
audit["host_labels"] = json.dumps(audit["host_labels"]) audit["host_labels"] = json.dumps(audit["host_labels"])
audit["cpu_current"] = json.dumps(audit["cpu_current"]) audit["cpu_current"] = json.dumps(audit["cpu_current"])
audit["cpu_projected"] = json.dumps(audit["cpu_projected"]) audit["cpu_projected"] = json.dumps(audit["cpu_projected"])
return audits return audits
def _skeleton_context(): def _skeleton_context():
"""Minimal context for skeleton-only index render.""" """Minimal context for skeleton-only index render."""
empty_flavors = { empty_flavors = {
"first_common_flavor": {"name": "", "count": 0}, "first_common_flavor": {"name": "", "count": 0},
"second_common_flavor": None, "second_common_flavor": None,
"third_common_flavor": None, "third_common_flavor": None,
} }
return { return {
"skeleton": True, "skeleton": True,
"region": {"name": "", "hosts_total": 0}, "region": {"name": "", "hosts_total": 0},
"pcpu": {"total": 0, "usage": 0, "free": 0, "used_percentage": 0}, "pcpu": {"total": 0, "usage": 0, "free": 0, "used_percentage": 0},
"pram": {"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}, "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}, "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}, "vm": {"count": 0, "active": 0, "stopped": 0, "avg_cpu": 0, "avg_ram": 0, "density": 0},
"flavors": empty_flavors, "flavors": empty_flavors,
"audits": [], "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() context = _skeleton_context()
return render(request, "index.html", context) return render(request, "index.html", context)
def api_stats(request): def api_stats(request):
cache_key = "dashboard_stats" cache_key = "dashboard_stats"
cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120) cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120)
data = cache.get(cache_key) data = cache.get(cache_key)
if data is None: if data is None:
data = collect_stats() data = collect_stats()
cache.set(cache_key, data, timeout=cache_ttl) cache.set(cache_key, data, timeout=cache_ttl)
return JsonResponse(data) return JsonResponse(data)
def api_audits(request): def api_audits(request):
cache_key = "dashboard_audits" cache_key = "dashboard_audits"
cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120) cache_ttl = getattr(settings, "DASHBOARD_CACHE_TTL", 120)
audits = cache.get(cache_key) audits = cache.get(cache_key)
if audits is None: if audits is None:
audits = collect_audits() audits = collect_audits()
cache.set(cache_key, audits, timeout=cache_ttl) cache.set(cache_key, audits, timeout=cache_ttl)
return JsonResponse({"audits": audits}) return JsonResponse({"audits": audits})

View File

@@ -1,33 +1,33 @@
asgiref==3.11.0 asgiref==3.11.0
certifi==2025.11.12 certifi==2025.11.12
cffi==2.0.0 cffi==2.0.0
charset-normalizer==3.4.4 charset-normalizer==3.4.4
cryptography==46.0.3 cryptography==46.0.3
decorator==5.2.1 decorator==5.2.1
Django==5.2.8 Django==5.2.8
dogpile.cache==1.5.0 dogpile.cache==1.5.0
idna==3.11 idna==3.11
iso8601==2.1.0 iso8601==2.1.0
jmespath==1.0.1 jmespath==1.0.1
jsonpatch==1.33 jsonpatch==1.33
jsonpointer==3.0.0 jsonpointer==3.0.0
keystoneauth1==5.12.0 keystoneauth1==5.12.0
numpy==2.3.5 numpy==2.3.5
openstacksdk==4.8.0 openstacksdk==4.8.0
os-service-types==1.8.2 os-service-types==1.8.2
pandas==2.3.3 pandas==2.3.3
pbr==7.0.3 pbr==7.0.3
platformdirs==4.5.0 platformdirs==4.5.0
psutil==7.1.3 psutil==7.1.3
pycparser==2.23 pycparser==2.23
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pytz==2025.2 pytz==2025.2
PyYAML==6.0.3 PyYAML==6.0.3
requests==2.32.5 requests==2.32.5
requestsexceptions==1.4.0 requestsexceptions==1.4.0
six==1.17.0 six==1.17.0
sqlparse==0.5.4 sqlparse==0.5.4
stevedore==5.6.0 stevedore==5.6.0
typing_extensions==4.15.0 typing_extensions==4.15.0
tzdata==2025.2 tzdata==2025.2
urllib3==2.5.0 urllib3==2.5.0

View File

@@ -1,76 +1,76 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="light"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SWatcher{% endblock %}</title> <title>{% block title %}SWatcher{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/output.css' %}"> <link rel="stylesheet" href="{% static 'css/output.css' %}">
{% block imports %} {% block imports %}
{% endblock %} {% endblock %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<!-- Navbar --> <!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg border-b border-base-200 sticky top-0 z-10"> <div class="navbar bg-base-100 shadow-lg border-b border-base-200 sticky top-0 z-10">
<div class="navbar-start"> <div class="navbar-start">
<a class="btn btn-ghost text-xl" href="{% url 'index' %}">SWatcher</a> <a class="btn btn-ghost text-xl" href="{% url 'index' %}">SWatcher</a>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<div class="px-1 flex items-center gap-3 pr-10"> <div class="px-1 flex items-center gap-3 pr-10">
<button type="button" class="btn btn-ghost btn-sm no-print" onclick="window.print()" title="Save as PDF" aria-label="Save as PDF"> <button type="button" class="btn btn-ghost btn-sm no-print" onclick="window.print()" title="Save as PDF" aria-label="Save as PDF">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg> </svg>
Save as PDF Save as PDF
</button> </button>
<span id="regionBadge" 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 theme-toggle no-print"> <label class="swap swap-rotate theme-toggle no-print">
<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">
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/> <path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
</svg> </svg>
<svg class="swap-on fill-current w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="swap-on fill-current w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/> <path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/>
</svg> </svg>
</label> </label>
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<main class="container mx-auto px-4 py-8 min-h-screen"> <main class="container mx-auto px-4 py-8 min-h-screen">
<p class="print-only text-lg font-semibold mb-4">Dashboard report</p> <p class="print-only text-lg font-semibold mb-4">Dashboard report</p>
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</main> </main>
<script> <script>
// Function to apply theme // Function to apply theme
function applyTheme(theme) { function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
const checkbox = document.querySelector('.theme-controller'); const checkbox = document.querySelector('.theme-controller');
checkbox.checked = (theme === 'dark'); checkbox.checked = (theme === 'dark');
document.dispatchEvent(new Event("themechange")); document.dispatchEvent(new Event("themechange"));
} }
// Load saved theme from localStorage // Load saved theme from localStorage
const savedTheme = localStorage.getItem('theme') || 'light'; const savedTheme = localStorage.getItem('theme') || 'light';
applyTheme(savedTheme); applyTheme(savedTheme);
// Listen for toggle changes // Listen for toggle changes
document.querySelector('.theme-controller').addEventListener('change', function() { document.querySelector('.theme-controller').addEventListener('change', function() {
const newTheme = this.checked ? 'dark' : 'light'; const newTheme = this.checked ? 'dark' : 'light';
applyTheme(newTheme); applyTheme(newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
}); });
</script> </script>
{% block script %} {% block script %}
{% endblock %} {% endblock %}
</body> </body>
</html> </html>