@cryptotaxi247 / CoPilot / commits / b068efe7

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>