Skip to content

Commit 4913c53

Browse files
committed
Implement limits in the Rest API
1 parent b3911a4 commit 4913c53

37 files changed

Lines changed: 2829 additions & 887 deletions

.claude/skills/SKILL-actions.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,19 @@ class WebhookAction(GlancesActionBase):
5050
Config: [mem] critical_webhook=https://hooks.example.com/alert
5151
"""
5252

53-
action_name = "webhook" # → key suffix in glances.conf
54-
requires = [] # → optional Python module names
53+
action_name = "webhook" # → key suffix in glances.conf
54+
requires = [] # → optional Python module names
5555

5656
async def execute(
5757
self,
5858
plugin_name: str,
59-
level: str, # "careful" | "warning" | "critical"
60-
context: dict, # plugin.get_export() + built-in vars
61-
action_value: str, # raw value from glances.conf
62-
repeat: bool = False, # True if the alert is repeating
59+
level: str, # "careful" | "warning" | "critical"
60+
context: dict, # plugin.get_export() + built-in vars
61+
action_value: str, # raw value from glances.conf
62+
repeat: bool = False, # True if the alert is repeating
6363
) -> None:
64-
import httpx # lazy import — already a v5 core dep
64+
import httpx # lazy import — already a v5 core dep
65+
6566
async with httpx.AsyncClient() as client:
6667
await client.post(action_value, json=context, timeout=5.0)
6768
```
@@ -83,6 +84,7 @@ class AppriseAction(GlancesActionBase):
8384

8485
async def execute(self, plugin_name, level, context, action_value, repeat=False):
8586
import apprise
87+
8688
...
8789
```
8890

.claude/skills/SKILL-config.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ explicitly.
4545
```python
4646
config = GlancesConfigV5()
4747

48-
refresh: int = config.get("global", "refresh_time", 2)
49-
api_doc: bool = config.get("outputs", "api_doc", True)
50-
hosts: list = config.get("outputs", "webui_allowed_hosts", [])
51-
host: str = config.get("influxdb", "host", "localhost")
52-
ratio: float = config.get("foo", "ratio", 1.0)
48+
refresh: int = config.get("global", "refresh_time", 2)
49+
api_doc: bool = config.get("outputs", "api_doc", True)
50+
hosts: list = config.get("outputs", "webui_allowed_hosts", [])
51+
host: str = config.get("influxdb", "host", "localhost")
52+
ratio: float = config.get("foo", "ratio", 1.0)
5353
```
5454

5555
Supported types: `str`, `int`, `float`, `bool`, `list[str]`. `dict` is not
@@ -97,8 +97,8 @@ uri
9797
The match is intentionally permissive — over-redact rather than under-redact.
9898

9999
```python
100-
config.as_dict() # {"influxdb": {"password": "secret123"}}
101-
config.as_dict_secure() # {"influxdb": {"password": "***"}}
100+
config.as_dict() # {"influxdb": {"password": "secret123"}}
101+
config.as_dict_secure() # {"influxdb": {"password": "***"}}
102102
```
103103

104104
## Hot-reload
@@ -109,7 +109,7 @@ hook only — there is no automatic polling.
109109
```python
110110
config = GlancesConfigV5(cli_config_path="/path/to/conf")
111111
# ... user edits the file ...
112-
config.reload() # picks up the changes
112+
config.reload() # picks up the changes
113113
```
114114

115115
> **TODO Phase 4** — add an `mtime` polling task (every 5 s) that calls

.claude/skills/SKILL-exporter.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,7 @@ async support) must be wrapped with `asyncio.to_thread()`.
5353
class GlancesExportBase(ABC):
5454
export_name: ClassVar[str] = ""
5555

56-
def __init__(self, config: GlancesConfigV5, store: StatsStoreV5) -> None:
57-
...
56+
def __init__(self, config: GlancesConfigV5, store: StatsStoreV5) -> None: ...
5857

5958
@abstractmethod
6059
async def update(self, plugins: list[GlancesPluginBase]) -> None:

.claude/skills/SKILL-plugin.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,12 @@ class NetworkPlugin(GlancesPluginBase[list]):
6666
"primary_key": True,
6767
},
6868
"rx": {"description": "Bytes received.", "unit": "bytespers"},
69-
"tx": {"description": "Bytes sent.", "unit": "bytespers"},
69+
"tx": {"description": "Bytes sent.", "unit": "bytespers"},
7070
}
7171

7272
async def _grab_stats(self) -> list:
7373
counters = await asyncio.to_thread(psutil.net_io_counters, pernic=True)
74-
return [
75-
{"interface_name": iface, "rx": c.bytes_recv, "tx": c.bytes_sent}
76-
for iface, c in counters.items()
77-
]
74+
return [{"interface_name": iface, "rx": c.bytes_recv, "tx": c.bytes_sent} for iface, c in counters.items()]
7875
```
7976

8077
## The `update()` pipeline (architecture §3.1)
@@ -325,6 +322,7 @@ The module exports a pure `render` function:
325322
```python
326323
from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row
327324

325+
328326
def render(payload: dict, fields_desc: dict) -> list[Row]:
329327
"""Build the plugin's TUI block from its current StatsStore payload."""
330328
...

.claude/skills/SKILL-rest-api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,10 @@ Pattern:
124124
from fastapi.testclient import TestClient
125125
from glances.webserver_v5 import build_app, register_plugin
126126

127+
127128
def test_my_route(config, store):
128129
plugin = FakePlugin(store, config)
129-
asyncio.run(plugin.update()) # populate the store
130+
asyncio.run(plugin.update()) # populate the store
130131
app = build_app(config=config, store=store)
131132
register_plugin(app, plugin)
132133
with TestClient(app) as client:

docs/architecture/glances-v5-architecture-decisions.md

Lines changed: 40 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ The `update()` method structure is derived directly from the [arena prototype](h
204204
async def update(self) -> None:
205205
# Orchestration pipeline. Implemented in base class. Never overridden.
206206
try:
207-
self._stats_previous = self._stats # 1. save previous cycle for rate computation
208-
await self._grab_stats() # 2. collect raw data (psutil via asyncio.to_thread)
209-
self._add_metadata() # 3. add time_since_update and other metadata
210-
self._transform() # 4. transformation pipeline (see below)
207+
self._stats_previous = self._stats # 1. save previous cycle for rate computation
208+
await self._grab_stats() # 2. collect raw data (psutil via asyncio.to_thread)
209+
self._add_metadata() # 3. add time_since_update and other metadata
210+
self._transform() # 4. transformation pipeline (see below)
211211
await self.store.set(self.plugin_name, self._stats) # 5. write to StatsStore
212212
except Exception as e:
213213
logger.warning("Plugin %s update failed: %s", self.plugin_name, e)
@@ -313,9 +313,9 @@ The nested shape — chosen over a flat string for v5 — keeps every consumer s
313313
"total": 16000000000,
314314
# ...
315315
"_levels": {
316-
"percent": {"level": "warning", "prominent": True},
317-
"swap_percent": {"level": "ok", "prominent": False},
318-
}
316+
"percent": {"level": "warning", "prominent": True},
317+
"swap_percent": {"level": "ok", "prominent": False},
318+
},
319319
}
320320
```
321321

@@ -325,18 +325,18 @@ The nested shape — chosen over a flat string for v5 — keeps every consumer s
325325
{
326326
"data": [
327327
{"interface_name": "eth0", "rx": 1200, "tx": 300},
328-
{"interface_name": "lo", "rx": 0, "tx": 0},
328+
{"interface_name": "lo", "rx": 0, "tx": 0},
329329
],
330-
"_levels": { # indexed by primary key, not inline
330+
"_levels": { # indexed by primary key, not inline
331331
"eth0": {
332332
"rx": {"level": "warning", "prominent": True},
333-
"tx": {"level": "ok", "prominent": True},
333+
"tx": {"level": "ok", "prominent": True},
334334
},
335-
"lo": {
336-
"rx": {"level": "ok", "prominent": True},
337-
"tx": {"level": "ok", "prominent": True},
335+
"lo": {
336+
"rx": {"level": "ok", "prominent": True},
337+
"tx": {"level": "ok", "prominent": True},
338338
},
339-
}
339+
},
340340
}
341341
```
342342

@@ -354,15 +354,15 @@ Primary key is declared in `fields_description` with `"primary_key": True` on th
354354
**Alert event shape** (Phase 1.4):
355355
```python
356356
{
357-
"ts": "2026-05-04T12:34:56+00:00", # ISO 8601, UTC
358-
"plugin": "network",
359-
"key": "eth0", # pk_value for collections; None for scalars
360-
"field": "bytes_recv",
361-
"level": "warning", # ok | careful | warning | critical
362-
"previous_level": "ok", # transition source
363-
"value": 53125000.0,
364-
"prominent": True, # copied from fields_description
365-
"hostname": "myhost", # server hostname (client/server scope)
357+
"ts": "2026-05-04T12:34:56+00:00", # ISO 8601, UTC
358+
"plugin": "network",
359+
"key": "eth0", # pk_value for collections; None for scalars
360+
"field": "bytes_recv",
361+
"level": "warning", # ok | careful | warning | critical
362+
"previous_level": "ok", # transition source
363+
"value": 53125000.0,
364+
"prominent": True, # copied from fields_description
365+
"hostname": "myhost", # server hostname (client/server scope)
366366
}
367367
```
368368

@@ -463,18 +463,18 @@ glances/actions/
463463
**GlancesActionBase contract:**
464464
```python
465465
class GlancesActionBase(ABC):
466-
action_name: str = "" # key suffix in glances.conf
467-
requires: list[str] = [] # optional Python dependencies
466+
action_name: str = "" # key suffix in glances.conf
467+
requires: list[str] = [] # optional Python dependencies
468468

469-
def is_available(self) -> bool: ... # False if requires missing
469+
def is_available(self) -> bool: ... # False if requires missing
470470

471471
@abstractmethod
472472
async def execute(
473473
self,
474474
plugin_name: str,
475-
level: str, # "careful" | "warning" | "critical"
476-
context: dict, # get_export() + built-in vars (see below)
477-
action_value: str, # raw value from glances.conf
475+
level: str, # "careful" | "warning" | "critical"
476+
context: dict, # get_export() + built-in vars (see below)
477+
action_value: str, # raw value from glances.conf
478478
repeat: bool = False,
479479
) -> None: ...
480480
```
@@ -522,8 +522,9 @@ class WebhookAction(GlancesActionBase):
522522
523523
Config: [mem] critical_webhook=https://hooks.example.com/alert
524524
"""
525+
525526
action_name = "webhook"
526-
requires = [] # httpx is already a core v5 dependency
527+
requires = [] # httpx is already a core v5 dependency
527528

528529
async def execute(self, plugin_name, level, context, action_value, repeat=False):
529530
url = chevron.render(action_value, context)
@@ -605,8 +606,10 @@ Plugins import the singleton and consume it from `_grab_stats`:
605606
```python
606607
from glances.cpu_sampler_v5 import sampler
607608

609+
608610
class PluginModel(GlancesPluginBase[dict]):
609611
plugin_name = "cpu"
612+
610613
async def _grab_stats(self) -> dict:
611614
agg = await sampler.get_aggregate()
612615
...
@@ -771,6 +774,8 @@ Routes live in `glances/routes_v5.py` as a single `APIRouter(prefix="/api/5")`,
771774
| `/api/5/token` | POST | Basic (route-level) | `app.state.jwt_handler` | Exempt from the global Auth middleware (listed in `UNAUTH_PATHS`). Returns `{access_token, token_type:"bearer", expires_in}`. 404 if `[outputs] password` is unset. |
772775
| `/api/5/pluginslist` | GET | per-config | `app.state.plugins` keys | Sorted plugin-name list. |
773776
| `/api/5/all` | GET | per-config | `store.as_dict()` | Every plugin's payload in a single dict. Plugins that have never written are absent. |
777+
| `/api/5/all/limits` | GET | per-config | per-plugin `get_limits()` | Effective thresholds for every registered plugin. Plugins with no watched field are omitted. |
778+
| `/api/5/<plugin>/limits` | GET | per-config | `plugin.get_limits()` | Effective thresholds — config layered over `default_thresholds`. `200 {}` when the plugin has no watched field, or for the 6 plugins that override `_derived_parameters()` (`sensors`, `wifi`, `folders`, `raid`, `ports`, `amps`) even when thresholds are active; `404` if not registered. Never subject to cycle-0 `null`: thresholds come from config + schema. See `docs/superpowers/specs/2026-08-03-glances-v5-limits-routes-design.md`. |
774779
| `/api/5/alert` | GET | per-config | `alerts.get_history()` | Returns the ring buffer. 404 if `alerts is None`. |
775780
| `/api/5/config` | GET | per-config | `config.as_dict_secure()` | Redacted via `as_dict_secure()`CVE-2026-32609 / 30928. |
776781
| `/api/5/<plugin>` | GET | per-config | `store.get(plugin)` | Raw payload **with `_levels`**. `200 null` if the plugin is registered but has not yet published (scheduler cycle 0). `404` if the plugin is not registered. |
@@ -949,15 +954,11 @@ def get_export(self) -> dict | list:
949954
data = self.store.get(self.plugin_name, {})
950955
if isinstance(data, dict):
951956
return {
952-
k: v for k, v in data.items()
953-
if not k.startswith('_')
954-
and self._fields.get(k, {}).get('exportable', True)
957+
k: v for k, v in data.items() if not k.startswith('_') and self._fields.get(k, {}).get('exportable', True)
955958
}
956959
# list plugin
957960
return [
958-
{k: v for k, v in item.items()
959-
if not k.startswith('_')
960-
and self._fields.get(k, {}).get('exportable', True)}
961+
{k: v for k, v in item.items() if not k.startswith('_') and self._fields.get(k, {}).get('exportable', True)}
961962
for item in data.get('data', [])
962963
]
963964
```
@@ -1191,8 +1192,8 @@ adapter automatically picks them up via the dynamic registry.
11911192
| `glances://stats` | `StatsStoreV5.as_dict()` ||
11921193
| `glances://stats/{plugin}` | `StatsStoreV5.get(plugin)` | ✅ for ported plugins; `ValueError("Plugin not found")` otherwise |
11931194
| `glances://stats/{plugin}/history` | _(no history buffer yet)_ | ⚠ returns `{}` + WARN log (once per plugin) |
1194-
| `glances://limits` | aggregated `plugin._fields[*].default_thresholds` ||
1195-
| `glances://limits/{plugin}` | per-plugin field thresholds ||
1195+
| `glances://limits` | `plugin.get_limits()` — effective thresholds (config over schema defaults) ||
1196+
| `glances://limits/{plugin}` | idem, per plugin ||
11961197
| Prompt `system_health_summary` | cpu, mem, memswap, load, fs, network | partial (memswap, fs absent → empty dicts) |
11971198
| Prompt `alert_analysis` | `GlancesAlerts.get_history()` | ✅ (v5-native schema, see §11.5) |
11981199
| Prompt `top_processes_report` | processlist | ⚠ processlist not ported — empty list |
@@ -1220,7 +1221,7 @@ event shape**:
12201221
{
12211222
"ts": "2026-05-15T13:54:09.123",
12221223
"plugin": "cpu",
1223-
"key": None, # primary-key value for collection plugins
1224+
"key": None, # primary-key value for collection plugins
12241225
"field": "total",
12251226
"level": "warning",
12261227
"previous_level": "ok",

0 commit comments

Comments
 (0)