Matthias Nott
2026-02-21 ed26def7d76ac011075c11e8c1679ed1f7a08abc
feat: Clickable stat tiles, view toggle, CPU/memory metrics, restore fix

- Dashboard stat tiles (Services/Healthy/Down) switch to filtered table view
- Card/Table view toggle in topbar with filter badges
- System page: CPU usage bar, memory/swap bars from /proc, filtered to real disks
- Fix restore.py: offsite download → offsite restore (correct ops CLI command)
- Dockerfile: add libexpat1 for host venv compatibility in restore flows
- Remove separate Services sidebar item (merged into Dashboard)
8 files modified
changed files
Dockerfile patch | view | blame | history
app/app/routers/restore.py patch | view | blame | history
app/app/routers/system.py patch | view | blame | history
app/routers/restore.py patch | view | blame | history
app/routers/services.py patch | view | blame | history
app/routers/system.py patch | view | blame | history
static/index.html patch | view | blame | history
static/js/app.js patch | view | blame | history
Dockerfile
....@@ -21,9 +21,9 @@
2121 COPY app/ ./app/
2222 COPY static/ ./static/
2323
24
-
25
-# Ensure locale support for π in paths
26
-RUN apt-get update && apt-get install -y --no-install-recommends locales && \
24
+# Locale support for paths with special chars + libexpat for host venv compatibility
25
+RUN apt-get update && \
26
+ apt-get install -y --no-install-recommends locales libexpat1 && \
2727 echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen && \
2828 rm -rf /var/lib/apt/lists/*
2929 ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
app/app/routers/restore.py
....@@ -22,15 +22,14 @@
2222 source: str,
2323 dry_run: bool,
2424 ) -> AsyncGenerator[str, None]:
25
- """
26
- Async generator that drives the restore workflow and yields SSE events.
27
- """
25
+ """Async generator that drives the restore workflow and yields SSE events."""
2826 base_args = ["restore", project, env]
2927 if dry_run:
3028 base_args.append("--dry-run")
3129
3230 if source == "offsite":
33
- download_args = ["offsite", "download", project, env]
31
+ # ops offsite restore <project> <env> — downloads from offsite storage
32
+ download_args = ["offsite", "restore", project, env]
3433 yield _sse_line({"line": f"Downloading {project}/{env} from offsite...", "timestamp": _now()})
3534
3635 download_ok = True
....@@ -69,7 +68,7 @@
6968 """
7069 Restore a backup for the given project/env.
7170
72
- Uses Server-Sent Events (SSE) to stream real-time progress to the client.
71
+ Uses Server-Sent Events (SSE) to stream real-time progress.
7372 Parameters are passed as query strings since EventSource only supports GET.
7473 """
7574 return StreamingResponse(
app/app/routers/system.py
....@@ -1,3 +1,5 @@
1
+import asyncio
2
+import os
13 import re
24 from typing import Any
35
....@@ -14,98 +16,116 @@
1416 # ---------------------------------------------------------------------------
1517
1618 def _parse_disk_output(raw: str) -> list[dict[str, str]]:
17
- """
18
- Parse df-style output into a list of filesystem dicts.
19
- Handles both the standard df header and custom ops output.
20
- """
19
+ """Parse df-style output into a list of filesystem dicts."""
2120 filesystems: list[dict[str, str]] = []
2221 lines = raw.strip().splitlines()
23
-
2422 if not lines:
2523 return filesystems
2624
27
- # Skip header line if present
2825 data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines
2926
3027 for line in data_lines:
3128 parts = line.split()
3229 if len(parts) >= 5:
33
- filesystems.append(
34
- {
35
- "filesystem": parts[0],
36
- "size": parts[1],
37
- "used": parts[2],
38
- "available": parts[3],
39
- "use_percent": parts[4],
40
- "mount": parts[5] if len(parts) > 5 else "",
41
- }
42
- )
43
-
30
+ filesystems.append({
31
+ "filesystem": parts[0],
32
+ "size": parts[1],
33
+ "used": parts[2],
34
+ "available": parts[3],
35
+ "use_percent": parts[4],
36
+ "mount": parts[5] if len(parts) > 5 else "",
37
+ })
4438 return filesystems
4539
4640
4741 def _parse_health_output(raw: str) -> list[dict[str, str]]:
48
- """
49
- Parse health check output into a list of check result dicts.
50
- Expects lines like: "[OK] Database connection" or "[FAIL] Disk space"
51
- Also handles plain text lines.
52
- """
42
+ """Parse health check output into check result dicts."""
5343 checks: list[dict[str, str]] = []
5444 for line in raw.strip().splitlines():
5545 line = line.strip()
5646 if not line:
5747 continue
58
-
5948 match = re.match(r"^\[(\w+)\]\s*(.+)$", line)
6049 if match:
6150 checks.append({"status": match.group(1), "check": match.group(2)})
6251 else:
63
- # Treat unstructured lines as informational
6452 checks.append({"status": "INFO", "check": line})
65
-
6653 return checks
6754
6855
6956 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
70
- """
71
- Parse `systemctl list-timers` output into structured timer dicts.
72
- Header: NEXT LEFT LAST PASSED UNIT ACTIVATES
73
- """
57
+ """Parse `systemctl list-timers` output into timer dicts."""
7458 timers: list[dict[str, str]] = []
7559 lines = raw.strip().splitlines()
76
-
7760 if not lines:
7861 return timers
7962
80
- # Skip header
8163 header_idx = 0
8264 for i, line in enumerate(lines):
8365 if re.match(r"(?i)next\s+left", line):
8466 header_idx = i
8567 break
8668
87
- for line in lines[header_idx + 1 :]:
69
+ for line in lines[header_idx + 1:]:
8870 line = line.strip()
8971 if not line or line.startswith("timers listed") or line.startswith("To show"):
9072 continue
91
-
92
- # systemctl list-timers columns are variable-width; split carefully
9373 parts = re.split(r"\s{2,}", line)
9474 if len(parts) >= 5:
95
- timers.append(
96
- {
97
- "next": parts[0],
98
- "left": parts[1],
99
- "last": parts[2],
100
- "passed": parts[3],
101
- "unit": parts[4],
102
- "activates": parts[5] if len(parts) > 5 else "",
103
- }
104
- )
75
+ timers.append({
76
+ "next": parts[0], "left": parts[1], "last": parts[2],
77
+ "passed": parts[3], "unit": parts[4],
78
+ "activates": parts[5] if len(parts) > 5 else "",
79
+ })
10580 elif parts:
10681 timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
107
-
10882 return timers
83
+
84
+
85
+def _read_memory() -> dict[str, Any]:
86
+ """Read memory and swap from /proc/meminfo."""
87
+ info: dict[str, int] = {}
88
+ try:
89
+ with open("/proc/meminfo") as f:
90
+ for line in f:
91
+ parts = line.split()
92
+ if len(parts) >= 2:
93
+ key = parts[0].rstrip(":")
94
+ info[key] = int(parts[1]) * 1024 # kB → bytes
95
+ except Exception:
96
+ return {}
97
+
98
+ mem_total = info.get("MemTotal", 0)
99
+ mem_available = info.get("MemAvailable", 0)
100
+ mem_used = mem_total - mem_available
101
+ swap_total = info.get("SwapTotal", 0)
102
+ swap_free = info.get("SwapFree", 0)
103
+ swap_used = swap_total - swap_free
104
+
105
+ return {
106
+ "memory": {
107
+ "total": mem_total,
108
+ "used": mem_used,
109
+ "available": mem_available,
110
+ "percent": round(mem_used / mem_total * 100, 1) if mem_total else 0,
111
+ },
112
+ "swap": {
113
+ "total": swap_total,
114
+ "used": swap_used,
115
+ "free": swap_free,
116
+ "percent": round(swap_used / swap_total * 100, 1) if swap_total else 0,
117
+ },
118
+ }
119
+
120
+
121
+def _read_cpu_stat() -> tuple[int, int]:
122
+ """Read idle and total jiffies from /proc/stat."""
123
+ with open("/proc/stat") as f:
124
+ line = f.readline()
125
+ parts = line.split()
126
+ values = [int(x) for x in parts[1:]]
127
+ idle = values[3] + (values[4] if len(values) > 4 else 0) # idle + iowait
128
+ return idle, sum(values)
109129
110130
111131 # ---------------------------------------------------------------------------
....@@ -116,21 +136,15 @@
116136 async def disk_usage(
117137 _: str = Depends(verify_token),
118138 ) -> dict[str, Any]:
119
- """
120
- Returns disk usage via `ops disk` (or falls back to `df -h`).
121
- """
139
+ """Returns disk usage via `ops disk` (fallback: `df -h`)."""
122140 result = await run_ops(["disk"])
123141 raw = result["output"]
124142
125143 if not result["success"] or not raw.strip():
126
- # Fallback to df
127144 fallback = await run_command(["df", "-h"])
128145 raw = fallback["output"]
129146 if not fallback["success"]:
130
- raise HTTPException(
131
- status_code=500,
132
- detail=f"Failed to get disk usage: {result['error']}",
133
- )
147
+ raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}")
134148
135149 return {
136150 "filesystems": _parse_disk_output(raw),
....@@ -142,20 +156,13 @@
142156 async def health_check(
143157 _: str = Depends(verify_token),
144158 ) -> dict[str, Any]:
145
- """
146
- Returns health check results via `ops health`.
147
- """
159
+ """Returns health check results via `ops health`."""
148160 result = await run_ops(["health"])
149161 if not result["success"] and not result["output"].strip():
150
- raise HTTPException(
151
- status_code=500,
152
- detail=f"Failed to run health checks: {result['error']}",
153
- )
154
-
155
- raw = result["output"]
162
+ raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
156163 return {
157
- "checks": _parse_health_output(raw),
158
- "raw": raw,
164
+ "checks": _parse_health_output(result["output"]),
165
+ "raw": result["output"],
159166 }
160167
161168
....@@ -163,35 +170,30 @@
163170 async def list_timers(
164171 _: str = Depends(verify_token),
165172 ) -> dict[str, Any]:
166
- """
167
- Lists systemd timers via `systemctl list-timers --no-pager`.
168
- """
173
+ """Lists systemd timers."""
169174 result = await run_command(["systemctl", "list-timers", "--no-pager"])
170175 if not result["success"] and not result["output"].strip():
171
- raise HTTPException(
172
- status_code=500,
173
- detail=f"Failed to list timers: {result['error']}",
174
- )
175
-
176
- raw = result["output"]
176
+ raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
177177 return {
178
- "timers": _parse_timers_output(raw),
179
- "raw": raw,
178
+ "timers": _parse_timers_output(result["output"]),
179
+ "raw": result["output"],
180180 }
181181
182182
183
-@router.get("/info", summary="Basic system information")
183
+@router.get("/info", summary="System information with CPU/memory")
184184 async def system_info(
185185 _: str = Depends(verify_token),
186186 ) -> dict[str, Any]:
187187 """
188
- Returns system uptime and load average.
189
- Reads from /proc/uptime and /proc/loadavg; falls back to `uptime` command.
188
+ Returns system uptime, load average, CPU usage, memory, and swap.
189
+
190
+ CPU usage is measured over a 0.5s window from /proc/stat.
191
+ Memory/swap are read from /proc/meminfo.
190192 """
191193 uptime_str = ""
192194 load_str = ""
193195
194
- # Try /proc first (Linux)
196
+ # Uptime
195197 try:
196198 with open("/proc/uptime") as f:
197199 seconds_up = float(f.read().split()[0])
....@@ -199,22 +201,43 @@
199201 hours = int((seconds_up % 86400) // 3600)
200202 minutes = int((seconds_up % 3600) // 60)
201203 uptime_str = f"{days}d {hours}h {minutes}m"
202
- except (FileNotFoundError, ValueError):
204
+ except Exception:
203205 pass
204206
207
+ # Load average
205208 try:
206209 with open("/proc/loadavg") as f:
207210 parts = f.read().split()
208211 load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
209
- except (FileNotFoundError, ValueError):
212
+ except Exception:
210213 pass
211214
212
- # Fallback: use `uptime` command (works on macOS too)
215
+ # CPU usage (two samples, 0.5s apart)
216
+ cpu_info: dict[str, Any] = {}
217
+ try:
218
+ idle1, total1 = _read_cpu_stat()
219
+ await asyncio.sleep(0.5)
220
+ idle2, total2 = _read_cpu_stat()
221
+ total_delta = total2 - total1
222
+ if total_delta > 0:
223
+ usage = round((1 - (idle2 - idle1) / total_delta) * 100, 1)
224
+ else:
225
+ usage = 0.0
226
+ cpu_info = {
227
+ "usage_percent": usage,
228
+ "cores": os.cpu_count() or 1,
229
+ }
230
+ except Exception:
231
+ pass
232
+
233
+ # Memory + Swap
234
+ mem_info = _read_memory()
235
+
236
+ # Fallback for uptime/load if /proc wasn't available
213237 if not uptime_str or not load_str:
214238 result = await run_command(["uptime"])
215239 if result["success"]:
216240 raw = result["output"].strip()
217
- # Parse "up X days, Y:Z, N users, load average: a, b, c"
218241 up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
219242 if up_match:
220243 uptime_str = uptime_str or up_match.group(1).strip()
....@@ -225,4 +248,6 @@
225248 return {
226249 "uptime": uptime_str or "unavailable",
227250 "load": load_str or "unavailable",
251
+ "cpu": cpu_info or None,
252
+ **mem_info,
228253 }
app/routers/restore.py
....@@ -22,15 +22,14 @@
2222 source: str,
2323 dry_run: bool,
2424 ) -> AsyncGenerator[str, None]:
25
- """
26
- Async generator that drives the restore workflow and yields SSE events.
27
- """
25
+ """Async generator that drives the restore workflow and yields SSE events."""
2826 base_args = ["restore", project, env]
2927 if dry_run:
3028 base_args.append("--dry-run")
3129
3230 if source == "offsite":
33
- download_args = ["offsite", "download", project, env]
31
+ # ops offsite restore <project> <env> — downloads from offsite storage
32
+ download_args = ["offsite", "restore", project, env]
3433 yield _sse_line({"line": f"Downloading {project}/{env} from offsite...", "timestamp": _now()})
3534
3635 download_ok = True
....@@ -69,7 +68,7 @@
6968 """
7069 Restore a backup for the given project/env.
7170
72
- Uses Server-Sent Events (SSE) to stream real-time progress to the client.
71
+ Uses Server-Sent Events (SSE) to stream real-time progress.
7372 Parameters are passed as query strings since EventSource only supports GET.
7473 """
7574 return StreamingResponse(
app/routers/services.py
....@@ -1,5 +1,7 @@
1
+import os
12 from typing import Any
23
4
+import yaml
35 from fastapi import APIRouter, Depends, HTTPException, Query
46
57 from app.auth import verify_token
....@@ -8,14 +10,111 @@
810 router = APIRouter()
911
1012 _DOCKER = "docker"
13
+_REGISTRY_PATH = os.environ.get(
14
+ "REGISTRY_PATH",
15
+ "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
16
+)
17
+
18
+# ---------------------------------------------------------------------------
19
+# Registry-based name prefix lookup (cached)
20
+# ---------------------------------------------------------------------------
21
+_prefix_cache: dict[str, str] | None = None
1122
1223
13
-def _container_name(project: str, env: str, service: str) -> str:
24
+def _load_prefixes() -> dict[str, str]:
25
+ """Load project -> name_prefix mapping from the ops registry."""
26
+ global _prefix_cache
27
+ if _prefix_cache is not None:
28
+ return _prefix_cache
29
+
30
+ try:
31
+ with open(_REGISTRY_PATH) as f:
32
+ data = yaml.safe_load(f)
33
+ _prefix_cache = {}
34
+ for proj_name, cfg in data.get("projects", {}).items():
35
+ _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)
36
+ return _prefix_cache
37
+ except Exception:
38
+ return {}
39
+
40
+
41
+# ---------------------------------------------------------------------------
42
+# Container name resolution
43
+# ---------------------------------------------------------------------------
44
+
45
+
46
+async def _find_by_prefix(pattern: str) -> str | None:
47
+ """Find first running container whose name starts with `pattern`."""
48
+ result = await run_command(
49
+ [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],
50
+ timeout=10,
51
+ )
52
+ if not result["success"]:
53
+ return None
54
+ for name in result["output"].strip().splitlines():
55
+ name = name.strip()
56
+ if name and name.startswith(pattern):
57
+ return name
58
+ return None
59
+
60
+
61
+async def _find_exact(name: str) -> str | None:
62
+ """Find a running container with exactly this name."""
63
+ result = await run_command(
64
+ [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
65
+ timeout=10,
66
+ )
67
+ if not result["success"]:
68
+ return None
69
+ for n in result["output"].strip().splitlines():
70
+ if n.strip() == name:
71
+ return name
72
+ return None
73
+
74
+
75
+async def _resolve_container(project: str, env: str, service: str) -> str:
1476 """
15
- Derive the Docker container name from project, env, and service.
16
- Docker Compose v2 default: {project}-{env}-{service}-1
77
+ Resolve the actual Docker container name from project/env/service.
78
+
79
+ Uses the ops registry name_prefix mapping and tries patterns in order:
80
+ 1. {env}-{prefix}-{service} (mdf, seriousletter: dev-mdf-mysql-UUID)
81
+ 2. {prefix}-{service} (ringsaday: ringsaday-website-UUID, coolify: coolify-db)
82
+ 3. {prefix}-{env} (ringsaday: ringsaday-dev-UUID)
83
+ 4. exact {prefix} (coolify infra: coolify)
1784 """
18
- return f"{project}-{env}-{service}-1"
85
+ prefixes = _load_prefixes()
86
+ prefix = prefixes.get(project, project)
87
+
88
+ # Pattern 1: {env}-{prefix}-{service}
89
+ hit = await _find_by_prefix(f"{env}-{prefix}-{service}")
90
+ if hit:
91
+ return hit
92
+
93
+ # Pattern 2: {prefix}-{service}
94
+ hit = await _find_by_prefix(f"{prefix}-{service}")
95
+ if hit:
96
+ return hit
97
+
98
+ # Pattern 3: {prefix}-{env}
99
+ hit = await _find_by_prefix(f"{prefix}-{env}")
100
+ if hit:
101
+ return hit
102
+
103
+ # Pattern 4: exact match when service == prefix (e.g., coolify)
104
+ if service == prefix:
105
+ hit = await _find_exact(prefix)
106
+ if hit:
107
+ return hit
108
+
109
+ raise HTTPException(
110
+ status_code=404,
111
+ detail=f"Container not found for {project}/{env}/{service}",
112
+ )
113
+
114
+
115
+# ---------------------------------------------------------------------------
116
+# Endpoints
117
+# ---------------------------------------------------------------------------
19118
20119
21120 @router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
....@@ -23,20 +122,19 @@
23122 project: str,
24123 env: str,
25124 service: str,
26
- lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"),
125
+ lines: int = Query(
126
+ default=100, ge=1, le=10000, description="Number of log lines to return"
127
+ ),
27128 _: str = Depends(verify_token),
28129 ) -> dict[str, Any]:
29
- """
30
- Fetch the last N lines of logs from a container.
31
- Uses `docker logs --tail {lines} {container}`.
32
- """
33
- container = _container_name(project, env, service)
130
+ """Fetch the last N lines of logs from a container."""
131
+ container = await _resolve_container(project, env, service)
34132 result = await run_command(
35133 [_DOCKER, "logs", "--tail", str(lines), container],
36134 timeout=30,
37135 )
38136
39
- # docker logs writes to stderr by default; treat combined output as logs
137
+ # docker logs writes to stderr by default; combine both streams
40138 combined = result["output"] + result["error"]
41139
42140 if not result["success"] and not combined.strip():
....@@ -59,10 +157,8 @@
59157 service: str,
60158 _: str = Depends(verify_token),
61159 ) -> dict[str, Any]:
62
- """
63
- Restart a Docker container via `docker restart {container}`.
64
- """
65
- container = _container_name(project, env, service)
160
+ """Restart a Docker container."""
161
+ container = await _resolve_container(project, env, service)
66162 result = await run_command(
67163 [_DOCKER, "restart", container],
68164 timeout=60,
app/routers/system.py
....@@ -1,3 +1,5 @@
1
+import asyncio
2
+import os
13 import re
24 from typing import Any
35
....@@ -14,98 +16,116 @@
1416 # ---------------------------------------------------------------------------
1517
1618 def _parse_disk_output(raw: str) -> list[dict[str, str]]:
17
- """
18
- Parse df-style output into a list of filesystem dicts.
19
- Handles both the standard df header and custom ops output.
20
- """
19
+ """Parse df-style output into a list of filesystem dicts."""
2120 filesystems: list[dict[str, str]] = []
2221 lines = raw.strip().splitlines()
23
-
2422 if not lines:
2523 return filesystems
2624
27
- # Skip header line if present
2825 data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines
2926
3027 for line in data_lines:
3128 parts = line.split()
3229 if len(parts) >= 5:
33
- filesystems.append(
34
- {
35
- "filesystem": parts[0],
36
- "size": parts[1],
37
- "used": parts[2],
38
- "available": parts[3],
39
- "use_percent": parts[4],
40
- "mount": parts[5] if len(parts) > 5 else "",
41
- }
42
- )
43
-
30
+ filesystems.append({
31
+ "filesystem": parts[0],
32
+ "size": parts[1],
33
+ "used": parts[2],
34
+ "available": parts[3],
35
+ "use_percent": parts[4],
36
+ "mount": parts[5] if len(parts) > 5 else "",
37
+ })
4438 return filesystems
4539
4640
4741 def _parse_health_output(raw: str) -> list[dict[str, str]]:
48
- """
49
- Parse health check output into a list of check result dicts.
50
- Expects lines like: "[OK] Database connection" or "[FAIL] Disk space"
51
- Also handles plain text lines.
52
- """
42
+ """Parse health check output into check result dicts."""
5343 checks: list[dict[str, str]] = []
5444 for line in raw.strip().splitlines():
5545 line = line.strip()
5646 if not line:
5747 continue
58
-
5948 match = re.match(r"^\[(\w+)\]\s*(.+)$", line)
6049 if match:
6150 checks.append({"status": match.group(1), "check": match.group(2)})
6251 else:
63
- # Treat unstructured lines as informational
6452 checks.append({"status": "INFO", "check": line})
65
-
6653 return checks
6754
6855
6956 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
70
- """
71
- Parse `systemctl list-timers` output into structured timer dicts.
72
- Header: NEXT LEFT LAST PASSED UNIT ACTIVATES
73
- """
57
+ """Parse `systemctl list-timers` output into timer dicts."""
7458 timers: list[dict[str, str]] = []
7559 lines = raw.strip().splitlines()
76
-
7760 if not lines:
7861 return timers
7962
80
- # Skip header
8163 header_idx = 0
8264 for i, line in enumerate(lines):
8365 if re.match(r"(?i)next\s+left", line):
8466 header_idx = i
8567 break
8668
87
- for line in lines[header_idx + 1 :]:
69
+ for line in lines[header_idx + 1:]:
8870 line = line.strip()
8971 if not line or line.startswith("timers listed") or line.startswith("To show"):
9072 continue
91
-
92
- # systemctl list-timers columns are variable-width; split carefully
9373 parts = re.split(r"\s{2,}", line)
9474 if len(parts) >= 5:
95
- timers.append(
96
- {
97
- "next": parts[0],
98
- "left": parts[1],
99
- "last": parts[2],
100
- "passed": parts[3],
101
- "unit": parts[4],
102
- "activates": parts[5] if len(parts) > 5 else "",
103
- }
104
- )
75
+ timers.append({
76
+ "next": parts[0], "left": parts[1], "last": parts[2],
77
+ "passed": parts[3], "unit": parts[4],
78
+ "activates": parts[5] if len(parts) > 5 else "",
79
+ })
10580 elif parts:
10681 timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
107
-
10882 return timers
83
+
84
+
85
+def _read_memory() -> dict[str, Any]:
86
+ """Read memory and swap from /proc/meminfo."""
87
+ info: dict[str, int] = {}
88
+ try:
89
+ with open("/proc/meminfo") as f:
90
+ for line in f:
91
+ parts = line.split()
92
+ if len(parts) >= 2:
93
+ key = parts[0].rstrip(":")
94
+ info[key] = int(parts[1]) * 1024 # kB → bytes
95
+ except Exception:
96
+ return {}
97
+
98
+ mem_total = info.get("MemTotal", 0)
99
+ mem_available = info.get("MemAvailable", 0)
100
+ mem_used = mem_total - mem_available
101
+ swap_total = info.get("SwapTotal", 0)
102
+ swap_free = info.get("SwapFree", 0)
103
+ swap_used = swap_total - swap_free
104
+
105
+ return {
106
+ "memory": {
107
+ "total": mem_total,
108
+ "used": mem_used,
109
+ "available": mem_available,
110
+ "percent": round(mem_used / mem_total * 100, 1) if mem_total else 0,
111
+ },
112
+ "swap": {
113
+ "total": swap_total,
114
+ "used": swap_used,
115
+ "free": swap_free,
116
+ "percent": round(swap_used / swap_total * 100, 1) if swap_total else 0,
117
+ },
118
+ }
119
+
120
+
121
+def _read_cpu_stat() -> tuple[int, int]:
122
+ """Read idle and total jiffies from /proc/stat."""
123
+ with open("/proc/stat") as f:
124
+ line = f.readline()
125
+ parts = line.split()
126
+ values = [int(x) for x in parts[1:]]
127
+ idle = values[3] + (values[4] if len(values) > 4 else 0) # idle + iowait
128
+ return idle, sum(values)
109129
110130
111131 # ---------------------------------------------------------------------------
....@@ -116,21 +136,15 @@
116136 async def disk_usage(
117137 _: str = Depends(verify_token),
118138 ) -> dict[str, Any]:
119
- """
120
- Returns disk usage via `ops disk` (or falls back to `df -h`).
121
- """
139
+ """Returns disk usage via `ops disk` (fallback: `df -h`)."""
122140 result = await run_ops(["disk"])
123141 raw = result["output"]
124142
125143 if not result["success"] or not raw.strip():
126
- # Fallback to df
127144 fallback = await run_command(["df", "-h"])
128145 raw = fallback["output"]
129146 if not fallback["success"]:
130
- raise HTTPException(
131
- status_code=500,
132
- detail=f"Failed to get disk usage: {result['error']}",
133
- )
147
+ raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}")
134148
135149 return {
136150 "filesystems": _parse_disk_output(raw),
....@@ -142,20 +156,13 @@
142156 async def health_check(
143157 _: str = Depends(verify_token),
144158 ) -> dict[str, Any]:
145
- """
146
- Returns health check results via `ops health`.
147
- """
159
+ """Returns health check results via `ops health`."""
148160 result = await run_ops(["health"])
149161 if not result["success"] and not result["output"].strip():
150
- raise HTTPException(
151
- status_code=500,
152
- detail=f"Failed to run health checks: {result['error']}",
153
- )
154
-
155
- raw = result["output"]
162
+ raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
156163 return {
157
- "checks": _parse_health_output(raw),
158
- "raw": raw,
164
+ "checks": _parse_health_output(result["output"]),
165
+ "raw": result["output"],
159166 }
160167
161168
....@@ -163,35 +170,30 @@
163170 async def list_timers(
164171 _: str = Depends(verify_token),
165172 ) -> dict[str, Any]:
166
- """
167
- Lists systemd timers via `systemctl list-timers --no-pager`.
168
- """
173
+ """Lists systemd timers."""
169174 result = await run_command(["systemctl", "list-timers", "--no-pager"])
170175 if not result["success"] and not result["output"].strip():
171
- raise HTTPException(
172
- status_code=500,
173
- detail=f"Failed to list timers: {result['error']}",
174
- )
175
-
176
- raw = result["output"]
176
+ raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
177177 return {
178
- "timers": _parse_timers_output(raw),
179
- "raw": raw,
178
+ "timers": _parse_timers_output(result["output"]),
179
+ "raw": result["output"],
180180 }
181181
182182
183
-@router.get("/info", summary="Basic system information")
183
+@router.get("/info", summary="System information with CPU/memory")
184184 async def system_info(
185185 _: str = Depends(verify_token),
186186 ) -> dict[str, Any]:
187187 """
188
- Returns system uptime and load average.
189
- Reads from /proc/uptime and /proc/loadavg; falls back to `uptime` command.
188
+ Returns system uptime, load average, CPU usage, memory, and swap.
189
+
190
+ CPU usage is measured over a 0.5s window from /proc/stat.
191
+ Memory/swap are read from /proc/meminfo.
190192 """
191193 uptime_str = ""
192194 load_str = ""
193195
194
- # Try /proc first (Linux)
196
+ # Uptime
195197 try:
196198 with open("/proc/uptime") as f:
197199 seconds_up = float(f.read().split()[0])
....@@ -199,22 +201,43 @@
199201 hours = int((seconds_up % 86400) // 3600)
200202 minutes = int((seconds_up % 3600) // 60)
201203 uptime_str = f"{days}d {hours}h {minutes}m"
202
- except (FileNotFoundError, ValueError):
204
+ except Exception:
203205 pass
204206
207
+ # Load average
205208 try:
206209 with open("/proc/loadavg") as f:
207210 parts = f.read().split()
208211 load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
209
- except (FileNotFoundError, ValueError):
212
+ except Exception:
210213 pass
211214
212
- # Fallback: use `uptime` command (works on macOS too)
215
+ # CPU usage (two samples, 0.5s apart)
216
+ cpu_info: dict[str, Any] = {}
217
+ try:
218
+ idle1, total1 = _read_cpu_stat()
219
+ await asyncio.sleep(0.5)
220
+ idle2, total2 = _read_cpu_stat()
221
+ total_delta = total2 - total1
222
+ if total_delta > 0:
223
+ usage = round((1 - (idle2 - idle1) / total_delta) * 100, 1)
224
+ else:
225
+ usage = 0.0
226
+ cpu_info = {
227
+ "usage_percent": usage,
228
+ "cores": os.cpu_count() or 1,
229
+ }
230
+ except Exception:
231
+ pass
232
+
233
+ # Memory + Swap
234
+ mem_info = _read_memory()
235
+
236
+ # Fallback for uptime/load if /proc wasn't available
213237 if not uptime_str or not load_str:
214238 result = await run_command(["uptime"])
215239 if result["success"]:
216240 raw = result["output"].strip()
217
- # Parse "up X days, Y:Z, N users, load average: a, b, c"
218241 up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
219242 if up_match:
220243 uptime_str = uptime_str or up_match.group(1).strip()
....@@ -225,4 +248,6 @@
225248 return {
226249 "uptime": uptime_str or "unavailable",
227250 "load": load_str or "unavailable",
251
+ "cpu": cpu_info or None,
252
+ **mem_info,
228253 }
static/index.html
....@@ -13,7 +13,7 @@
1313 #main { flex: 1; display: flex; flex-direction: column; overflow-x: hidden; }
1414 #topbar { background: #111827; border-bottom: 1px solid #1f2937; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; }
1515 #page-content { flex: 1; padding: 1.5rem; overflow-y: auto; }
16
- .breadcrumb { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; }
16
+ .breadcrumb { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; flex-wrap: wrap; }
1717 .breadcrumb a { color: #60a5fa; cursor: pointer; text-decoration: none; }
1818 .breadcrumb a:hover { text-decoration: underline; }
1919 .breadcrumb .sep { color: #4b5563; }
....@@ -22,19 +22,28 @@
2222 .sidebar-logo { padding: 1.25rem 1rem; font-size: 1.125rem; font-weight: 700; color: #f3f4f6; border-bottom: 1px solid #1f2937; display: flex; align-items: center; gap: 0.5rem; }
2323 .sidebar-nav { padding: 0.75rem 0.5rem; flex: 1; }
2424 .sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid #1f2937; font-size: 0.75rem; color: #6b7280; }
25
- .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
26
- .env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
27
- .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
28
- .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
25
+ .grid-auto { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
26
+ .grid-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; }
27
+ .grid-metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
2928 .card-clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; }
3029 .card-clickable:hover { border-color: #60a5fa; transform: translateY(-1px); }
30
+ .stat-tile { cursor: pointer; transition: border-color 0.2s, transform 0.1s; }
31
+ .stat-tile:hover { border-color: #60a5fa; transform: translateY(-2px); }
32
+ .filter-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.625rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
33
+ .filter-badge button { background: none; border: none; color: #60a5fa; cursor: pointer; font-size: 0.875rem; padding: 0; line-height: 1; }
34
+ .filter-badge button:hover { color: #f87171; }
35
+ .view-toggle { display: flex; background: #1f2937; border-radius: 0.375rem; overflow: hidden; border: 1px solid #374151; }
36
+ .view-toggle button { background: none; border: none; color: #6b7280; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
37
+ .view-toggle button.active { background: rgba(59,130,246,0.2); color: #60a5fa; }
38
+ .view-toggle button:hover:not(.active) { color: #d1d5db; }
3139 .mobile-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; }
3240 @media (max-width: 768px) {
3341 #sidebar { position: fixed; left: -240px; top: 0; bottom: 0; z-index: 50; transition: left 0.2s; }
3442 #sidebar.open { left: 0; }
3543 .mobile-overlay.open { display: block; }
3644 .hamburger { display: block; }
37
- .project-grid, .env-grid, .service-grid { grid-template-columns: 1fr; }
45
+ .grid-auto { grid-template-columns: 1fr; }
46
+ .grid-stats { grid-template-columns: repeat(2, 1fr); }
3847 }
3948 </style>
4049 </head>
....@@ -53,7 +62,6 @@
5362
5463 <!-- App Shell -->
5564 <div id="app" style="display:none;">
56
- <!-- Mobile overlay -->
5765 <div id="mobile-overlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
5866
5967 <!-- Sidebar -->
....@@ -66,10 +74,6 @@
6674 <a class="sidebar-link active" data-page="dashboard" onclick="showPage('dashboard')">
6775 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
6876 Dashboard
69
- </a>
70
- <a class="sidebar-link" data-page="services" onclick="showPage('services')">
71
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
72
- Services
7377 </a>
7478 <a class="sidebar-link" data-page="backups" onclick="showPage('backups')">
7579 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
....@@ -91,17 +95,26 @@
9195
9296 <!-- Main Content -->
9397 <div id="main">
94
- <!-- Top bar -->
9598 <div id="topbar">
9699 <button class="hamburger" onclick="toggleSidebar()">&#9776;</button>
97100 <div id="breadcrumbs" class="breadcrumb" style="flex:1;"></div>
101
+ <div id="view-toggle-wrap" style="display:none;">
102
+ <div class="view-toggle">
103
+ <button id="btn-view-cards" class="active" onclick="setViewMode('cards')" title="Card view">
104
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
105
+ Cards
106
+ </button>
107
+ <button id="btn-view-table" onclick="setViewMode('table')" title="Table view">
108
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
109
+ Table
110
+ </button>
111
+ </div>
112
+ </div>
98113 <div style="display:flex;align-items:center;gap:0.75rem;">
99114 <div id="refresh-indicator" class="refresh-ring paused" title="Auto-refresh"></div>
100115 <button class="btn btn-ghost btn-xs" onclick="refreshCurrentPage()" title="Refresh now">Refresh</button>
101116 </div>
102117 </div>
103
-
104
- <!-- Page content -->
105118 <div id="page-content"></div>
106119 </div>
107120 </div>
....@@ -121,7 +134,7 @@
121134 </div>
122135 <div class="modal-footer">
123136 <button class="btn btn-ghost btn-sm" onclick="closeLogModal()">Close</button>
124
- <button class="btn btn-primary btn-sm" id="log-refresh-btn" onclick="refreshLogs()">Refresh</button>
137
+ <button class="btn btn-primary btn-sm" onclick="refreshLogs()">Refresh</button>
125138 </div>
126139 </div>
127140 </div>
static/js/app.js
....@@ -1,7 +1,7 @@
11 'use strict';
22
33 // ============================================================
4
-// OPS Dashboard — Vanilla JS Application
4
+// OPS Dashboard — Vanilla JS Application (v3)
55 // ============================================================
66
77 // ---------------------------------------------------------------------------
....@@ -9,99 +9,95 @@
99 // ---------------------------------------------------------------------------
1010 let allServices = [];
1111 let currentPage = 'dashboard';
12
-let drillLevel = 0; // 0=projects, 1=environments, 2=services
12
+let viewMode = 'cards'; // 'cards' | 'table'
13
+let tableFilter = null; // null | 'healthy' | 'down' | 'project:name' | 'env:name'
14
+let tableFilterLabel = '';
15
+let drillLevel = 0; // 0=projects, 1=environments, 2=services
1316 let drillProject = null;
1417 let drillEnv = null;
1518 let refreshTimer = null;
1619 const REFRESH_INTERVAL = 30000;
1720
1821 // Log modal state
19
-let logModalProject = null;
20
-let logModalEnv = null;
21
-let logModalService = null;
22
+let logCtx = { project: null, env: null, service: null };
2223
2324 // ---------------------------------------------------------------------------
2425 // Helpers
2526 // ---------------------------------------------------------------------------
26
-function formatBytes(bytes) {
27
- if (bytes == null || bytes === '') return '\u2014';
28
- const n = Number(bytes);
27
+function fmtBytes(b) {
28
+ if (b == null) return '\u2014';
29
+ const n = Number(b);
2930 if (isNaN(n) || n === 0) return '0 B';
30
- const k = 1024;
31
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
31
+ const k = 1024, s = ['B', 'KB', 'MB', 'GB', 'TB'];
3232 const i = Math.floor(Math.log(Math.abs(n)) / Math.log(k));
33
- return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i];
33
+ return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + s[i];
3434 }
3535
36
-function timeAgo(dateInput) {
37
- if (!dateInput) return '\u2014';
38
- const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
39
- if (isNaN(date)) return '\u2014';
40
- const secs = Math.floor((Date.now() - date.getTime()) / 1000);
41
- if (secs < 60) return secs + 's ago';
42
- if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
43
- if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
44
- return Math.floor(secs / 86400) + 'd ago';
36
+function esc(str) {
37
+ const d = document.createElement('div');
38
+ d.textContent = str;
39
+ return d.innerHTML;
4540 }
4641
47
-function escapeHtml(str) {
48
- const div = document.createElement('div');
49
- div.textContent = str;
50
- return div.innerHTML;
51
-}
52
-
53
-function statusDotClass(status, health) {
54
- const s = (status || '').toLowerCase();
55
- const h = (health || '').toLowerCase();
56
- if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green';
42
+function dotClass(status, health) {
43
+ const s = (status || '').toLowerCase(), h = (health || '').toLowerCase();
44
+ if (s === 'up' && (h === 'healthy' || !h)) return 'status-dot-green';
5745 if (s === 'up' && h === 'unhealthy') return 'status-dot-red';
5846 if (s === 'up' && h === 'starting') return 'status-dot-yellow';
5947 if (s === 'down' || s === 'exited') return 'status-dot-red';
6048 return 'status-dot-gray';
6149 }
6250
63
-function badgeClass(status, health) {
64
- const s = (status || '').toLowerCase();
65
- const h = (health || '').toLowerCase();
66
- if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';
51
+function badgeCls(status, health) {
52
+ const s = (status || '').toLowerCase(), h = (health || '').toLowerCase();
53
+ if (s === 'up' && (h === 'healthy' || !h)) return 'badge-green';
6754 if (s === 'up' && h === 'unhealthy') return 'badge-red';
6855 if (s === 'up' && h === 'starting') return 'badge-yellow';
6956 if (s === 'down' || s === 'exited') return 'badge-red';
7057 return 'badge-gray';
7158 }
7259
73
-function diskColorClass(pct) {
60
+function diskColor(pct) {
7461 const n = parseInt(pct);
75
- if (isNaN(n)) return 'disk-ok';
7662 if (n >= 90) return 'disk-danger';
7763 if (n >= 75) return 'disk-warn';
7864 return 'disk-ok';
7965 }
8066
67
+function isHealthy(svc) {
68
+ return svc.status === 'Up' && (svc.health === 'healthy' || !svc.health);
69
+}
70
+
71
+function isDown(svc) { return !isHealthy(svc); }
72
+
73
+function filterServices(list) {
74
+ if (!tableFilter) return list;
75
+ if (tableFilter === 'healthy') return list.filter(isHealthy);
76
+ if (tableFilter === 'down') return list.filter(isDown);
77
+ if (tableFilter.startsWith('project:')) {
78
+ const p = tableFilter.slice(8);
79
+ return list.filter(s => s.project === p);
80
+ }
81
+ if (tableFilter.startsWith('env:')) {
82
+ const e = tableFilter.slice(4);
83
+ return list.filter(s => s.env === e);
84
+ }
85
+ return list;
86
+}
87
+
8188 // ---------------------------------------------------------------------------
8289 // Auth
8390 // ---------------------------------------------------------------------------
84
-function getToken() {
85
- return localStorage.getItem('ops_token');
86
-}
91
+function getToken() { return localStorage.getItem('ops_token'); }
8792
8893 function doLogin() {
8994 const input = document.getElementById('login-token');
90
- const errEl = document.getElementById('login-error');
95
+ const err = document.getElementById('login-error');
9196 const token = input.value.trim();
92
- if (!token) {
93
- errEl.textContent = 'Please enter a token';
94
- errEl.style.display = 'block';
95
- return;
96
- }
97
- errEl.style.display = 'none';
98
-
99
- // Validate token by calling the API
97
+ if (!token) { err.textContent = 'Please enter a token'; err.style.display = 'block'; return; }
98
+ err.style.display = 'none';
10099 fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
101
- .then(r => {
102
- if (!r.ok) throw new Error('Invalid token');
103
- return r.json();
104
- })
100
+ .then(r => { if (!r.ok) throw new Error(); return r.json(); })
105101 .then(data => {
106102 localStorage.setItem('ops_token', token);
107103 allServices = data;
....@@ -110,10 +106,7 @@
110106 showPage('dashboard');
111107 startAutoRefresh();
112108 })
113
- .catch(() => {
114
- errEl.textContent = 'Invalid token. Try again.';
115
- errEl.style.display = 'block';
116
- });
109
+ .catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; });
117110 }
118111
119112 function doLogout() {
....@@ -125,46 +118,34 @@
125118 }
126119
127120 // ---------------------------------------------------------------------------
128
-// API Helper
121
+// API
129122 // ---------------------------------------------------------------------------
130123 async function api(path, opts = {}) {
131124 const token = getToken();
132125 const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };
133126 const resp = await fetch(path, { ...opts, headers });
134
- if (resp.status === 401) {
135
- doLogout();
136
- throw new Error('Session expired');
137
- }
138
- if (!resp.ok) {
139
- const body = await resp.text();
140
- throw new Error(body || 'HTTP ' + resp.status);
141
- }
127
+ if (resp.status === 401) { doLogout(); throw new Error('Session expired'); }
128
+ if (!resp.ok) { const b = await resp.text(); throw new Error(b || 'HTTP ' + resp.status); }
142129 const ct = resp.headers.get('content-type') || '';
143
- if (ct.includes('json')) return resp.json();
144
- return resp.text();
130
+ return ct.includes('json') ? resp.json() : resp.text();
145131 }
146132
147
-async function fetchStatus() {
148
- allServices = await api('/api/status/');
149
-}
133
+async function fetchStatus() { allServices = await api('/api/status/'); }
150134
151135 // ---------------------------------------------------------------------------
152
-// Toast Notifications
136
+// Toast
153137 // ---------------------------------------------------------------------------
154
-function toast(message, type = 'info') {
155
- const container = document.getElementById('toast-container');
138
+function toast(msg, type = 'info') {
139
+ const c = document.getElementById('toast-container');
156140 const el = document.createElement('div');
157141 el.className = 'toast toast-' + type;
158
- el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">&times;</span>`;
159
- container.appendChild(el);
160
- setTimeout(() => {
161
- el.classList.add('toast-out');
162
- setTimeout(() => el.remove(), 200);
163
- }, 4000);
142
+ el.innerHTML = `<span>${esc(msg)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">&times;</span>`;
143
+ c.appendChild(el);
144
+ setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 200); }, 4000);
164145 }
165146
166147 // ---------------------------------------------------------------------------
167
-// Sidebar & Navigation
148
+// Navigation
168149 // ---------------------------------------------------------------------------
169150 function toggleSidebar() {
170151 document.getElementById('sidebar').classList.toggle('open');
....@@ -173,16 +154,11 @@
173154
174155 function showPage(page) {
175156 currentPage = page;
176
- drillLevel = 0;
177
- drillProject = null;
178
- drillEnv = null;
157
+ drillLevel = 0; drillProject = null; drillEnv = null;
158
+ if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; }
179159
180
- // Update sidebar active
181
- document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
182
- el.classList.toggle('active', el.dataset.page === page);
183
- });
184
-
185
- // Close mobile sidebar
160
+ document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
161
+ el.classList.toggle('active', el.dataset.page === page));
186162 document.getElementById('sidebar').classList.remove('open');
187163 document.getElementById('mobile-overlay').classList.remove('open');
188164
....@@ -190,12 +166,12 @@
190166 }
191167
192168 function renderPage() {
193
- const content = document.getElementById('page-content');
194
- content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
169
+ const c = document.getElementById('page-content');
170
+ c.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
171
+ updateViewToggle();
195172
196173 switch (currentPage) {
197174 case 'dashboard': renderDashboard(); break;
198
- case 'services': renderServicesFlat(); break;
199175 case 'backups': renderBackups(); break;
200176 case 'system': renderSystem(); break;
201177 case 'restore': renderRestore(); break;
....@@ -204,11 +180,44 @@
204180 }
205181
206182 function refreshCurrentPage() {
207
- showRefreshSpinner();
208
- fetchStatus()
209
- .then(() => renderPage())
210
- .catch(e => toast('Refresh failed: ' + e.message, 'error'))
211
- .finally(() => hideRefreshSpinner());
183
+ showSpin();
184
+ fetchStatus().then(() => renderPage()).catch(e => toast('Refresh failed: ' + e.message, 'error')).finally(hideSpin);
185
+}
186
+
187
+// ---------------------------------------------------------------------------
188
+// View Mode & Filters
189
+// ---------------------------------------------------------------------------
190
+function setViewMode(mode) {
191
+ viewMode = mode;
192
+ if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; }
193
+ updateViewToggle();
194
+ renderDashboard();
195
+}
196
+
197
+function setTableFilter(filter, label) {
198
+ tableFilter = filter;
199
+ tableFilterLabel = label || filter;
200
+ viewMode = 'table';
201
+ updateViewToggle();
202
+ renderDashboard();
203
+}
204
+
205
+function clearFilter() {
206
+ tableFilter = null; tableFilterLabel = '';
207
+ renderDashboard();
208
+}
209
+
210
+function updateViewToggle() {
211
+ const wrap = document.getElementById('view-toggle-wrap');
212
+ const btnCards = document.getElementById('btn-view-cards');
213
+ const btnTable = document.getElementById('btn-view-table');
214
+ if (currentPage === 'dashboard') {
215
+ wrap.style.display = '';
216
+ btnCards.classList.toggle('active', viewMode === 'cards');
217
+ btnTable.classList.toggle('active', viewMode === 'table');
218
+ } else {
219
+ wrap.style.display = 'none';
220
+ }
212221 }
213222
214223 // ---------------------------------------------------------------------------
....@@ -217,321 +226,298 @@
217226 function startAutoRefresh() {
218227 stopAutoRefresh();
219228 refreshTimer = setInterval(() => {
220
- fetchStatus()
221
- .then(() => {
222
- if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
223
- })
224
- .catch(() => {});
229
+ fetchStatus().then(() => { if (currentPage === 'dashboard') renderPage(); }).catch(() => {});
225230 }, REFRESH_INTERVAL);
226231 }
227
-
228
-function stopAutoRefresh() {
229
- if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
230
-}
231
-
232
-function showRefreshSpinner() {
233
- document.getElementById('refresh-indicator').classList.remove('paused');
234
-}
235
-function hideRefreshSpinner() {
236
- document.getElementById('refresh-indicator').classList.add('paused');
237
-}
232
+function stopAutoRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
233
+function showSpin() { document.getElementById('refresh-indicator').classList.remove('paused'); }
234
+function hideSpin() { document.getElementById('refresh-indicator').classList.add('paused'); }
238235
239236 // ---------------------------------------------------------------------------
240237 // Breadcrumbs
241238 // ---------------------------------------------------------------------------
242239 function updateBreadcrumbs() {
243240 const bc = document.getElementById('breadcrumbs');
244
- let html = '';
241
+ let h = '';
245242
246243 if (currentPage === 'dashboard') {
247
- if (drillLevel === 0) {
248
- html = '<span class="current">Dashboard</span>';
244
+ if (viewMode === 'table') {
245
+ h = '<a onclick="setViewMode(\'cards\')">Dashboard</a><span class="sep">/</span>';
246
+ h += '<span class="current">All Services</span>';
247
+ if (tableFilter) {
248
+ h += '&nbsp;<span class="filter-badge">' + esc(tableFilterLabel) +
249
+ ' <button onclick="clearFilter()">&times;</button></span>';
250
+ }
251
+ } else if (drillLevel === 0) {
252
+ h = '<span class="current">Dashboard</span>';
249253 } else if (drillLevel === 1) {
250
- html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>';
254
+ h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + esc(drillProject) + '</span>';
251255 } else if (drillLevel === 2) {
252
- html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + escapeHtml(drillProject) + '</a><span class="sep">/</span><span class="current">' + escapeHtml(drillEnv) + '</span>';
256
+ h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + esc(drillProject) + '</a><span class="sep">/</span><span class="current">' + esc(drillEnv) + '</span>';
253257 }
254258 } else {
255
- const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
256
- html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
259
+ const names = { backups: 'Backups', system: 'System', restore: 'Restore' };
260
+ h = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
257261 }
258
-
259
- bc.innerHTML = html;
262
+ bc.innerHTML = h;
260263 }
261264
262265 function drillBack(level) {
263
- if (level === 0) {
264
- drillLevel = 0;
265
- drillProject = null;
266
- drillEnv = null;
267
- } else if (level === 1) {
268
- drillLevel = 1;
269
- drillEnv = null;
270
- }
266
+ if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; }
267
+ else if (level === 1) { drillLevel = 1; drillEnv = null; }
271268 renderDashboard();
272269 }
273270
274271 // ---------------------------------------------------------------------------
275
-// Dashboard — 3-level Drill
272
+// Dashboard — Cards + Table modes
276273 // ---------------------------------------------------------------------------
277274 function renderDashboard() {
278275 currentPage = 'dashboard';
279
- if (drillLevel === 0) renderProjects();
280
- else if (drillLevel === 1) renderEnvironments();
281
- else if (drillLevel === 2) renderServices();
276
+ if (viewMode === 'table') { renderDashboardTable(); }
277
+ else if (drillLevel === 0) { renderProjects(); }
278
+ else if (drillLevel === 1) { renderEnvironments(); }
279
+ else { renderDrillServices(); }
282280 updateBreadcrumbs();
283281 }
284282
285283 function renderProjects() {
286
- const content = document.getElementById('page-content');
287
- const projects = groupByProject(allServices);
288
-
289
- // Summary stats
290
- const totalUp = allServices.filter(s => s.status === 'Up').length;
284
+ const c = document.getElementById('page-content');
285
+ const projects = groupBy(allServices, 'project');
286
+ const totalUp = allServices.filter(isHealthy).length;
291287 const totalDown = allServices.length - totalUp;
292288
293
- let html = '<div class="page-enter" style="padding:0;">';
289
+ let h = '<div class="page-enter">';
294290
295
- // Summary bar
296
- html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
297
- html += statCard('Projects', Object.keys(projects).length, '#3b82f6');
298
- html += statCard('Services', allServices.length, '#8b5cf6');
299
- html += statCard('Healthy', totalUp, '#10b981');
300
- html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280');
301
- html += '</div>';
291
+ // Stat tiles — clickable
292
+ h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
293
+ h += statTile('Projects', Object.keys(projects).length, '#3b82f6');
294
+ h += statTile('Services', allServices.length, '#8b5cf6', "setViewMode('table')");
295
+ h += statTile('Healthy', totalUp, '#10b981', "setTableFilter('healthy','Healthy')");
296
+ h += statTile('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280', totalDown > 0 ? "setTableFilter('down','Down')" : null);
297
+ h += '</div>';
302298
303299 // Project cards
304
- html += '<div class="project-grid">';
305
- for (const [name, proj] of Object.entries(projects)) {
306
- const upCount = proj.services.filter(s => s.status === 'Up').length;
307
- const total = proj.services.length;
308
- const allUp = upCount === total;
309
- const envNames = [...new Set(proj.services.map(s => s.env))];
310
-
311
- html += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')">
300
+ h += '<div class="grid-auto">';
301
+ for (const [name, svcs] of Object.entries(projects)) {
302
+ const up = svcs.filter(isHealthy).length;
303
+ const total = svcs.length;
304
+ const envs = [...new Set(svcs.map(s => s.env))];
305
+ h += `<div class="card card-clickable" onclick="drillToProject('${esc(name)}')">
312306 <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
313
- <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
314
- <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span>
315
- <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
307
+ <span class="status-dot ${up === total ? 'status-dot-green' : 'status-dot-red'}"></span>
308
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(name)}</span>
309
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} svc</span>
316310 </div>
317311 <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
318
- ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')}
312
+ ${envs.map(e => `<span class="badge badge-blue">${esc(e)}</span>`).join('')}
319313 </div>
320
- <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
314
+ <div style="font-size:0.8125rem;color:#9ca3af;">${up}/${total} healthy</div>
321315 </div>`;
322316 }
323
- html += '</div></div>';
324
- content.innerHTML = html;
317
+ h += '</div></div>';
318
+ c.innerHTML = h;
325319 }
326320
327321 function renderEnvironments() {
328
- const content = document.getElementById('page-content');
329
- const projServices = allServices.filter(s => s.project === drillProject);
330
- const envs = groupByEnv(projServices);
322
+ const c = document.getElementById('page-content');
323
+ const envs = groupBy(allServices.filter(s => s.project === drillProject), 'env');
331324
332
- let html = '<div class="page-enter" style="padding:0;">';
333
- html += '<div class="env-grid">';
334
-
335
- for (const [envName, services] of Object.entries(envs)) {
336
- const upCount = services.filter(s => s.status === 'Up').length;
337
- const total = services.length;
338
- const allUp = upCount === total;
339
-
340
- html += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')">
325
+ let h = '<div class="page-enter"><div class="grid-auto">';
326
+ for (const [envName, svcs] of Object.entries(envs)) {
327
+ const up = svcs.filter(isHealthy).length;
328
+ const total = svcs.length;
329
+ h += `<div class="card card-clickable" onclick="drillToEnv('${esc(envName)}')">
341330 <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
342
- <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
343
- <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span>
344
- <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
331
+ <span class="status-dot ${up === total ? 'status-dot-green' : 'status-dot-red'}"></span>
332
+ <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(envName).toUpperCase()}</span>
333
+ <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} svc</span>
345334 </div>
346335 <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
347
- ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')}
336
+ ${svcs.map(s => `<span class="badge ${badgeCls(s.status, s.health)}">${esc(s.service)}</span>`).join('')}
348337 </div>
349
- <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
338
+ <div style="font-size:0.8125rem;color:#9ca3af;">${up}/${total} healthy</div>
350339 </div>`;
351340 }
352
-
353
- html += '</div></div>';
354
- content.innerHTML = html;
341
+ h += '</div></div>';
342
+ c.innerHTML = h;
355343 }
356344
357
-function renderServices() {
358
- const content = document.getElementById('page-content');
359
- const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
345
+function renderDrillServices() {
346
+ const c = document.getElementById('page-content');
347
+ const svcs = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
348
+ let h = '<div class="page-enter"><div class="grid-auto">';
349
+ for (const svc of svcs) h += serviceCard(svc);
350
+ h += '</div></div>';
351
+ c.innerHTML = h;
352
+}
360353
361
- let html = '<div class="page-enter" style="padding:0;">';
362
- html += '<div class="service-grid">';
354
+function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); }
355
+function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); }
363356
364
- for (const svc of services) {
365
- html += serviceCard(svc);
357
+// ---------------------------------------------------------------------------
358
+// Dashboard — Table View
359
+// ---------------------------------------------------------------------------
360
+function renderDashboardTable() {
361
+ const c = document.getElementById('page-content');
362
+ const svcs = filterServices(allServices);
363
+
364
+ let h = '<div class="page-enter">';
365
+
366
+ // Quick filter row
367
+ h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1rem;">';
368
+ h += filterBtn('All', null);
369
+ h += filterBtn('Healthy', 'healthy');
370
+ h += filterBtn('Down', 'down');
371
+ h += '<span style="color:#374151;">|</span>';
372
+ const projects = [...new Set(allServices.map(s => s.project))].sort();
373
+ for (const p of projects) {
374
+ h += filterBtn(p, 'project:' + p);
375
+ }
376
+ h += '</div>';
377
+
378
+ // Table
379
+ if (svcs.length === 0) {
380
+ h += '<div class="card" style="text-align:center;color:#6b7280;padding:2rem;">No services match this filter.</div>';
381
+ } else {
382
+ h += '<div class="table-wrapper"><table class="ops-table">';
383
+ h += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead><tbody>';
384
+ for (const svc of svcs) {
385
+ h += `<tr>
386
+ <td><a style="color:#60a5fa;cursor:pointer;" onclick="setTableFilter('project:${esc(svc.project)}','${esc(svc.project)}')">${esc(svc.project)}</a></td>
387
+ <td><span class="badge badge-blue">${esc(svc.env)}</span></td>
388
+ <td class="mono">${esc(svc.service)}</td>
389
+ <td><span class="badge ${badgeCls(svc.status, svc.health)}">${esc(svc.status)}</span></td>
390
+ <td>${esc(svc.health || 'n/a')}</td>
391
+ <td>${esc(svc.uptime || 'n/a')}</td>
392
+ <td style="white-space:nowrap;">
393
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${esc(svc.project)}','${esc(svc.env)}','${esc(svc.service)}')">Logs</button>
394
+ <button class="btn btn-warning btn-xs" onclick="restartService('${esc(svc.project)}','${esc(svc.env)}','${esc(svc.service)}')">Restart</button>
395
+ </td>
396
+ </tr>`;
397
+ }
398
+ h += '</tbody></table></div>';
366399 }
367400
368
- html += '</div></div>';
369
- content.innerHTML = html;
370
-}
371
-
372
-function drillToProject(name) {
373
- drillProject = name;
374
- drillLevel = 1;
375
- renderDashboard();
376
-}
377
-
378
-function drillToEnv(name) {
379
- drillEnv = name;
380
- drillLevel = 2;
381
- renderDashboard();
401
+ h += '</div>';
402
+ c.innerHTML = h;
382403 }
383404
384405 // ---------------------------------------------------------------------------
385
-// Service Card (shared component)
406
+// Shared Components
386407 // ---------------------------------------------------------------------------
387408 function serviceCard(svc) {
388
- const proj = escapeHtml(svc.project);
389
- const env = escapeHtml(svc.env);
390
- const service = escapeHtml(svc.service);
391
- const bc = badgeClass(svc.status, svc.health);
392
- const dc = statusDotClass(svc.status, svc.health);
393
-
409
+ const p = esc(svc.project), e = esc(svc.env), s = esc(svc.service);
394410 return `<div class="card">
395411 <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
396
- <span class="status-dot ${dc}"></span>
397
- <span style="font-weight:600;color:#f3f4f6;">${service}</span>
398
- <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span>
412
+ <span class="status-dot ${dotClass(svc.status, svc.health)}"></span>
413
+ <span style="font-weight:600;color:#f3f4f6;">${s}</span>
414
+ <span class="badge ${badgeCls(svc.status, svc.health)}" style="margin-left:auto;">${esc(svc.status)}</span>
399415 </div>
400416 <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
401
- Health: ${escapeHtml(svc.health || 'n/a')} &middot; Uptime: ${escapeHtml(svc.uptime || 'n/a')}
417
+ Health: ${esc(svc.health || 'n/a')} &middot; Uptime: ${esc(svc.uptime || 'n/a')}
402418 </div>
403
- <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
404
- <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
405
- <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
419
+ <div style="display:flex;gap:0.5rem;">
420
+ <button class="btn btn-ghost btn-xs" onclick="viewLogs('${p}','${e}','${s}')">Logs</button>
421
+ <button class="btn btn-warning btn-xs" onclick="restartService('${p}','${e}','${s}')">Restart</button>
406422 </div>
407423 </div>`;
408424 }
409425
410
-function statCard(label, value, color) {
411
- return `<div class="card" style="text-align:center;">
426
+function statTile(label, value, color, onclick) {
427
+ const click = onclick ? ` onclick="${onclick}"` : '';
428
+ const cls = onclick ? ' stat-tile' : '';
429
+ return `<div class="card${cls}" style="text-align:center;"${click}>
412430 <div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div>
413431 <div style="font-size:0.8125rem;color:#9ca3af;">${label}</div>
414432 </div>`;
415433 }
416434
417
-// ---------------------------------------------------------------------------
418
-// Services (flat list page)
419
-// ---------------------------------------------------------------------------
420
-function renderServicesFlat() {
421
- updateBreadcrumbs();
422
- const content = document.getElementById('page-content');
423
-
424
- if (allServices.length === 0) {
425
- content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>';
426
- return;
435
+function filterBtn(label, filter) {
436
+ const active = tableFilter === filter;
437
+ const cls = active ? 'btn btn-primary btn-xs' : 'btn btn-ghost btn-xs';
438
+ if (filter === null) {
439
+ return `<button class="${cls}" onclick="tableFilter=null;tableFilterLabel='';renderDashboard()">${label}</button>`;
427440 }
441
+ return `<button class="${cls}" onclick="setTableFilter('${filter}','${label}')">${label}</button>`;
442
+}
428443
429
- let html = '<div class="page-enter" style="padding:0;">';
430
- html += '<div class="table-wrapper"><table class="ops-table">';
431
- html += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead>';
432
- html += '<tbody>';
433
-
434
- for (const svc of allServices) {
435
- const bc = badgeClass(svc.status, svc.health);
436
- const proj = escapeHtml(svc.project);
437
- const env = escapeHtml(svc.env);
438
- const service = escapeHtml(svc.service);
439
-
440
- html += `<tr>
441
- <td style="font-weight:500;">${proj}</td>
442
- <td><span class="badge badge-blue">${env}</span></td>
443
- <td class="mono">${service}</td>
444
- <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td>
445
- <td>${escapeHtml(svc.health || 'n/a')}</td>
446
- <td>${escapeHtml(svc.uptime || 'n/a')}</td>
447
- <td style="white-space:nowrap;">
448
- <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
449
- <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
450
- </td>
451
- </tr>`;
452
- }
453
-
454
- html += '</tbody></table></div></div>';
455
- content.innerHTML = html;
444
+function metricBar(label, used, total, unit, color) {
445
+ if (!total || total === 0) return '';
446
+ const pct = Math.round(used / total * 100);
447
+ const cls = pct >= 90 ? 'disk-danger' : pct >= 75 ? 'disk-warn' : color || 'disk-ok';
448
+ return `<div class="card">
449
+ <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
450
+ <span style="font-weight:500;color:#f3f4f6;">${label}</span>
451
+ <span style="font-size:0.8125rem;color:#9ca3af;">${fmtBytes(used)} / ${fmtBytes(total)} (${pct}%)</span>
452
+ </div>
453
+ <div class="progress-bar-track">
454
+ <div class="progress-bar-fill ${cls}" style="width:${pct}%;"></div>
455
+ </div>
456
+ </div>`;
456457 }
457458
458459 // ---------------------------------------------------------------------------
459
-// Backups Page
460
+// Backups
460461 // ---------------------------------------------------------------------------
461462 async function renderBackups() {
462463 updateBreadcrumbs();
463
- const content = document.getElementById('page-content');
464
-
464
+ const c = document.getElementById('page-content');
465465 try {
466466 const [local, offsite] = await Promise.all([
467467 api('/api/backups/'),
468468 api('/api/backups/offsite').catch(() => []),
469469 ]);
470470
471
- let html = '<div class="page-enter" style="padding:0;">';
471
+ let h = '<div class="page-enter">';
472472
473473 // Quick backup buttons
474
- html += '<div style="margin-bottom:1.5rem;">';
475
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
476
- html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
477
- for (const proj of ['mdf', 'seriousletter']) {
478
- for (const env of ['dev', 'int', 'prod']) {
479
- html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`;
474
+ h += '<div style="margin-bottom:1.5rem;">';
475
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
476
+ h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
477
+ for (const p of ['mdf', 'seriousletter']) {
478
+ for (const e of ['dev', 'int', 'prod']) {
479
+ h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`;
480480 }
481481 }
482
- html += '</div></div>';
482
+ h += '</div></div>';
483483
484
- // Local backups
485
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
484
+ // Local
485
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
486486 if (local.length === 0) {
487
- html += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
487
+ h += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
488488 } else {
489
- html += '<div class="table-wrapper"><table class="ops-table">';
490
- html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
489
+ h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
491490 for (const b of local) {
492
- html += `<tr>
493
- <td>${escapeHtml(b.project || '')}</td>
494
- <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
495
- <td>${escapeHtml(b.date || b.timestamp || '')}</td>
496
- <td>${escapeHtml(b.size || '')}</td>
497
- <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td>
498
- </tr>`;
491
+ h += `<tr><td>${esc(b.project||'')}</td><td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td><td>${esc(b.date||b.timestamp||'')}</td><td>${esc(b.size||'')}</td><td class="mono" style="font-size:0.75rem;">${esc(b.file||b.files||'')}</td></tr>`;
499492 }
500
- html += '</tbody></table></div>';
493
+ h += '</tbody></table></div>';
501494 }
502495
503
- // Offsite backups
504
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
496
+ // Offsite
497
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
505498 if (offsite.length === 0) {
506
- html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
499
+ h += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
507500 } else {
508
- html += '<div class="table-wrapper"><table class="ops-table">';
509
- html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
501
+ h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
510502 for (const b of offsite) {
511
- html += `<tr>
512
- <td>${escapeHtml(b.project || '')}</td>
513
- <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
514
- <td>${escapeHtml(b.date || b.timestamp || '')}</td>
515
- <td>${escapeHtml(b.size || '')}</td>
516
- </tr>`;
503
+ h += `<tr><td>${esc(b.project||'')}</td><td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td><td>${esc(b.date||b.timestamp||'')}</td><td>${esc(b.size||'')}</td></tr>`;
517504 }
518
- html += '</tbody></table></div>';
505
+ h += '</tbody></table></div>';
519506 }
520507
521
- html += '</div>';
522
- content.innerHTML = html;
508
+ h += '</div>';
509
+ c.innerHTML = h;
523510 } catch (e) {
524
- content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
511
+ c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + esc(e.message) + '</div>';
525512 }
526513 }
527514
528515 // ---------------------------------------------------------------------------
529
-// System Page
516
+// System
530517 // ---------------------------------------------------------------------------
531518 async function renderSystem() {
532519 updateBreadcrumbs();
533
- const content = document.getElementById('page-content');
534
-
520
+ const c = document.getElementById('page-content');
535521 try {
536522 const [disk, health, timers, info] = await Promise.all([
537523 api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
....@@ -540,120 +526,119 @@
540526 api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
541527 ]);
542528
543
- let html = '<div class="page-enter" style="padding:0;">';
529
+ let h = '<div class="page-enter">';
544530
545
- // System info bar
546
- html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
547
- html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');
548
- html += statCard('Load', info.load || 'n/a', '#8b5cf6');
549
- html += '</div>';
531
+ // Resource metrics (CPU, Memory, Swap)
532
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Resources</h2>';
533
+ h += '<div class="grid-metrics" style="margin-bottom:1.5rem;">';
550534
551
- // Disk usage
552
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
553
- if (disk.filesystems && disk.filesystems.length > 0) {
554
- html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">';
555
- for (const fs of disk.filesystems) {
535
+ if (info.cpu) {
536
+ const cpu = info.cpu;
537
+ const cpuPct = cpu.usage_percent || 0;
538
+ const cpuCls = cpuPct >= 90 ? 'disk-danger' : cpuPct >= 75 ? 'disk-warn' : 'disk-ok';
539
+ h += `<div class="card">
540
+ <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
541
+ <span style="font-weight:500;color:#f3f4f6;">CPU</span>
542
+ <span style="font-size:0.8125rem;color:#9ca3af;">${cpuPct}% (${cpu.cores} cores)</span>
543
+ </div>
544
+ <div class="progress-bar-track">
545
+ <div class="progress-bar-fill ${cpuCls}" style="width:${cpuPct}%;"></div>
546
+ </div>
547
+ </div>`;
548
+ }
549
+
550
+ if (info.memory) {
551
+ h += metricBar('Memory', info.memory.used, info.memory.total);
552
+ }
553
+
554
+ if (info.swap && info.swap.total > 0) {
555
+ h += metricBar('Swap', info.swap.used, info.swap.total);
556
+ }
557
+
558
+ h += '</div>';
559
+
560
+ // Quick stats row
561
+ h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
562
+ h += statTile('Uptime', info.uptime || 'n/a', '#3b82f6');
563
+ h += statTile('Load', info.load || 'n/a', '#8b5cf6');
564
+ h += '</div>';
565
+
566
+ // Disk usage — only real filesystems
567
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
568
+ const realFs = (disk.filesystems || []).filter(f => f.filesystem && f.filesystem.startsWith('/dev'));
569
+ if (realFs.length > 0) {
570
+ h += '<div class="grid-metrics" style="margin-bottom:1.5rem;">';
571
+ for (const fs of realFs) {
556572 const pct = parseInt(fs.use_percent) || 0;
557
- html += `<div class="card">
573
+ h += `<div class="card">
558574 <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
559
- <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span>
560
- <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span>
575
+ <span class="mono" style="font-size:0.8125rem;">${esc(fs.mount || fs.filesystem)}</span>
576
+ <span style="font-size:0.8125rem;color:#9ca3af;">${esc(fs.used)} / ${esc(fs.size)} (${esc(fs.use_percent)})</span>
561577 </div>
562578 <div class="progress-bar-track">
563
- <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
579
+ <div class="progress-bar-fill ${diskColor(fs.use_percent)}" style="width:${pct}%;"></div>
564580 </div>
565581 </div>`;
566582 }
567
- html += '</div>';
583
+ h += '</div>';
568584 } else {
569
- html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
585
+ h += '<div class="card" style="color:#6b7280;">No disk data.</div>';
570586 }
571587
572588 // Health checks
573
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
589
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
574590 if (health.checks && health.checks.length > 0) {
575
- html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
576
- for (const c of health.checks) {
577
- const st = (c.status || '').toUpperCase();
591
+ h += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
592
+ for (const ck of health.checks) {
593
+ const st = (ck.status || '').toUpperCase();
578594 const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
579
- html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
580
- <span class="badge ${cls}">${escapeHtml(st)}</span>
581
- <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span>
595
+ h += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
596
+ <span class="badge ${cls}">${esc(st)}</span>
597
+ <span style="font-size:0.875rem;">${esc(ck.check)}</span>
582598 </div>`;
583599 }
584
- html += '</div>';
600
+ h += '</div>';
585601 } else {
586
- html += '<div class="card" style="color:#6b7280;">No health check data.</div>';
602
+ h += '<div class="card" style="color:#6b7280;">No health check data.</div>';
587603 }
588604
589605 // Timers
590
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
606
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
591607 if (timers.timers && timers.timers.length > 0) {
592
- html += '<div class="table-wrapper"><table class="ops-table">';
593
- html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
608
+ h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
594609 for (const t of timers.timers) {
595
- html += `<tr>
596
- <td class="mono">${escapeHtml(t.unit)}</td>
597
- <td>${escapeHtml(t.next)}</td>
598
- <td>${escapeHtml(t.left)}</td>
599
- <td>${escapeHtml(t.last)}</td>
600
- <td>${escapeHtml(t.passed)}</td>
601
- </tr>`;
610
+ h += `<tr><td class="mono">${esc(t.unit)}</td><td>${esc(t.next)}</td><td>${esc(t.left)}</td><td>${esc(t.last)}</td><td>${esc(t.passed)}</td></tr>`;
602611 }
603
- html += '</tbody></table></div>';
612
+ h += '</tbody></table></div>';
604613 } else {
605
- html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
614
+ h += '<div class="card" style="color:#6b7280;">No timers found.</div>';
606615 }
607616
608
- html += '</div>';
609
- content.innerHTML = html;
617
+ h += '</div>';
618
+ c.innerHTML = h;
610619 } catch (e) {
611
- content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
620
+ c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + esc(e.message) + '</div>';
612621 }
613622 }
614623
615624 // ---------------------------------------------------------------------------
616
-// Restore Page
625
+// Restore
617626 // ---------------------------------------------------------------------------
618627 function renderRestore() {
619628 updateBreadcrumbs();
620
- const content = document.getElementById('page-content');
621
-
622
- let html = '<div class="page-enter" style="padding:0;">';
623
- html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
624
- html += '<div class="card" style="max-width:480px;">';
625
-
626
- html += '<div style="margin-bottom:1rem;">';
627
- html += '<label class="form-label">Project</label>';
628
- html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>';
629
- html += '</div>';
630
-
631
- html += '<div style="margin-bottom:1rem;">';
632
- html += '<label class="form-label">Environment</label>';
633
- html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>';
634
- html += '</div>';
635
-
636
- html += '<div style="margin-bottom:1rem;">';
637
- html += '<label class="form-label">Source</label>';
638
- html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>';
639
- html += '</div>';
640
-
641
- html += '<div style="margin-bottom:1rem;">';
642
- html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">';
643
- html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)';
644
- html += '</label>';
645
- html += '</div>';
646
-
647
- html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
648
- html += '</div>';
649
-
650
- html += '<div id="restore-output" style="display:none;margin-top:1rem;">';
651
- html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>';
652
- html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>';
653
- html += '</div>';
654
-
655
- html += '</div>';
656
- content.innerHTML = html;
629
+ const c = document.getElementById('page-content');
630
+ let h = '<div class="page-enter">';
631
+ h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
632
+ h += '<div class="card" style="max-width:480px;">';
633
+ h += '<div style="margin-bottom:1rem;"><label class="form-label">Project</label><select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select></div>';
634
+ h += '<div style="margin-bottom:1rem;"><label class="form-label">Environment</label><select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select></div>';
635
+ h += '<div style="margin-bottom:1rem;"><label class="form-label">Source</label><select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select></div>';
636
+ h += '<div style="margin-bottom:1rem;"><label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;"><input type="checkbox" id="restore-dry" checked> Dry run (preview only)</label></div>';
637
+ h += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
638
+ h += '</div>';
639
+ h += '<div id="restore-output" style="display:none;margin-top:1rem;"><h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3><div id="restore-terminal" class="terminal" style="max-height:400px;"></div></div>';
640
+ h += '</div>';
641
+ c.innerHTML = h;
657642 }
658643
659644 async function startRestore() {
....@@ -661,36 +646,26 @@
661646 const env = document.getElementById('restore-env').value;
662647 const source = document.getElementById('restore-source').value;
663648 const dryRun = document.getElementById('restore-dry').checked;
649
+ if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}?`)) return;
664650
665
- if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return;
666
-
667
- const outputDiv = document.getElementById('restore-output');
668
- const terminal = document.getElementById('restore-terminal');
669
- outputDiv.style.display = 'block';
670
- terminal.textContent = 'Starting restore...\n';
651
+ const out = document.getElementById('restore-output');
652
+ const term = document.getElementById('restore-terminal');
653
+ out.style.display = 'block';
654
+ term.textContent = 'Starting restore...\n';
671655
672656 const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
673
- const evtSource = new EventSource(url);
674
-
675
- evtSource.onmessage = function(e) {
676
- const data = JSON.parse(e.data);
677
- if (data.done) {
678
- evtSource.close();
679
- terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
680
- toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error');
657
+ const es = new EventSource(url);
658
+ es.onmessage = function(e) {
659
+ const d = JSON.parse(e.data);
660
+ if (d.done) {
661
+ es.close();
662
+ term.textContent += d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
663
+ toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
681664 return;
682665 }
683
- if (data.line) {
684
- terminal.textContent += data.line + '\n';
685
- terminal.scrollTop = terminal.scrollHeight;
686
- }
666
+ if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; }
687667 };
688
-
689
- evtSource.onerror = function() {
690
- evtSource.close();
691
- terminal.textContent += '\n--- Connection lost ---\n';
692
- toast('Restore connection lost', 'error');
693
- };
668
+ es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); };
694669 }
695670
696671 // ---------------------------------------------------------------------------
....@@ -698,84 +673,54 @@
698673 // ---------------------------------------------------------------------------
699674 async function restartService(project, env, service) {
700675 if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;
701
-
702676 toast('Restarting ' + service + '...', 'info');
703677 try {
704
- const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
705
- toast(result.message || 'Restarted successfully', 'success');
706
- setTimeout(() => refreshCurrentPage(), 3000);
707
- } catch (e) {
708
- toast('Restart failed: ' + e.message, 'error');
709
- }
678
+ const r = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
679
+ toast(r.message || 'Restarted', 'success');
680
+ setTimeout(refreshCurrentPage, 3000);
681
+ } catch (e) { toast('Restart failed: ' + e.message, 'error'); }
710682 }
711683
712684 async function viewLogs(project, env, service) {
713
- logModalProject = project;
714
- logModalEnv = env;
715
- logModalService = service;
716
-
685
+ logCtx = { project, env, service };
717686 document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`;
718687 document.getElementById('log-modal-content').textContent = 'Loading...';
719688 document.getElementById('log-modal').style.display = 'flex';
720
-
721689 await refreshLogs();
722690 }
723691
724692 async function refreshLogs() {
725
- if (!logModalProject) return;
693
+ if (!logCtx.project) return;
726694 try {
727
- const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`);
728
- const terminal = document.getElementById('log-modal-content');
729
- terminal.textContent = data.logs || 'No logs available.';
730
- terminal.scrollTop = terminal.scrollHeight;
731
- } catch (e) {
732
- document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message;
733
- }
695
+ const d = await api(`/api/services/logs/${logCtx.project}/${logCtx.env}/${logCtx.service}?lines=200`);
696
+ const t = document.getElementById('log-modal-content');
697
+ t.textContent = d.logs || 'No logs available.';
698
+ t.scrollTop = t.scrollHeight;
699
+ } catch (e) { document.getElementById('log-modal-content').textContent = 'Error: ' + e.message; }
734700 }
735701
736702 function closeLogModal() {
737703 document.getElementById('log-modal').style.display = 'none';
738
- logModalProject = null;
739
- logModalEnv = null;
740
- logModalService = null;
704
+ logCtx = { project: null, env: null, service: null };
741705 }
742706
743
-// ---------------------------------------------------------------------------
744
-// Backup Actions
745
-// ---------------------------------------------------------------------------
746707 async function createBackup(project, env) {
747708 if (!confirm(`Create backup for ${project}/${env}?`)) return;
748
- toast('Creating backup for ' + project + '/' + env + '...', 'info');
709
+ toast('Creating backup...', 'info');
749710 try {
750711 await api(`/api/backups/${project}/${env}`, { method: 'POST' });
751712 toast('Backup created for ' + project + '/' + env, 'success');
752713 if (currentPage === 'backups') renderBackups();
753
- } catch (e) {
754
- toast('Backup failed: ' + e.message, 'error');
755
- }
714
+ } catch (e) { toast('Backup failed: ' + e.message, 'error'); }
756715 }
757716
758717 // ---------------------------------------------------------------------------
759
-// Data Grouping
718
+// Utilities
760719 // ---------------------------------------------------------------------------
761
-function groupByProject(services) {
762
- const map = {};
763
- for (const s of services) {
764
- const key = s.project || 'other';
765
- if (!map[key]) map[key] = { name: key, services: [] };
766
- map[key].services.push(s);
767
- }
768
- return map;
769
-}
770
-
771
-function groupByEnv(services) {
772
- const map = {};
773
- for (const s of services) {
774
- const key = s.env || 'default';
775
- if (!map[key]) map[key] = [];
776
- map[key].push(s);
777
- }
778
- return map;
720
+function groupBy(arr, key) {
721
+ const m = {};
722
+ for (const item of arr) { const k = item[key] || 'other'; (m[k] = m[k] || []).push(item); }
723
+ return m;
779724 }
780725
781726 // ---------------------------------------------------------------------------
....@@ -784,12 +729,8 @@
784729 (function init() {
785730 const token = getToken();
786731 if (token) {
787
- // Validate and load
788732 fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
789
- .then(r => {
790
- if (!r.ok) throw new Error('Invalid token');
791
- return r.json();
792
- })
733
+ .then(r => { if (!r.ok) throw new Error(); return r.json(); })
793734 .then(data => {
794735 allServices = data;
795736 document.getElementById('login-overlay').style.display = 'none';
....@@ -797,16 +738,7 @@
797738 showPage('dashboard');
798739 startAutoRefresh();
799740 })
800
- .catch(() => {
801
- localStorage.removeItem('ops_token');
802
- document.getElementById('login-overlay').style.display = 'flex';
803
- });
741
+ .catch(() => { localStorage.removeItem('ops_token'); });
804742 }
805
-
806
- // ESC to close modals
807
- document.addEventListener('keydown', e => {
808
- if (e.key === 'Escape') {
809
- closeLogModal();
810
- }
811
- });
743
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogModal(); });
812744 })();