761 healthoverview (#762)
* Add InfluxDB metrics routes and schema definitions * Add metrics dashboard and related components for performance monitoring * Refactor Healthcheck and Metrics routes to use nested structure with redirects * Bump version to 0.1.49 * precommit fixes
taylor_socfortress committed
Mar 13, 2026 at 16:00 UTC
b068efe7b572292cebd2d19d65f72d03caadec63
16 files changed
+1486
-17
backend/app/connectors/influxdb/routes/metrics.py
new
+158
@@ -0,0 +1,158 @@
1
+from fastapi import APIRouter
2
+from fastapi import Depends
3
+from fastapi import Query
4
+from fastapi import Security
5
+from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+from app.auth.utils import AuthHandler
8
+from app.connectors.influxdb.schema.metrics import HostsResponse
9
+from app.connectors.influxdb.schema.metrics import MetricsResponse
10
+from app.connectors.influxdb.services.metrics import get_cpu_metrics
11
+from app.connectors.influxdb.services.metrics import get_disk_metrics
12
+from app.connectors.influxdb.services.metrics import get_hosts
13
+from app.connectors.influxdb.services.metrics import get_kernel_metrics
14
+from app.connectors.influxdb.services.metrics import get_memory_metrics
15
+from app.connectors.influxdb.services.metrics import get_network_metrics
16
+from app.connectors.influxdb.services.metrics import get_process_metrics
17
+from app.connectors.influxdb.services.metrics import get_summary
18
+from app.db.db_session import get_db
19
+
20
+influxdb_metrics_router = APIRouter()
21
+
22
+
23
+@influxdb_metrics_router.get(
24
+ "/hosts",
25
+ response_model=HostsResponse,
26
+ description="Get available hosts from InfluxDB metrics",
27
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
28
+)
29
+async def get_hosts_route(
30
+ session: AsyncSession = Depends(get_db),
31
+) -> HostsResponse:
32
+ """
33
+ Get a list of all hosts reporting metrics to InfluxDB.
34
+ """
35
+ return await get_hosts(session)
36
+
37
+
38
+@influxdb_metrics_router.get(
39
+ "/summary",
40
+ response_model=MetricsResponse,
41
+ description="Get system summary metrics for a host",
42
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
43
+)
44
+async def get_summary_route(
45
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
46
+ range_h: str = Query("1", description="Number of hours to look back"),
47
+ session: AsyncSession = Depends(get_db),
48
+) -> MetricsResponse:
49
+ """
50
+ Get a summary of system metrics for a host including uptime, CPU count,
51
+ memory, processes, swap, and system load time-series.
52
+ """
53
+ return await get_summary(host, range_h, session)
54
+
55
+
56
+@influxdb_metrics_router.get(
57
+ "/cpu",
58
+ response_model=MetricsResponse,
59
+ description="Get CPU metrics for a host",
60
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
61
+)
62
+async def get_cpu_route(
63
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
64
+ range_h: str = Query("1", description="Number of hours to look back"),
65
+ session: AsyncSession = Depends(get_db),
66
+) -> MetricsResponse:
67
+ """
68
+ Get CPU usage time-series: system, user, iowait, and softirq.
69
+ """
70
+ return await get_cpu_metrics(host, range_h, session)
71
+
72
+
73
+@influxdb_metrics_router.get(
74
+ "/memory",
75
+ response_model=MetricsResponse,
76
+ description="Get memory metrics for a host",
77
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
78
+)
79
+async def get_memory_route(
80
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
81
+ range_h: str = Query("1", description="Number of hours to look back"),
82
+ session: AsyncSession = Depends(get_db),
83
+) -> MetricsResponse:
84
+ """
85
+ Get memory usage time-series and swap info.
86
+ """
87
+ return await get_memory_metrics(host, range_h, session)
88
+
89
+
90
+@influxdb_metrics_router.get(
91
+ "/kernel",
92
+ response_model=MetricsResponse,
93
+ description="Get kernel metrics for a host",
94
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
95
+)
96
+async def get_kernel_route(
97
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
98
+ range_h: str = Query("1", description="Number of hours to look back"),
99
+ session: AsyncSession = Depends(get_db),
100
+) -> MetricsResponse:
101
+ """
102
+ Get kernel metrics: interrupts and processes forked (rate per second).
103
+ """
104
+ return await get_kernel_metrics(host, range_h, session)
105
+
106
+
107
+@influxdb_metrics_router.get(
108
+ "/disks",
109
+ response_model=MetricsResponse,
110
+ description="Get disk metrics for a host",
111
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
112
+)
113
+async def get_disks_route(
114
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
115
+ range_h: str = Query("1", description="Number of hours to look back"),
116
+ session: AsyncSession = Depends(get_db),
117
+) -> MetricsResponse:
118
+ """
119
+ Get disk metrics: total size, usage percent by path, I/O throughput by device,
120
+ and inode usage.
121
+ """
122
+ return await get_disk_metrics(host, range_h, session)
123
+
124
+
125
+@influxdb_metrics_router.get(
126
+ "/processes",
127
+ response_model=MetricsResponse,
128
+ description="Get process metrics for a host",
129
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
130
+)
131
+async def get_processes_route(
132
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
133
+ range_h: str = Query("1", description="Number of hours to look back"),
134
+ session: AsyncSession = Depends(get_db),
135
+) -> MetricsResponse:
136
+ """
137
+ Get process metrics: status time-series (running, sleeping, zombies, stopped, blocked)
138
+ and current counts per state.
139
+ """
140
+ return await get_process_metrics(host, range_h, session)
141
+
142
+
143
+@influxdb_metrics_router.get(
144
+ "/network",
145
+ response_model=MetricsResponse,
146
+ description="Get network metrics for a host",
147
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
148
+)
149
+async def get_network_route(
150
+ host: str = Query(..., description="Hostname to retrieve metrics for"),
151
+ range_h: str = Query("1", description="Number of hours to look back"),
152
+ session: AsyncSession = Depends(get_db),
153
+) -> MetricsResponse:
154
+ """
155
+ Get network metrics: traffic (bytes/sec by interface), TCP established connections,
156
+ and interface errors.
157
+ """
158
+ return await get_network_metrics(host, range_h, session)
backend/app/connectors/influxdb/schema/metrics.py
new
+40
@@ -0,0 +1,40 @@
1
+from datetime import datetime
2
+from typing import Any
3
+from typing import Optional
4
+
5
+from pydantic import BaseModel
6
+from pydantic import Field
7
+
8
+
9
+class TimeSeriesPoint(BaseModel):
10
+ """A single time-series data point."""
11
+
12
+ time: datetime
13
+ field: str
14
+ value: float
15
+ label: Optional[str] = None
16
+
17
+
18
+class TimeSeriesData(BaseModel):
19
+ """Time-series data grouped by field/label."""
20
+
21
+ series: dict[str, list[dict[str, Any]]] = Field(
22
+ default_factory=dict,
23
+ description="Mapping of series name to list of {time, value} points",
24
+ )
25
+
26
+
27
+class MetricsResponse(BaseModel):
28
+ """Generic response for metrics endpoints."""
29
+
30
+ success: bool
31
+ message: str
32
+ data: dict[str, Any] = Field(default_factory=dict)
33
+
34
+
35
+class HostsResponse(BaseModel):
36
+ """Response for listing available hosts."""
37
+
38
+ success: bool
39
+ message: str
40
+ hosts: list[str] = Field(default_factory=list)
backend/app/connectors/influxdb/services/metrics.py
new
+513
@@ -0,0 +1,513 @@
1
+import math
2
+from typing import Any
3
+
4
+from loguru import logger
5
+from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+from app.connectors.influxdb.schema.metrics import HostsResponse
8
+from app.connectors.influxdb.schema.metrics import MetricsResponse
9
+from app.connectors.influxdb.utils.universal import create_influxdb_client
10
+from app.connectors.influxdb.utils.universal import get_influxdb_organization
11
+from app.connectors.utils import get_connector_info_from_db
12
+
13
+RANGE_WINDOWS = {
14
+ "1": "1m",
15
+ "3": "2m",
16
+ "6": "5m",
17
+ "12": "10m",
18
+ "24": "20m",
19
+ "48": "30m",
20
+ "72": "1h",
21
+ "168": "2h",
22
+ "720": "12h",
23
+}
24
+
25
+
26
+async def _get_influxdb_bucket(session: AsyncSession) -> str:
27
+ """
28
+ Read the `connector_extra_data` from the database and return the bucket name.
29
+ The extra data format is: `ORG,BUCKET` e.g. `SOCFORTRESS,telegraf`.
30
+ """
31
+ attributes = await get_connector_info_from_db("InfluxDB", session)
32
+ if attributes is None:
33
+ raise ValueError("No InfluxDB connector found in the database")
34
+ parts = attributes["connector_extra_data"].split(",")
35
+ if len(parts) < 2:
36
+ raise ValueError(
37
+ "Invalid connector_extra_data format for InfluxDB. Expected 'ORG,BUCKET'.",
38
+ )
39
+ return parts[1].strip()
40
+
41
+
42
+def _window(range_h: str) -> str:
43
+ """Return an appropriate aggregateWindow interval for the given hour range."""
44
+ return RANGE_WINDOWS.get(range_h, "5m")
45
+
46
+
47
+def _range_clause(range_h: str) -> str:
48
+ return f"range(start: -{range_h}h)"
49
+
50
+
51
+def _parse_ts_series(
52
+ result,
53
+ label_field: str = "_field",
54
+) -> dict[str, list[dict[str, Any]]]:
55
+ """
56
+ Convert InfluxDB table results into a dict of named series.
57
+ Each series is a list of {time, value} dicts.
58
+ """
59
+ series: dict[str, list[dict[str, Any]]] = {}
60
+ for table in result:
61
+ for record in table.records:
62
+ label = record.values.get(label_field, record.values.get("_field", "unknown"))
63
+ point = {
64
+ "time": record.get_time().isoformat(),
65
+ "value": record.get_value(),
66
+ }
67
+ series.setdefault(label, []).append(point)
68
+ return series
69
+
70
+
71
+def _last_value(result) -> Any:
72
+ """Return the scalar _value from the last record, or None."""
73
+ for table in result:
74
+ for record in table.records:
75
+ return record.get_value()
76
+ return None
77
+
78
+
79
+async def _run_queries(
80
+ influxdb_client,
81
+ org: str,
82
+ queries: dict[str, str],
83
+ ts_keys: set[str] | None = None,
84
+ label_field: str = "_field",
85
+) -> dict[str, Any]:
86
+ """
87
+ Execute a batch of Flux queries and return parsed results.
88
+ Keys in *ts_keys* are parsed as time-series; others as last-value scalars.
89
+ """
90
+ ts_keys = ts_keys or set()
91
+ query_api = influxdb_client.query_api()
92
+ results: dict[str, Any] = {}
93
+ for key, flux in queries.items():
94
+ result = await query_api.query(flux, org=org)
95
+ if key in ts_keys:
96
+ results[key] = _parse_ts_series(result, label_field=label_field)
97
+ else:
98
+ results[key] = _last_value(result)
99
+ return results
100
+
101
+
102
+# ── Hosts ────────────────────────────────────────────────────────────────
103
+
104
+
105
+async def get_hosts(session: AsyncSession) -> HostsResponse:
106
+ """Retrieve the list of unique host tag values from the metrics bucket."""
107
+ connector_info = await get_connector_info_from_db("InfluxDB", session)
108
+ if not connector_info:
109
+ return HostsResponse(success=False, message="InfluxDB connector not found", hosts=[])
110
+
111
+ try:
112
+ bucket = await _get_influxdb_bucket(session)
113
+ org = await get_influxdb_organization()
114
+ except Exception as e:
115
+ logger.error(f"Error getting InfluxDB config: {e}")
116
+ return HostsResponse(success=False, message=str(e), hosts=[])
117
+
118
+ influxdb_client = await create_influxdb_client("InfluxDB")
119
+ try:
120
+ flux = f'import "influxdata/influxdb/schema"\n' f'schema.tagValues(bucket: "{bucket}", tag: "host")'
121
+ query_api = influxdb_client.query_api()
122
+ result = await query_api.query(flux, org=org)
123
+ hosts = sorted(
124
+ {record.get_value() for table in result for record in table.records if record.get_value()},
125
+ )
126
+ return HostsResponse(success=True, message="Successfully retrieved hosts", hosts=hosts)
127
+ except Exception as e:
128
+ logger.error(f"Error fetching hosts: {e}")
129
+ return HostsResponse(success=False, message=f"Error fetching hosts: {e}", hosts=[])
130
+ finally:
131
+ await influxdb_client.close()
132
+
133
+
134
+# ── Summary ──────────────────────────────────────────────────────────────
135
+
136
+
137
+async def get_summary(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
138
+ """Retrieve a summary of system metrics for a given host."""
139
+ try:
140
+ bucket = await _get_influxdb_bucket(session)
141
+ org = await get_influxdb_organization()
142
+ except Exception as e:
143
+ return MetricsResponse(success=False, message=str(e))
144
+
145
+ influxdb_client = await create_influxdb_client("InfluxDB")
146
+ rng = _range_clause(range_h)
147
+ win = _window(range_h)
148
+
149
+ queries = {
150
+ "uptime": f"""from(bucket: "{bucket}") |> {rng}
151
+ |> filter(fn: (r) => r["_measurement"] == "system")
152
+ |> filter(fn: (r) => r["_field"] == "uptime")
153
+ |> filter(fn: (r) => r["host"] == "{host}")
154
+ |> last()""",
155
+ "total_mem": f"""from(bucket: "{bucket}") |> {rng}
156
+ |> filter(fn: (r) => r["_measurement"] == "mem")
157
+ |> filter(fn: (r) => r["_field"] == "total")
158
+ |> filter(fn: (r) => r["host"] == "{host}")
159
+ |> last()""",
160
+ "cpus": f"""from(bucket: "{bucket}") |> {rng}
161
+ |> filter(fn: (r) => r["_measurement"] == "system")
162
+ |> filter(fn: (r) => r["_field"] == "n_cpus")
163
+ |> filter(fn: (r) => r["host"] == "{host}")
164
+ |> last()""",
165
+ "total_processes": f"""from(bucket: "{bucket}") |> {rng}
166
+ |> filter(fn: (r) => r["_measurement"] == "processes")
167
+ |> filter(fn: (r) => r["_field"] == "total")
168
+ |> filter(fn: (r) => r["host"] == "{host}")
169
+ |> last()""",
170
+ "cpu_idle": f"""from(bucket: "{bucket}") |> {rng}
171
+ |> filter(fn: (r) => r["_measurement"] == "cpu")
172
+ |> filter(fn: (r) => r["_field"] == "usage_idle")
173
+ |> filter(fn: (r) => r["cpu"] == "cpu-total")
174
+ |> filter(fn: (r) => r["host"] == "{host}")
175
+ |> last()""",
176
+ "logged_on_users": f"""from(bucket: "{bucket}") |> {rng}
177
+ |> filter(fn: (r) => r["_measurement"] == "system")
178
+ |> filter(fn: (r) => r["_field"] == "n_users")
179
+ |> filter(fn: (r) => r["host"] == "{host}")
180
+ |> last()""",
181
+ "swap_free": f"""from(bucket: "{bucket}") |> {rng}
182
+ |> filter(fn: (r) => r["_measurement"] == "swap")
183
+ |> filter(fn: (r) => r["_field"] == "free")
184
+ |> filter(fn: (r) => r["host"] == "{host}")
185
+ |> last()""",
186
+ "load": f"""from(bucket: "{bucket}") |> {rng}
187
+ |> filter(fn: (r) => r["_measurement"] == "system")
188
+ |> filter(fn: (r) => r["_field"] == "load1" or r["_field"] == "load5" or r["_field"] == "load15")
189
+ |> filter(fn: (r) => r["host"] == "{host}")
190
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
191
+ }
192
+
193
+ try:
194
+ data = await _run_queries(influxdb_client, org, queries, ts_keys={"load"})
195
+ return MetricsResponse(success=True, message="Successfully retrieved summary", data=data)
196
+ except Exception as e:
197
+ logger.error(f"Error fetching summary: {e}")
198
+ return MetricsResponse(success=False, message=f"Error fetching summary: {e}")
199
+ finally:
200
+ await influxdb_client.close()
201
+
202
+
203
+# ── CPU ──────────────────────────────────────────────────────────────────
204
+
205
+
206
+async def get_cpu_metrics(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
207
+ """Retrieve CPU time-series metrics for a given host."""
208
+ try:
209
+ bucket = await _get_influxdb_bucket(session)
210
+ org = await get_influxdb_organization()
211
+ except Exception as e:
212
+ return MetricsResponse(success=False, message=str(e))
213
+
214
+ influxdb_client = await create_influxdb_client("InfluxDB")
215
+ rng = _range_clause(range_h)
216
+ win = _window(range_h)
217
+
218
+ queries = {
219
+ "cpu_usage_system": f"""from(bucket: "{bucket}") |> {rng}
220
+ |> filter(fn: (r) => r["_measurement"] == "cpu")
221
+ |> filter(fn: (r) => r["_field"] == "usage_system")
222
+ |> filter(fn: (r) => r["cpu"] == "cpu-total")
223
+ |> filter(fn: (r) => r["host"] == "{host}")
224
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
225
+ "cpu_usage_user": f"""from(bucket: "{bucket}") |> {rng}
226
+ |> filter(fn: (r) => r["_measurement"] == "cpu")
227
+ |> filter(fn: (r) => r["_field"] == "usage_user")
228
+ |> filter(fn: (r) => r["cpu"] == "cpu-total")
229
+ |> filter(fn: (r) => r["host"] == "{host}")
230
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
231
+ "cpu_iowait": f"""from(bucket: "{bucket}") |> {rng}
232
+ |> filter(fn: (r) => r["_measurement"] == "cpu")
233
+ |> filter(fn: (r) => r["_field"] == "usage_iowait")
234
+ |> filter(fn: (r) => r["cpu"] == "cpu-total")
235
+ |> filter(fn: (r) => r["host"] == "{host}")
236
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
237
+ "cpu_softirq": f"""from(bucket: "{bucket}") |> {rng}
238
+ |> filter(fn: (r) => r["_measurement"] == "cpu")
239
+ |> filter(fn: (r) => r["_field"] == "usage_softirq")
240
+ |> filter(fn: (r) => r["cpu"] == "cpu-total")
241
+ |> filter(fn: (r) => r["host"] == "{host}")
242
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
243
+ }
244
+
245
+ try:
246
+ data = await _run_queries(
247
+ influxdb_client,
248
+ org,
249
+ queries,
250
+ ts_keys=set(queries.keys()),
251
+ )
252
+ return MetricsResponse(success=True, message="Successfully retrieved CPU metrics", data=data)
253
+ except Exception as e:
254
+ logger.error(f"Error fetching CPU metrics: {e}")
255
+ return MetricsResponse(success=False, message=f"Error fetching CPU metrics: {e}")
256
+ finally:
257
+ await influxdb_client.close()
258
+
259
+
260
+# ── Memory ───────────────────────────────────────────────────────────────
261
+
262
+
263
+async def get_memory_metrics(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
264
+ """Retrieve memory metrics for a given host."""
265
+ try:
266
+ bucket = await _get_influxdb_bucket(session)
267
+ org = await get_influxdb_organization()
268
+ except Exception as e:
269
+ return MetricsResponse(success=False, message=str(e))
270
+
271
+ influxdb_client = await create_influxdb_client("InfluxDB")
272
+ rng = _range_clause(range_h)
273
+ win = _window(range_h)
274
+
275
+ queries = {
276
+ "mem_used": f"""from(bucket: "{bucket}") |> {rng}
277
+ |> filter(fn: (r) => r["_measurement"] == "mem")
278
+ |> filter(fn: (r) => r["_field"] == "used" or r["_field"] == "total")
279
+ |> filter(fn: (r) => r["host"] == "{host}")
280
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
281
+ "swap_total": f"""from(bucket: "{bucket}") |> {rng}
282
+ |> filter(fn: (r) => r["_measurement"] == "swap")
283
+ |> filter(fn: (r) => r["_field"] == "total")
284
+ |> filter(fn: (r) => r["host"] == "{host}")
285
+ |> last()""",
286
+ "swap_free": f"""from(bucket: "{bucket}") |> {rng}
287
+ |> filter(fn: (r) => r["_measurement"] == "swap")
288
+ |> filter(fn: (r) => r["_field"] == "free")
289
+ |> filter(fn: (r) => r["host"] == "{host}")
290
+ |> last()""",
291
+ }
292
+
293
+ try:
294
+ data = await _run_queries(influxdb_client, org, queries, ts_keys={"mem_used"})
295
+ return MetricsResponse(success=True, message="Successfully retrieved memory metrics", data=data)
296
+ except Exception as e:
297
+ logger.error(f"Error fetching memory metrics: {e}")
298
+ return MetricsResponse(success=False, message=f"Error fetching memory metrics: {e}")
299
+ finally:
300
+ await influxdb_client.close()
301
+
302
+
303
+# ── Kernel ───────────────────────────────────────────────────────────────
304
+
305
+
306
+async def get_kernel_metrics(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
307
+ """Retrieve kernel metrics for a given host."""
308
+ try:
309
+ bucket = await _get_influxdb_bucket(session)
310
+ org = await get_influxdb_organization()
311
+ except Exception as e:
312
+ return MetricsResponse(success=False, message=str(e))
313
+
314
+ influxdb_client = await create_influxdb_client("InfluxDB")
315
+ rng = _range_clause(range_h)
316
+ win = _window(range_h)
317
+
318
+ queries = {
319
+ "interrupts": f"""from(bucket: "{bucket}") |> {rng}
320
+ |> filter(fn: (r) => r["_measurement"] == "kernel")
321
+ |> filter(fn: (r) => r["_field"] == "interrupts")
322
+ |> filter(fn: (r) => r["host"] == "{host}")
323
+ |> derivative(unit: 1s, nonNegative: true)
324
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
325
+ "processes_forked": f"""from(bucket: "{bucket}") |> {rng}
326
+ |> filter(fn: (r) => r["_measurement"] == "kernel")
327
+ |> filter(fn: (r) => r["_field"] == "processes_forked")
328
+ |> filter(fn: (r) => r["host"] == "{host}")
329
+ |> derivative(unit: 1s, nonNegative: true)
330
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
331
+ }
332
+
333
+ try:
334
+ data = await _run_queries(
335
+ influxdb_client,
336
+ org,
337
+ queries,
338
+ ts_keys=set(queries.keys()),
339
+ )
340
+ return MetricsResponse(success=True, message="Successfully retrieved kernel metrics", data=data)
341
+ except Exception as e:
342
+ logger.error(f"Error fetching kernel metrics: {e}")
343
+ return MetricsResponse(success=False, message=f"Error fetching kernel metrics: {e}")
344
+ finally:
345
+ await influxdb_client.close()
346
+
347
+
348
+# ── Disks ────────────────────────────────────────────────────────────────
349
+
350
+
351
+async def get_disk_metrics(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
352
+ """Retrieve disk metrics for a given host."""
353
+ try:
354
+ bucket = await _get_influxdb_bucket(session)
355
+ org = await get_influxdb_organization()
356
+ except Exception as e:
357
+ return MetricsResponse(success=False, message=str(e))
358
+
359
+ influxdb_client = await create_influxdb_client("InfluxDB")
360
+ rng = _range_clause(range_h)
361
+ win = _window(range_h)
362
+
363
+ queries = {
364
+ "disk_total": f"""from(bucket: "{bucket}") |> {rng}
365
+ |> filter(fn: (r) => r["_measurement"] == "disk")
366
+ |> filter(fn: (r) => r["_field"] == "total")
367
+ |> filter(fn: (r) => r["host"] == "{host}")
368
+ |> last()""",
369
+ "disk_usage": f"""from(bucket: "{bucket}") |> {rng}
370
+ |> filter(fn: (r) => r["_measurement"] == "disk")
371
+ |> filter(fn: (r) => r["_field"] == "used_percent")
372
+ |> filter(fn: (r) => r["host"] == "{host}")
373
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
374
+ "disk_io": f"""from(bucket: "{bucket}") |> {rng}
375
+ |> filter(fn: (r) => r["_measurement"] == "diskio")
376
+ |> filter(fn: (r) => r["_field"] == "read_bytes" or r["_field"] == "write_bytes")
377
+ |> filter(fn: (r) => r["host"] == "{host}")
378
+ |> derivative(unit: 1s, nonNegative: true)
379
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
380
+ "inodes": f"""from(bucket: "{bucket}") |> {rng}
381
+ |> filter(fn: (r) => r["_measurement"] == "disk")
382
+ |> filter(fn: (r) => r["_field"] == "inodes_used" or r["_field"] == "inodes_total")
383
+ |> filter(fn: (r) => r["host"] == "{host}")
384
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
385
+ }
386
+
387
+ try:
388
+ query_api = influxdb_client.query_api()
389
+ data: dict[str, Any] = {}
390
+
391
+ for key, flux in queries.items():
392
+ result = await query_api.query(flux, org=org)
393
+ if key == "disk_total":
394
+ # Sum across all mount points
395
+ total = 0.0
396
+ for table in result:
397
+ for record in table.records:
398
+ try:
399
+ v = float(record.get_value())
400
+ if math.isfinite(v):
401
+ total += v
402
+ except (ValueError, TypeError):
403
+ pass
404
+ data[key] = total
405
+ elif key == "disk_io":
406
+ data[key] = _parse_ts_series(result, label_field="name")
407
+ elif key == "disk_usage" or key == "inodes":
408
+ data[key] = _parse_ts_series(result, label_field="path")
409
+ else:
410
+ data[key] = _parse_ts_series(result)
411
+
412
+ return MetricsResponse(success=True, message="Successfully retrieved disk metrics", data=data)
413
+ except Exception as e:
414
+ logger.error(f"Error fetching disk metrics: {e}")
415
+ return MetricsResponse(success=False, message=f"Error fetching disk metrics: {e}")
416
+ finally:
417
+ await influxdb_client.close()
418
+
419
+
420
+# ── Processes ────────────────────────────────────────────────────────────
421
+
422
+
423
+async def get_process_metrics(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
424
+ """Retrieve process metrics for a given host."""
425
+ try:
426
+ bucket = await _get_influxdb_bucket(session)
427
+ org = await get_influxdb_organization()
428
+ except Exception as e:
429
+ return MetricsResponse(success=False, message=str(e))
430
+
431
+ influxdb_client = await create_influxdb_client("InfluxDB")
432
+ rng = _range_clause(range_h)
433
+ win = _window(range_h)
434
+
435
+ queries = {
436
+ "status": f"""from(bucket: "{bucket}") |> {rng}
437
+ |> filter(fn: (r) => r["_measurement"] == "processes")
438
+ |> filter(fn: (r) => r["_field"] == "running" or r["_field"] == "sleeping" or r["_field"] == "zombies" or r["_field"] == "stopped" or r["_field"] == "blocked")
439
+ |> filter(fn: (r) => r["host"] == "{host}")
440
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
441
+ }
442
+
443
+ # Stat values
444
+ for field in ("running", "sleeping", "unknown", "zombies"):
445
+ queries[
446
+ field
447
+ ] = f"""from(bucket: "{bucket}") |> {rng}
448
+ |> filter(fn: (r) => r["_measurement"] == "processes")
449
+ |> filter(fn: (r) => r["_field"] == "{field}")
450
+ |> filter(fn: (r) => r["host"] == "{host}")
451
+ |> last()"""
452
+
453
+ try:
454
+ data = await _run_queries(influxdb_client, org, queries, ts_keys={"status"})
455
+ return MetricsResponse(success=True, message="Successfully retrieved process metrics", data=data)
456
+ except Exception as e:
457
+ logger.error(f"Error fetching process metrics: {e}")
458
+ return MetricsResponse(success=False, message=f"Error fetching process metrics: {e}")
459
+ finally:
460
+ await influxdb_client.close()
461
+
462
+
463
+# ── Network ──────────────────────────────────────────────────────────────
464
+
465
+
466
+async def get_network_metrics(host: str, range_h: str, session: AsyncSession) -> MetricsResponse:
467
+ """Retrieve network metrics for a given host."""
468
+ try:
469
+ bucket = await _get_influxdb_bucket(session)
470
+ org = await get_influxdb_organization()
471
+ except Exception as e:
472
+ return MetricsResponse(success=False, message=str(e))
473
+
474
+ influxdb_client = await create_influxdb_client("InfluxDB")
475
+ rng = _range_clause(range_h)
476
+ win = _window(range_h)
477
+
478
+ queries = {
479
+ "traffic": f"""from(bucket: "{bucket}") |> {rng}
480
+ |> filter(fn: (r) => r["_measurement"] == "net")
481
+ |> filter(fn: (r) => r["_field"] == "bytes_recv" or r["_field"] == "bytes_sent")
482
+ |> filter(fn: (r) => r["host"] == "{host}")
483
+ |> derivative(unit: 1s, nonNegative: true)
484
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
485
+ "tcp_established": f"""from(bucket: "{bucket}") |> {rng}
486
+ |> filter(fn: (r) => r["_measurement"] == "netstat")
487
+ |> filter(fn: (r) => r["_field"] == "tcp_established")
488
+ |> filter(fn: (r) => r["host"] == "{host}")
489
+ |> last()""",
490
+ "interface_errors": f"""from(bucket: "{bucket}") |> {rng}
491
+ |> filter(fn: (r) => r["_measurement"] == "net")
492
+ |> filter(fn: (r) => r["_field"] == "err_in" or r["_field"] == "err_out")
493
+ |> filter(fn: (r) => r["host"] == "{host}")
494
+ |> aggregateWindow(every: {win}, fn: mean, createEmpty: false)""",
495
+ }
496
+
497
+ try:
498
+ query_api = influxdb_client.query_api()
499
+ data: dict[str, Any] = {}
500
+
501
+ for key, flux in queries.items():
502
+ result = await query_api.query(flux, org=org)
503
+ if key == "tcp_established":
504
+ data[key] = _last_value(result)
505
+ else:
506
+ data[key] = _parse_ts_series(result, label_field="interface")
507
+
508
+ return MetricsResponse(success=True, message="Successfully retrieved network metrics", data=data)
509
+ except Exception as e:
510
+ logger.error(f"Error fetching network metrics: {e}")
511
+ return MetricsResponse(success=False, message=f"Error fetching network metrics: {e}")
512
+ finally:
513
+ await influxdb_client.close()
backend/app/routers/influxdb.py
+3
-1
@@ -1,9 +1,11 @@
1
from fastapi import APIRouter
2
3
from app.connectors.influxdb.routes.alerts import influxdb_alerts_router
4
+from app.connectors.influxdb.routes.metrics import influxdb_metrics_router
5
6
# Instantiate the APIRouter
7
router = APIRouter()
8
8
-# Include the Shuffle related routes
9
+# Include the InfluxDB related routes
10
router.include_router(influxdb_alerts_router, prefix="/influxdb", tags=["InfluxDB"])
11
+router.include_router(influxdb_metrics_router, prefix="/influxdb/metrics", tags=["InfluxDB Metrics"])
backend/app/version/services/version.py
+1
-1
@@ -7,7 +7,7 @@ from loguru import logger
7
from packaging.version import Version
8
9
# Current version - update this with each release
10
-CURRENT_VERSION = "0.1.48"
10
+CURRENT_VERSION = "0.1.49"
11
VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/metrics.ts
new
+51
@@ -0,0 +1,51 @@
1
+import type { FlaskBaseResponse } from "@/types/flask.d"
2
+import type { HostsResponse, MetricsResponse } from "@/types/metrics.d"
3
+import { HttpClient } from "../httpClient"
4
+
5
+export default {
6
+ getHosts() {
7
+ return HttpClient.get<FlaskBaseResponse & HostsResponse>(`/influxdb/metrics/hosts`)
8
+ },
9
+
10
+ getSummary(host: string, rangeH: string = "1") {
11
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/summary`, {
12
+ params: { host, range_h: rangeH }
13
+ })
14
+ },
15
+
16
+ getCpu(host: string, rangeH: string = "1") {
17
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/cpu`, {
18
+ params: { host, range_h: rangeH }
19
+ })
20
+ },
21
+
22
+ getMemory(host: string, rangeH: string = "1") {
23
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/memory`, {
24
+ params: { host, range_h: rangeH }
25
+ })
26
+ },
27
+
28
+ getKernel(host: string, rangeH: string = "1") {
29
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/kernel`, {
30
+ params: { host, range_h: rangeH }
31
+ })
32
+ },
33
+
34
+ getDisks(host: string, rangeH: string = "1") {
35
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/disks`, {
36
+ params: { host, range_h: rangeH }
37
+ })
38
+ },
39
+
40
+ getProcesses(host: string, rangeH: string = "1") {
41
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/processes`, {
42
+ params: { host, range_h: rangeH }
43
+ })
44
+ },
45
+
46
+ getNetwork(host: string, rangeH: string = "1") {
47
+ return HttpClient.get<FlaskBaseResponse & MetricsResponse>(`/influxdb/metrics/network`, {
48
+ params: { host, range_h: rangeH }
49
+ })
50
+ }
51
+}
frontend/src/api/index.ts
+2
@@ -19,6 +19,7 @@ import incidentManagement from "./endpoints/incidentManagement"
19
import integrations from "./endpoints/integrations"
20
import license from "./endpoints/license"
21
import logs from "./endpoints/logs"
22
+import metrics from "./endpoints/metrics"
23
import monitoringAlerts from "./endpoints/monitoringAlerts"
24
import networkConnectors from "./endpoints/networkConnectors"
25
import patchTuesday from "./endpoints/patchTuesday"
@@ -56,6 +57,7 @@ export default {
57
askSocfortress,
58
customers,
59
logs,
60
+ metrics,
61
flow,
62
integrations,
63
monitoringAlerts,
frontend/src/app-layouts/common/Navbar/items.tsx
+31
-12
@@ -363,18 +363,37 @@ export default function getItems(): MenuMixedOption[] {
363
]
364
},
365
{
366
- label: () =>
367
- h(
368
- RouterLink,
369
- {
370
- to: {
371
- name: "Healthcheck"
372
- }
373
- },
374
- { default: () => "Healthcheck" }
375
- ),
376
- key: "Healthcheck",
377
- icon: renderIcon(HealthcheckIcon)
366
+ label: "Healthcheck",
367
+ key: "HealthcheckMenu",
368
+ icon: renderIcon(HealthcheckIcon),
369
+ children: [
370
+ {
371
+ label: () =>
372
+ h(
373
+ RouterLink,
374
+ {
375
+ to: {
376
+ name: "Healthcheck"
377
+ }
378
+ },
379
+ { default: () => "Healthcheck Alerts" }
380
+ ),
381
+ key: "Healthcheck"
382
+ },
383
+ {
384
+ label: () =>
385
+ h(
386
+ RouterLink,
387
+ {
388
+ to: {
389
+ name: "Metrics"
390
+ }
391
+ },
392
+ { default: () => "Metrics Overview" }
393
+ ),
394
+ key: "Metrics"
395
+ }
396
+ ]
397
},
398
{
399
label: "Indices",
frontend/src/components/metrics/MetricsChart.vue
new
+168
@@ -0,0 +1,168 @@
1
+<template>
2
+ <div ref="chartEl" :style="{ height: height + 'px' }"></div>
3
+</template>
4
+
5
+<script setup lang="ts">
6
+import type { ECharts } from "echarts/core"
7
+import type { TimeSeriesData } from "@/types/metrics.d"
8
+import { BarChart, GaugeChart, LineChart } from "echarts/charts"
9
+import { DataZoomComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components"
10
+import { init as echartsInit, use as echartsUse } from "echarts/core"
11
+import { CanvasRenderer } from "echarts/renderers"
12
+import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue"
13
+import { useThemeStore } from "@/stores/theme"
14
+
15
+echartsUse([
16
+ TitleComponent,
17
+ TooltipComponent,
18
+ LegendComponent,
19
+ GridComponent,
20
+ DataZoomComponent,
21
+ LineChart,
22
+ BarChart,
23
+ GaugeChart,
24
+ CanvasRenderer
25
+])
26
+
27
+const COLORS = ["#38bdf8", "#22c55e", "#eab308", "#ef4444", "#a855f7", "#f97316", "#06b6d4", "#ec4899"]
28
+
29
+const props = withDefaults(
30
+ defineProps<{
31
+ title: string
32
+ series: TimeSeriesData
33
+ height?: number
34
+ yAxisName?: string
35
+ formatBytes?: boolean
36
+ }>(),
37
+ {
38
+ height: 250,
39
+ yAxisName: "",
40
+ formatBytes: false
41
+ }
42
+)
43
+
44
+const { title, series, height, yAxisName, formatBytes } = toRefs(props)
45
+
46
+const chartEl = ref<HTMLElement | null>(null)
47
+const chartCtx = ref<ECharts | null>(null)
48
+const style = computed(() => useThemeStore().style)
49
+let resizeObserver: ResizeObserver | null = null
50
+
51
+function fmtBytes(bytes: number): string {
52
+ if (bytes === null || bytes === undefined) return "—"
53
+ const units = ["B", "KB", "MB", "GB", "TB"]
54
+ let i = 0
55
+ let v = Number(bytes)
56
+ while (v >= 1024 && i < units.length - 1) {
57
+ v /= 1024
58
+ i++
59
+ }
60
+ return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
61
+}
62
+
63
+function getOptions() {
64
+ const seriesData = series.value || {}
65
+ const seriesNames = Object.keys(seriesData)
66
+
67
+ const eSeries = seriesNames.map((name, i) => ({
68
+ name,
69
+ type: "line" as const,
70
+ smooth: true,
71
+ symbol: "none",
72
+ lineStyle: { width: 1.5 },
73
+ areaStyle: { opacity: 0.08 },
74
+ itemStyle: { color: COLORS[i % COLORS.length] },
75
+ data: (seriesData[name] || []).map(d => [new Date(d.time).getTime(), d.value])
76
+ }))
77
+
78
+ return {
79
+ backgroundColor: "transparent",
80
+ title: {
81
+ text: title.value,
82
+ textStyle: {
83
+ color: style.value["fg-default-color"],
84
+ fontSize: 13,
85
+ fontWeight: 500,
86
+ fontFamily: style.value["font-family"]
87
+ },
88
+ left: 10,
89
+ top: 5
90
+ },
91
+ tooltip: {
92
+ trigger: "axis",
93
+ backgroundColor: style.value["bg-default-color"],
94
+ borderColor: style.value["primary-color"],
95
+ textStyle: {
96
+ color: style.value["fg-default-color"],
97
+ fontSize: 12,
98
+ fontFamily: style.value["font-family-mono"]
99
+ },
100
+ formatter(params: any[]) {
101
+ let html = `<div style="font-size:11px;color:${style.value["fg-secondary-color"]};margin-bottom:4px">${new Date(params[0].value[0]).toLocaleString()}</div>`
102
+ params.forEach((p: any) => {
103
+ const val = formatBytes.value
104
+ ? fmtBytes(p.value[1])
105
+ : typeof p.value[1] === "number"
106
+ ? p.value[1].toFixed(2)
107
+ : p.value[1]
108
+ html += `<div>${p.marker} ${p.seriesName}: <b>${val}</b></div>`
109
+ })
110
+ return html
111
+ }
112
+ },
113
+ legend: {
114
+ bottom: 0,
115
+ textStyle: { color: style.value["fg-secondary-color"], fontSize: 11 },
116
+ icon: "roundRect",
117
+ itemWidth: 12,
118
+ itemHeight: 3
119
+ },
120
+ grid: {
121
+ left: 60,
122
+ right: 20,
123
+ top: 35,
124
+ bottom: seriesNames.length > 1 ? 40 : 20
125
+ },
126
+ xAxis: {
127
+ type: "time",
128
+ axisLine: { lineStyle: { color: style.value["border-color"] } },
129
+ axisLabel: { color: style.value["fg-secondary-color"], fontSize: 10 },
130
+ splitLine: { show: false }
131
+ },
132
+ yAxis: {
133
+ type: "value",
134
+ name: yAxisName.value,
135
+ nameTextStyle: { color: style.value["fg-secondary-color"], fontSize: 10 },
136
+ axisLine: { lineStyle: { color: style.value["border-color"] } },
137
+ axisLabel: {
138
+ color: style.value["fg-secondary-color"],
139
+ fontSize: 10,
140
+ formatter: formatBytes.value ? (v: number) => fmtBytes(v) : undefined
141
+ },
142
+ splitLine: { lineStyle: { color: style.value["border-color"], opacity: 0.3 } }
143
+ },
144
+ series: eSeries
145
+ }
146
+}
147
+
148
+function renderChart() {
149
+ if (!chartCtx.value) return
150
+ chartCtx.value.setOption(getOptions(), true)
151
+}
152
+
153
+watch([series, style], renderChart)
154
+
155
+onMounted(() => {
156
+ if (!chartEl.value) return
157
+ chartCtx.value = echartsInit(chartEl.value)
158
+ renderChart()
159
+
160
+ resizeObserver = new ResizeObserver(() => chartCtx.value?.resize())
161
+ resizeObserver.observe(chartEl.value)
162
+})
163
+
164
+onBeforeUnmount(() => {
165
+ resizeObserver?.disconnect()
166
+ chartCtx.value?.dispose()
167
+})
168
+</script>
frontend/src/components/metrics/MetricsDashboard.vue
new
+298
@@ -0,0 +1,298 @@
1
+<template>
2
+ <div class="metrics-dashboard">
3
+ <!-- Filters -->
4
+ <div class="header mb-6 flex flex-wrap items-end gap-4">
5
+ <div>
6
+ <label class="mb-1 block text-xs" :style="{ color: 'var(--fg-secondary-color)' }">Host</label>
7
+ <n-select
8
+ v-model:value="selectedHost"
9
+ :options="hostOptions"
10
+ :loading="hostsLoading"
11
+ placeholder="Select host"
12
+ class="w-52!"
13
+ size="small"
14
+ @update:value="refresh"
15
+ />
16
+ </div>
17
+ <div>
18
+ <label class="mb-1 block text-xs" :style="{ color: 'var(--fg-secondary-color)' }">Time Range</label>
19
+ <n-select
20
+ v-model:value="selectedRange"
21
+ :options="rangeOptions"
22
+ class="w-44!"
23
+ size="small"
24
+ @update:value="refresh"
25
+ />
26
+ </div>
27
+ <n-button
28
+ size="small"
29
+ type="primary"
30
+ secondary
31
+ :loading="loading"
32
+ :disabled="!selectedHost"
33
+ @click="refresh"
34
+ >
35
+ <template #icon>
36
+ <Icon :name="RefreshIcon" :size="15" />
37
+ </template>
38
+ </n-button>
39
+ </div>
40
+
41
+ <!-- Empty state -->
42
+ <n-card v-if="!selectedHost" class="p-12 text-center">
43
+ <n-empty description="Select a host to view performance metrics">
44
+ <template #icon>
45
+ <Icon :name="ChartIcon" :size="40" />
46
+ </template>
47
+ </n-empty>
48
+ </n-card>
49
+
50
+ <!-- Loading -->
51
+ <n-spin v-else-if="loading && !loaded" class="flex min-h-80 items-center justify-center" :show="true" />
52
+
53
+ <!-- Dashboard -->
54
+ <div v-else-if="loaded">
55
+ <!-- Summary Section -->
56
+ <section class="mb-8">
57
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">Metrics Summary</h3>
58
+ <div class="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-7">
59
+ <MetricsStatCard label="Uptime" :value="summary.uptime" format="uptime" />
60
+ <MetricsStatCard label="Total Memory" :value="summary.total_mem" format="bytes" />
61
+ <MetricsStatCard label="CPUs" :value="summary.cpus" />
62
+ <MetricsStatCard label="Processes" :value="summary.total_processes" />
63
+ <MetricsStatCard
64
+ label="CPU Idle"
65
+ :value="summary.cpu_idle"
66
+ format="percent"
67
+ :decimals="1"
68
+ :color-thresholds="{ low: 20, mid: 50 }"
69
+ />
70
+ <MetricsStatCard label="Swap Free" :value="summary.swap_free" format="bytes" />
71
+ <MetricsStatCard label="Users Logged In" :value="summary.logged_on_users" />
72
+ </div>
73
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
74
+ <n-card>
75
+ <MetricsGauge :value="summary.cpu_idle ?? 0" title="CPU Idle" :height="220" />
76
+ </n-card>
77
+ <n-card class="lg:col-span-2">
78
+ <MetricsChart title="System Load" :series="summary.load || {}" :height="220" />
79
+ </n-card>
80
+ </div>
81
+ </section>
82
+
83
+ <!-- CPU Section -->
84
+ <section class="mb-8">
85
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">CPU</h3>
86
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
87
+ <n-card>
88
+ <MetricsChart title="CPU System %" :series="cpu.cpu_usage_system || {}" y-axis-name="%" />
89
+ </n-card>
90
+ <n-card>
91
+ <MetricsChart title="CPU User %" :series="cpu.cpu_usage_user || {}" y-axis-name="%" />
92
+ </n-card>
93
+ <n-card>
94
+ <MetricsChart title="I/O Wait %" :series="cpu.cpu_iowait || {}" y-axis-name="%" />
95
+ </n-card>
96
+ <n-card>
97
+ <MetricsChart title="Soft IRQ %" :series="cpu.cpu_softirq || {}" y-axis-name="%" />
98
+ </n-card>
99
+ </div>
100
+ </section>
101
+
102
+ <!-- Memory Section -->
103
+ <section class="mb-8">
104
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">Memory</h3>
105
+ <div class="mb-4 grid grid-cols-2 gap-4">
106
+ <MetricsStatCard label="Swap Total" :value="memory.swap_total" format="bytes" />
107
+ <MetricsStatCard label="Swap Free" :value="memory.swap_free" format="bytes" />
108
+ </div>
109
+ <n-card>
110
+ <MetricsChart
111
+ title="Memory Usage"
112
+ :series="memory.mem_used || {}"
113
+ :height="280"
114
+ y-axis-name="bytes"
115
+ :format-bytes="true"
116
+ />
117
+ </n-card>
118
+ </section>
119
+
120
+ <!-- Kernel Section -->
121
+ <section class="mb-8">
122
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">Kernel</h3>
123
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
124
+ <n-card>
125
+ <MetricsChart title="Interrupts/sec" :series="kernel.interrupts || {}" y-axis-name="/s" />
126
+ </n-card>
127
+ <n-card>
128
+ <MetricsChart
129
+ title="Processes Forked/sec"
130
+ :series="kernel.processes_forked || {}"
131
+ y-axis-name="/s"
132
+ />
133
+ </n-card>
134
+ </div>
135
+ </section>
136
+
137
+ <!-- Disks Section -->
138
+ <section class="mb-8">
139
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">Disks</h3>
140
+ <div class="mb-4">
141
+ <MetricsStatCard label="Total Disk Size" :value="disks.disk_total" format="bytes" />
142
+ </div>
143
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
144
+ <n-card>
145
+ <MetricsChart title="Disk Usage %" :series="disks.disk_usage || {}" y-axis-name="%" />
146
+ </n-card>
147
+ <n-card>
148
+ <MetricsChart
149
+ title="Disk I/O (bytes/s)"
150
+ :series="disks.disk_io || {}"
151
+ y-axis-name="B/s"
152
+ :format-bytes="true"
153
+ />
154
+ </n-card>
155
+ </div>
156
+ </section>
157
+
158
+ <!-- Processes Section -->
159
+ <section class="mb-8">
160
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">Processes</h3>
161
+ <div class="mb-4 grid grid-cols-2 gap-4 md:grid-cols-4">
162
+ <MetricsStatCard label="Running" :value="processes.running" />
163
+ <MetricsStatCard label="Sleeping" :value="processes.sleeping" />
164
+ <MetricsStatCard label="Unknown" :value="processes.unknown" />
165
+ <MetricsStatCard label="Zombies" :value="processes.zombies" />
166
+ </div>
167
+ <n-card>
168
+ <MetricsChart title="Process Status" :series="processes.status || {}" :height="280" />
169
+ </n-card>
170
+ </section>
171
+
172
+ <!-- Network Section -->
173
+ <section class="mb-8">
174
+ <h3 class="mb-4 text-lg font-semibold" :style="{ color: 'var(--primary-color)' }">Network</h3>
175
+ <div class="mb-4">
176
+ <MetricsStatCard label="TCP Sessions Established" :value="network.tcp_established" />
177
+ </div>
178
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
179
+ <n-card>
180
+ <MetricsChart
181
+ title="Network Traffic (bytes/s)"
182
+ :series="network.traffic || {}"
183
+ :height="280"
184
+ y-axis-name="B/s"
185
+ :format-bytes="true"
186
+ />
187
+ </n-card>
188
+ <n-card>
189
+ <MetricsChart title="Interface Errors" :series="network.interface_errors || {}" :height="280" />
190
+ </n-card>
191
+ </div>
192
+ </section>
193
+ </div>
194
+ </div>
195
+</template>
196
+
197
+<script setup lang="ts">
198
+import { NButton, NCard, NEmpty, NSelect, NSpin, useMessage } from "naive-ui"
199
+import { computed, onBeforeMount, ref } from "vue"
200
+import Api from "@/api"
201
+import Icon from "@/components/common/Icon.vue"
202
+import MetricsChart from "./MetricsChart.vue"
203
+import MetricsGauge from "./MetricsGauge.vue"
204
+import MetricsStatCard from "./MetricsStatCard.vue"
205
+
206
+const RefreshIcon = "carbon:update-now"
207
+const ChartIcon = "carbon:chart-line"
208
+
209
+const message = useMessage()
210
+const loading = ref(false)
211
+const loaded = ref(false)
212
+const hostsLoading = ref(false)
213
+const hosts = ref<string[]>([])
214
+const selectedHost = ref<string | null>(null)
215
+const selectedRange = ref("1")
216
+
217
+const summary = ref<Record<string, any>>({})
218
+const cpu = ref<Record<string, any>>({})
219
+const memory = ref<Record<string, any>>({})
220
+const kernel = ref<Record<string, any>>({})
221
+const disks = ref<Record<string, any>>({})
222
+const processes = ref<Record<string, any>>({})
223
+const network = ref<Record<string, any>>({})
224
+
225
+const hostOptions = computed(() => hosts.value.map(h => ({ label: h, value: h })))
226
+
227
+const rangeOptions = [
228
+ { label: "Last 1 Hour", value: "1" },
229
+ { label: "Last 3 Hours", value: "3" },
230
+ { label: "Last 6 Hours", value: "6" },
231
+ { label: "Last 12 Hours", value: "12" },
232
+ { label: "Last 24 Hours", value: "24" },
233
+ { label: "Last 2 Days", value: "48" },
234
+ { label: "Last 7 Days", value: "168" }
235
+]
236
+
237
+async function loadHosts() {
238
+ hostsLoading.value = true
239
+ try {
240
+ const res = await Api.metrics.getHosts()
241
+ if (res.data.success) {
242
+ hosts.value = res.data.hosts || []
243
+ } else {
244
+ message.warning(res.data?.message || "Failed to load hosts")
245
+ }
246
+ } catch (err: any) {
247
+ message.error(err.response?.data?.message || "Failed to load hosts")
248
+ } finally {
249
+ hostsLoading.value = false
250
+ }
251
+}
252
+
253
+async function refresh() {
254
+ if (!selectedHost.value) return
255
+ loading.value = true
256
+
257
+ const host = selectedHost.value
258
+ const range = selectedRange.value
259
+
260
+ try {
261
+ const [sumRes, cpuRes, memRes, kerRes, dskRes, prcRes, netRes] = await Promise.all([
262
+ Api.metrics.getSummary(host, range),
263
+ Api.metrics.getCpu(host, range),
264
+ Api.metrics.getMemory(host, range),
265
+ Api.metrics.getKernel(host, range),
266
+ Api.metrics.getDisks(host, range),
267
+ Api.metrics.getProcesses(host, range),
268
+ Api.metrics.getNetwork(host, range)
269
+ ])
270
+
271
+ for (const res of [sumRes, cpuRes, memRes, kerRes, dskRes, prcRes, netRes]) {
272
+ if (!res.data.success) {
273
+ message.warning(res.data?.message || "Error fetching metrics")
274
+ loading.value = false
275
+ return
276
+ }
277
+ }
278
+
279
+ summary.value = sumRes.data.data || {}
280
+ cpu.value = cpuRes.data.data || {}
281
+ memory.value = memRes.data.data || {}
282
+ kernel.value = kerRes.data.data || {}
283
+ disks.value = dskRes.data.data || {}
284
+ processes.value = prcRes.data.data || {}
285
+ network.value = netRes.data.data || {}
286
+
287
+ loaded.value = true
288
+ } catch (err: any) {
289
+ message.error(err.response?.data?.message || "Error fetching metrics")
290
+ } finally {
291
+ loading.value = false
292
+ }
293
+}
294
+
295
+onBeforeMount(() => {
296
+ loadHosts()
297
+})
298
+</script>
frontend/src/components/metrics/MetricsGauge.vue
new
+96
@@ -0,0 +1,96 @@
1
+<template>
2
+ <div ref="gaugeEl" :style="{ height: height + 'px' }"></div>
3
+</template>
4
+
5
+<script setup lang="ts">
6
+import type { ECharts } from "echarts/core"
7
+import { GaugeChart } from "echarts/charts"
8
+import { GridComponent, TooltipComponent } from "echarts/components"
9
+import { init as echartsInit, use as echartsUse } from "echarts/core"
10
+import { CanvasRenderer } from "echarts/renderers"
11
+import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue"
12
+import { useThemeStore } from "@/stores/theme"
13
+
14
+echartsUse([TooltipComponent, GridComponent, GaugeChart, CanvasRenderer])
15
+
16
+const props = withDefaults(
17
+ defineProps<{
18
+ value: number
19
+ title?: string
20
+ height?: number
21
+ }>(),
22
+ {
23
+ title: "CPU Idle",
24
+ height: 220
25
+ }
26
+)
27
+
28
+const { value, title, height } = toRefs(props)
29
+
30
+const gaugeEl = ref<HTMLElement | null>(null)
31
+const chartCtx = ref<ECharts | null>(null)
32
+const style = computed(() => useThemeStore().style)
33
+let resizeObserver: ResizeObserver | null = null
34
+
35
+function getOptions() {
36
+ return {
37
+ backgroundColor: "transparent",
38
+ series: [
39
+ {
40
+ type: "gauge",
41
+ startAngle: 220,
42
+ endAngle: -40,
43
+ min: 0,
44
+ max: 100,
45
+ detail: {
46
+ formatter: "{value}%",
47
+ fontSize: 18,
48
+ color: style.value["fg-default-color"],
49
+ offsetCenter: [0, "70%"]
50
+ },
51
+ data: [{ value: Math.round(value.value * 10) / 10, name: title.value }],
52
+ axisLine: {
53
+ lineStyle: {
54
+ width: 15,
55
+ color: [
56
+ [0.2, "#ef4444"],
57
+ [0.5, "#eab308"],
58
+ [1, "#22c55e"]
59
+ ]
60
+ }
61
+ },
62
+ pointer: { length: "60%", width: 4, itemStyle: { color: style.value["primary-color"] } },
63
+ axisTick: { show: false },
64
+ splitLine: { length: 10, lineStyle: { color: style.value["border-color"] } },
65
+ axisLabel: { color: style.value["fg-secondary-color"], fontSize: 10 },
66
+ title: {
67
+ color: style.value["fg-secondary-color"],
68
+ fontSize: 12,
69
+ offsetCenter: [0, "90%"]
70
+ }
71
+ }
72
+ ]
73
+ }
74
+}
75
+
76
+function renderChart() {
77
+ if (!chartCtx.value) return
78
+ chartCtx.value.setOption(getOptions(), true)
79
+}
80
+
81
+watch([value, style], renderChart)
82
+
83
+onMounted(() => {
84
+ if (!gaugeEl.value) return
85
+ chartCtx.value = echartsInit(gaugeEl.value)
86
+ renderChart()
87
+
88
+ resizeObserver = new ResizeObserver(() => chartCtx.value?.resize())
89
+ resizeObserver.observe(gaugeEl.value)
90
+})
91
+
92
+onBeforeUnmount(() => {
93
+ resizeObserver?.disconnect()
94
+ chartCtx.value?.dispose()
95
+})
96
+</script>
frontend/src/components/metrics/MetricsStatCard.vue
new
+74
@@ -0,0 +1,74 @@
1
+<template>
2
+ <n-card content-class="p-0!">
3
+ <div class="stat-card flex flex-col items-center justify-center p-4 text-center">
4
+ <div class="label text-xs" :style="{ color: 'var(--fg-secondary-color)' }">{{ label }}</div>
5
+ <div class="value mt-1 text-xl font-bold" :class="colorClass">
6
+ {{ displayValue }}
7
+ </div>
8
+ </div>
9
+ </n-card>
10
+</template>
11
+
12
+<script setup lang="ts">
13
+import { NCard } from "naive-ui"
14
+import { computed, toRefs } from "vue"
15
+
16
+const props = withDefaults(
17
+ defineProps<{
18
+ label: string
19
+ value: number | null | undefined
20
+ format?: "number" | "bytes" | "uptime" | "percent"
21
+ decimals?: number
22
+ colorThresholds?: { low: number; mid: number }
23
+ }>(),
24
+ {
25
+ format: "number",
26
+ decimals: 0
27
+ }
28
+)
29
+
30
+const { label, value, format, decimals, colorThresholds } = toRefs(props)
31
+
32
+function formatBytes(bytes: number): string {
33
+ const units = ["B", "KB", "MB", "GB", "TB"]
34
+ let i = 0
35
+ let v = bytes
36
+ while (v >= 1024 && i < units.length - 1) {
37
+ v /= 1024
38
+ i++
39
+ }
40
+ return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
41
+}
42
+
43
+function formatUptime(seconds: number): string {
44
+ const d = Math.floor(seconds / 86400)
45
+ const h = Math.floor((seconds % 86400) / 3600)
46
+ const m = Math.floor((seconds % 3600) / 60)
47
+ if (d > 0) return `${d}d ${h}h ${m}m`
48
+ if (h > 0) return `${h}h ${m}m`
49
+ return `${m}m`
50
+}
51
+
52
+const displayValue = computed(() => {
53
+ if (value.value === null || value.value === undefined) return "—"
54
+ const v = Number(value.value)
55
+ switch (format.value) {
56
+ case "bytes":
57
+ return formatBytes(v)
58
+ case "uptime":
59
+ return formatUptime(v)
60
+ case "percent":
61
+ return `${v.toFixed(decimals.value)}%`
62
+ default:
63
+ return v.toFixed(decimals.value)
64
+ }
65
+})
66
+
67
+const colorClass = computed(() => {
68
+ if (!colorThresholds?.value || value.value === null || value.value === undefined) return ""
69
+ const v = Number(value.value)
70
+ if (v < colorThresholds.value.low) return "text-red-400"
71
+ if (v < colorThresholds.value.mid) return "text-yellow-400"
72
+ return "text-green-400"
73
+})
74
+</script>
frontend/src/composables/useNavigation.ts
+5
@@ -47,6 +47,10 @@ export function useNavigation() {
47
return routerConstructor({ name: "Healthcheck" })
48
}
49
50
+ function routeMetrics() {
51
+ return routerConstructor({ name: "Metrics" })
52
+ }
53
+
54
function routeGraylogMetrics() {
55
return routerConstructor({ name: "Graylog-Metrics" })
56
}
@@ -91,6 +95,7 @@ export function useNavigation() {
95
routeIndex,
96
routeLicense,
97
routeHealthcheck,
98
+ routeMetrics,
99
routeGraylogMetrics,
100
routeGraylogManagement,
101
routeSocAlerts,
frontend/src/router/index.ts
+19
-3
@@ -259,9 +259,25 @@ const router = createRouter({
259
},
260
{
261
path: "/healthcheck",
262
- name: "Healthcheck",
263
- component: () => import("@/views/Healthcheck.vue"),
264
- meta: { title: "Healthcheck", auth: true, roles: RouteRole.All }
262
+ redirect: "/healthcheck/alerts",
263
+ meta: {
264
+ auth: true,
265
+ roles: RouteRole.All
266
+ },
267
+ children: [
268
+ {
269
+ path: "alerts",
270
+ name: "Healthcheck",
271
+ component: () => import("@/views/Healthcheck.vue"),
272
+ meta: { title: "Healthcheck Alerts" }
273
+ },
274
+ {
275
+ path: "metrics",
276
+ name: "Metrics",
277
+ component: () => import("@/views/Metrics.vue"),
278
+ meta: { title: "Metrics Overview" }
279
+ }
280
+ ]
281
},
282
{
283
path: "/customers",
frontend/src/types/metrics.d.ts
new
+18
@@ -0,0 +1,18 @@
1
+export interface MetricsResponse {
2
+ success: boolean
3
+ message: string
4
+ data: Record<string, any>
5
+}
6
+
7
+export interface HostsResponse {
8
+ success: boolean
9
+ message: string
10
+ hosts: string[]
11
+}
12
+
13
+export interface TimeSeriesPoint {
14
+ time: string
15
+ value: number
16
+}
17
+
18
+export type TimeSeriesData = Record<string, TimeSeriesPoint[]>
frontend/src/views/Metrics.vue
new
+9
@@ -0,0 +1,9 @@
1
+<template>
2
+ <div class="page">
3
+ <MetricsDashboard />
4
+ </div>
5
+</template>
6
+
7
+<script setup lang="ts">
8
+import MetricsDashboard from "@/components/metrics/MetricsDashboard.vue"
9
+</script>