server-motd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """server-motd: login MOTD for media servers."""
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from server_motd.cli import main
4
+
5
+ sys.exit(main())
server_motd/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """Command line entry point. The login invocation always exits 0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import socket
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from server_motd import config, profile, render, runner, terminal
13
+ from server_motd.config import BadEntry, ConfigError, ModuleSpec
14
+ from server_motd.model import Module, Result, Status
15
+ from server_motd.modules import REGISTRY
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ p = argparse.ArgumentParser(prog="server-motd")
20
+ p.add_argument("--config", type=Path, help="config file (overrides discovery)")
21
+ p.add_argument("--width", type=int, help="output width in columns")
22
+ p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
23
+ p.add_argument("--verbose", action="store_true", help="show exception detail")
24
+ sub = p.add_subparsers(dest="command") # no subcommand = render
25
+ check = sub.add_parser("check", help="validate the config and probe every module")
26
+ # SUPPRESS keeps values given before the subcommand from being overwritten.
27
+ check.add_argument("--config", type=Path, default=argparse.SUPPRESS)
28
+ check.add_argument("--verbose", action="store_true", default=argparse.SUPPRESS)
29
+ sub.add_parser("install-profile", help="add the profile block")
30
+ sub.add_parser("uninstall-profile", help="remove the profile block")
31
+ return p
32
+
33
+
34
+ def main(argv: Sequence[str] | None = None) -> int:
35
+ args = build_parser().parse_args(argv)
36
+ try:
37
+ if args.command == "check":
38
+ return _check(args)
39
+ if args.command in ("install-profile", "uninstall-profile"):
40
+ return profile.run(args.command)
41
+ return _render(args)
42
+ except Exception as exc: # never block login
43
+ print(f"server-motd: {exc}", file=sys.stderr)
44
+ return 0
45
+
46
+
47
+ def _bad(title: str, message: str) -> Result:
48
+ return runner.unavailable(title, f"invalid config: {message}")
49
+
50
+
51
+ def _build_items(
52
+ entries: list[ModuleSpec | BadEntry],
53
+ ) -> list[tuple[str, Module | Result]]:
54
+ # Config order is preserved: each item is a ready Result or a Module to run.
55
+ items: list[tuple[str, Module | Result]] = []
56
+ for entry in entries:
57
+ if isinstance(entry, BadEntry):
58
+ items.append((entry.label, _bad(entry.label, entry.message)))
59
+ continue
60
+ if not entry.enabled:
61
+ continue
62
+ title = entry.name or entry.kind
63
+ factory = REGISTRY.get(entry.kind)
64
+ if factory is None:
65
+ items.append((title, _bad(title, f"unknown kind '{entry.kind}'")))
66
+ continue
67
+ try:
68
+ items.append((title, factory(entry)))
69
+ except ConfigError as exc:
70
+ items.append((title, _bad(title, str(exc))))
71
+ return items
72
+
73
+
74
+ def _check(args: argparse.Namespace) -> int:
75
+ try:
76
+ entries = config.load(args.config)
77
+ except ConfigError as exc:
78
+ print(f"server-motd: {exc}", file=sys.stderr)
79
+ return 1
80
+ items = _build_items(entries)
81
+ log: list[str] = []
82
+ to_run = [(t, m) for t, m in items if not isinstance(m, Result)]
83
+ fetched = iter(runner.run(to_run, log))
84
+ failed = False
85
+ for title, item in items:
86
+ result = item if isinstance(item, Result) else next(fetched)
87
+ if result.status is Status.UNAVAILABLE:
88
+ failed = True
89
+ reason = result.rows[0].value.removeprefix("UNAVAILABLE: ")
90
+ print(f"FAIL {title}: {reason}")
91
+ else:
92
+ print(f"ok {title}")
93
+ if args.verbose:
94
+ for entry_log in log:
95
+ print(entry_log, file=sys.stderr)
96
+ return 1 if failed else 0
97
+
98
+
99
+ def _render(args: argparse.Namespace) -> int:
100
+ try:
101
+ entries = config.load(args.config)
102
+ settings = config.load_settings(args.config)
103
+ except ConfigError as exc:
104
+ print(f"server-motd: {exc}", file=sys.stderr)
105
+ return 0
106
+ items = _build_items(entries)
107
+
108
+ log: list[str] = []
109
+ to_run = [(t, m) for t, m in items if not isinstance(m, Result)]
110
+ fetched = iter(runner.run(to_run, log))
111
+ results = [m if isinstance(m, Result) else next(fetched) for _, m in items]
112
+
113
+ width = args.width or shutil.get_terminal_size((80, 24)).columns
114
+ color = args.color == "always" or (args.color == "auto" and sys.stdout.isatty())
115
+ if color and not terminal.enable_vt() and args.color == "auto":
116
+ color = False # console cannot render escape codes: plain text
117
+ box = settings.box
118
+ if box not in render.BOX_STYLES:
119
+ print(f"server-motd: unknown box style '{box}', using single", file=sys.stderr)
120
+ box = "single"
121
+ title = settings.title or socket.gethostname()
122
+ sys.stdout.write(render.render(results, width, color, title, box))
123
+ if args.verbose:
124
+ for entry_log in log:
125
+ print(entry_log, file=sys.stderr)
126
+ return 0
server_motd/config.py ADDED
@@ -0,0 +1,155 @@
1
+ """TOML config loading, strict per-module validation and secret resolution.
2
+
3
+ Config shape: an ordered `[[modules]]` array. Each entry has `kind`, optional
4
+ `name`, `enabled` (default true) and any kind-specific keys, including
5
+ `warn`/`crit`. Those are left in `ModuleSpec.options`; a module consumes what
6
+ it supports with the `take_*` helpers and then calls `done()`, which rejects
7
+ any key left over. Errors are raised as `ConfigError` and reported only for
8
+ the module they belong to.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import sys
16
+ import tomllib
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ class ConfigError(Exception):
23
+ """Invalid configuration (file level or for a single module)."""
24
+
25
+
26
+ @dataclass
27
+ class ModuleSpec:
28
+ kind: str
29
+ name: str | None
30
+ enabled: bool
31
+ options: dict[str, Any] = field(default_factory=dict)
32
+
33
+ def take(self, key: str, typ: type, default: Any = None) -> Any:
34
+ """Pop an optional option, checking its type."""
35
+ if key not in self.options:
36
+ return default
37
+ value = self.options.pop(key)
38
+ if typ is float and isinstance(value, int) and not isinstance(value, bool):
39
+ value = float(value)
40
+ if not isinstance(value, typ) or (typ is not bool and isinstance(value, bool)):
41
+ raise ConfigError(f"'{key}' must be {typ.__name__}")
42
+ return value
43
+
44
+ def take_secret(self, key: str) -> str | None:
45
+ """Resolve a secret given inline, as `${ENV_VAR}`, or via `<key>_file`."""
46
+ value = self.take(key, str)
47
+ file_key = f"{key}_file"
48
+ path = self.take(file_key, str)
49
+ if value is not None and path is not None:
50
+ raise ConfigError(f"set only one of '{key}' and '{file_key}'")
51
+ if path is not None:
52
+ try:
53
+ return Path(path).read_text(encoding="utf-8").strip()
54
+ except OSError as exc:
55
+ raise ConfigError(f"cannot read '{file_key}': {exc.strerror}") from exc
56
+ if value is None:
57
+ return None
58
+ return _expand_env(value, key)
59
+
60
+ def done(self) -> None:
61
+ if self.options:
62
+ keys = ", ".join(sorted(self.options))
63
+ raise ConfigError(f"unknown option(s): {keys}")
64
+
65
+
66
+ _ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
67
+
68
+
69
+ def _expand_env(value: str, key: str) -> str:
70
+ def sub(m: re.Match[str]) -> str:
71
+ name = m.group(1)
72
+ if name not in os.environ:
73
+ raise ConfigError(f"'{key}' references unset variable {name}")
74
+ return os.environ[name]
75
+
76
+ return _ENV.sub(sub, value)
77
+
78
+
79
+ @dataclass
80
+ class BadEntry:
81
+ """A `[[modules]]` entry too malformed to build a ModuleSpec from."""
82
+
83
+ label: str
84
+ message: str
85
+
86
+
87
+ def default_config_paths() -> list[Path]:
88
+ if sys.platform == "win32":
89
+ paths = []
90
+ for var in ("APPDATA", "ProgramData"):
91
+ if os.environ.get(var):
92
+ paths.append(Path(os.environ[var]) / "server-motd" / "config.toml")
93
+ return paths
94
+ return [Path("/etc/server-motd/config.toml")]
95
+
96
+
97
+ @dataclass
98
+ class Settings:
99
+ """Top-level (non-module) config keys: `title` and `box`."""
100
+
101
+ title: str | None = None
102
+ box: str = "single"
103
+
104
+
105
+ def load_settings(path: Path | None) -> Settings:
106
+ """Read the top-level keys; invalid values raise ConfigError."""
107
+ data = _read(path)
108
+ title, box = data.get("title"), data.get("box", "single")
109
+ if title is not None and not isinstance(title, str):
110
+ raise ConfigError("'title' must be a string")
111
+ if not isinstance(box, str):
112
+ raise ConfigError("'box' must be a string")
113
+ return Settings(title, box)
114
+
115
+
116
+ def load(path: Path | None) -> list[ModuleSpec | BadEntry]:
117
+ """Parse the config. Each entry is a ModuleSpec, or a BadEntry."""
118
+ data = _read(path)
119
+ entries = data.get("modules", [])
120
+ if not isinstance(entries, list):
121
+ raise ConfigError("'modules' must be an array of tables")
122
+ return [_parse_entry(e) for e in entries]
123
+
124
+
125
+ def _read(path: Path | None) -> dict[str, Any]:
126
+ candidates = [path] if path else default_config_paths()
127
+ for candidate in candidates:
128
+ if candidate.is_file():
129
+ chosen = candidate
130
+ break
131
+ else:
132
+ shown = ", ".join(str(c) for c in candidates) or "(none)"
133
+ raise ConfigError(f"no config file found (looked in {shown})")
134
+ try:
135
+ data = tomllib.loads(chosen.read_text(encoding="utf-8"))
136
+ except (OSError, tomllib.TOMLDecodeError) as exc:
137
+ raise ConfigError(f"{chosen}: {exc}") from exc
138
+ return data
139
+
140
+
141
+ def _parse_entry(entry: Any) -> ModuleSpec | BadEntry:
142
+ if not isinstance(entry, dict):
143
+ return BadEntry("?", "module entry must be a table")
144
+ entry = dict(entry)
145
+ kind = entry.pop("kind", None)
146
+ name = entry.pop("name", None)
147
+ enabled = entry.pop("enabled", True)
148
+ label = name if isinstance(name, str) else (kind if isinstance(kind, str) else "?")
149
+ if not isinstance(kind, str):
150
+ return BadEntry(label, "'kind' is required and must be a string")
151
+ if name is not None and not isinstance(name, str):
152
+ return BadEntry(label, "'name' must be a string")
153
+ if not isinstance(enabled, bool):
154
+ return BadEntry(label, "'enabled' must be a boolean")
155
+ return ModuleSpec(kind=kind, name=name, enabled=enabled, options=entry)
server_motd/model.py ADDED
@@ -0,0 +1,38 @@
1
+ """Core schema: Status, Row, Result and the Module protocol.
2
+
3
+ The schema carries no layout. The renderer maps Status to colour.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Protocol
11
+
12
+
13
+ class Status(Enum):
14
+ INFO = "info"
15
+ OK = "ok"
16
+ WARN = "warn"
17
+ CRIT = "crit"
18
+ UNAVAILABLE = "unavailable"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Row:
23
+ label: str
24
+ value: str
25
+ status: Status = Status.INFO
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Result:
30
+ title: str
31
+ rows: list[Row] = field(default_factory=list)
32
+ status: Status = Status.INFO
33
+
34
+
35
+ class Module(Protocol):
36
+ """A synchronous unit of MOTD output. `fetch` raises on failure."""
37
+
38
+ def fetch(self) -> Result: ...
@@ -0,0 +1,54 @@
1
+ """Module registry. Extension conventions for adding a module kind:
2
+
3
+ 1. Create one file, `modules/<kind>.py`, defining a class with
4
+ `__init__(self, spec: ModuleSpec)` (read options with `spec.take*`, then call
5
+ `spec.done()`; raise `ConfigError` for bad config) and `fetch() -> Result`
6
+ (raise on failure; the runner turns that into UNAVAILABLE).
7
+ 2. Add ONE import and ONE line to `REGISTRY` below: `"<kind>": <kind>.Class,`.
8
+ Keep entries alphabetical so parallel tickets merge cleanly.
9
+ 3. Test only through the CLI (tests/conftest.py `run_cli`, `write_config`,
10
+ `assert_golden`). Pin anything non-deterministic at the boundary it comes
11
+ from (e.g. monkeypatch `psutil` / `time`), not inside the module.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+
18
+ from server_motd.config import ModuleSpec
19
+ from server_motd.model import Module
20
+ from server_motd.modules import (
21
+ cpu,
22
+ disks,
23
+ ips,
24
+ load,
25
+ logins,
26
+ memory,
27
+ processes,
28
+ qbittorrent,
29
+ rtorrent,
30
+ sabnzbd,
31
+ servarr,
32
+ tautulli,
33
+ updates,
34
+ uptime,
35
+ )
36
+
37
+ REGISTRY: dict[str, Callable[[ModuleSpec], Module]] = {
38
+ "cpu": cpu.Cpu,
39
+ "disks": disks.Disks,
40
+ "ips": ips.Ips,
41
+ "lidarr": servarr.Lidarr,
42
+ "load": load.Load,
43
+ "logins": logins.Logins,
44
+ "memory": memory.Memory,
45
+ "processes": processes.Processes,
46
+ "qbittorrent": qbittorrent.QBittorrent,
47
+ "radarr": servarr.Radarr,
48
+ "rtorrent": rtorrent.Rtorrent,
49
+ "sabnzbd": sabnzbd.Sabnzbd,
50
+ "sonarr": servarr.Sonarr,
51
+ "tautulli": tautulli.Tautulli,
52
+ "updates": updates.Updates,
53
+ "uptime": uptime.Uptime,
54
+ }
@@ -0,0 +1,42 @@
1
+ """Helpers shared by the modules: thresholds, status ranking, size formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from server_motd.config import ConfigError, ModuleSpec
8
+ from server_motd.model import Row, Status
9
+
10
+ _SEVERITY = [Status.INFO, Status.OK, Status.WARN, Status.CRIT, Status.UNAVAILABLE]
11
+
12
+
13
+ class Thresholds:
14
+ """Optional `warn` / `crit` options; a value >= a limit takes that status."""
15
+
16
+ def __init__(self, spec: ModuleSpec) -> None:
17
+ self.warn: float | None = spec.take("warn", float)
18
+ self.crit: float | None = spec.take("crit", float)
19
+ if self.warn is not None and self.crit is not None and self.warn > self.crit:
20
+ raise ConfigError("'warn' must not exceed 'crit'")
21
+
22
+ def status(self, value: float) -> Status:
23
+ if self.warn is None and self.crit is None:
24
+ return Status.INFO
25
+ if self.crit is not None and value >= self.crit:
26
+ return Status.CRIT
27
+ if self.warn is not None and value >= self.warn:
28
+ return Status.WARN
29
+ return Status.OK
30
+
31
+
32
+ def worst(rows: Iterable[Row]) -> Status:
33
+ """Overall status: the most severe row status."""
34
+ return max((r.status for r in rows), key=_SEVERITY.index, default=Status.INFO)
35
+
36
+
37
+ def human_bytes(n: float) -> str:
38
+ for unit in "BKMGT":
39
+ if n < 1024 or unit == "T":
40
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
41
+ n /= 1024
42
+ raise AssertionError("unreachable")
@@ -0,0 +1,78 @@
1
+ """Helpers shared by the HTTP service modules.
2
+
3
+ One request timeout (kept within the runner's per-module limit), the common
4
+ `url` / `api_key` options, and the mapping of transport failures to short
5
+ user-visible messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from server_motd.config import ConfigError, ModuleSpec
17
+
18
+ REQUEST_TIMEOUT = 2.5
19
+
20
+
21
+ def take_url_and_key(spec: ModuleSpec) -> tuple[str, str]:
22
+ """Take the required `url` (trailing slash stripped) and `api_key` options."""
23
+ url = spec.take("url", str)
24
+ key = spec.take_secret("api_key")
25
+ if not url:
26
+ raise ConfigError("'url' is required")
27
+ if not key:
28
+ raise ConfigError("'api_key' is required")
29
+ return url.rstrip("/"), key
30
+
31
+
32
+ def request(
33
+ opener: Callable[..., Any],
34
+ *args: Any,
35
+ auth_error: str = "bad API key",
36
+ ) -> Any:
37
+ """Call `opener(*args, timeout=REQUEST_TIMEOUT)` and return the response.
38
+
39
+ Failures raise RuntimeError with the message shown in the MOTD. Use as a
40
+ context manager result: the caller closes the response.
41
+ """
42
+ try:
43
+ return opener(*args, timeout=REQUEST_TIMEOUT)
44
+ except urllib.error.HTTPError as exc:
45
+ if exc.code in (401, 403):
46
+ raise RuntimeError(f"{auth_error} (HTTP {exc.code})") from exc
47
+ raise RuntimeError(f"HTTP {exc.code}") from exc
48
+ except urllib.error.URLError as exc:
49
+ raise RuntimeError(f"unreachable: {exc.reason}") from exc
50
+ except (OSError, ValueError) as exc:
51
+ raise RuntimeError(f"bad response: {exc}") from exc
52
+
53
+
54
+ def read(
55
+ opener: Callable[..., Any],
56
+ *args: Any,
57
+ auth_error: str = "bad API key",
58
+ ) -> bytes:
59
+ """Open, read fully and close; transport failures map as in `request`."""
60
+ try:
61
+ with request(opener, *args, auth_error=auth_error) as resp:
62
+ data: bytes = resp.read()
63
+ return data
64
+ except RuntimeError:
65
+ raise
66
+ except (OSError, ValueError) as exc: # failure while reading the body
67
+ raise RuntimeError(f"bad response: {exc}") from exc
68
+
69
+
70
+ def get_json(
71
+ target: str | urllib.request.Request, *, auth_error: str = "bad API key"
72
+ ) -> Any:
73
+ """GET a URL (or Request) and parse the JSON body."""
74
+ body = read(urllib.request.urlopen, target, auth_error=auth_error)
75
+ try:
76
+ return json.loads(body)
77
+ except ValueError as exc:
78
+ raise RuntimeError(f"bad response: {exc}") from exc
@@ -0,0 +1,31 @@
1
+ """System module: CPU utilisation percent (works everywhere; replaces load on Windows).
2
+
3
+ Thresholds apply to the utilisation percentage.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import psutil
9
+
10
+ from server_motd.config import ModuleSpec
11
+ from server_motd.model import Result, Row
12
+ from server_motd.modules._common import Thresholds
13
+
14
+ SAMPLE_SECONDS = 0.2 # cpu_percent(None) reports a meaningless 0.0 on first call
15
+
16
+
17
+ class Cpu:
18
+ def __init__(self, spec: ModuleSpec) -> None:
19
+ self.title = spec.name or "CPU"
20
+ self.limits = Thresholds(spec)
21
+ spec.done()
22
+
23
+ def fetch(self) -> Result:
24
+ percent = psutil.cpu_percent(interval=SAMPLE_SECONDS)
25
+ cores = psutil.cpu_count() or 1
26
+ status = self.limits.status(percent)
27
+ return Result(
28
+ self.title,
29
+ [Row("Usage", f"{percent:.0f}% ({cores} cores)", status)],
30
+ status,
31
+ )
@@ -0,0 +1,32 @@
1
+ """System module: usage per mounted filesystem. Thresholds on percent used."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import psutil
6
+
7
+ from server_motd.config import ModuleSpec
8
+ from server_motd.model import Result, Row
9
+ from server_motd.modules._common import Thresholds, human_bytes, worst
10
+
11
+
12
+ class Disks:
13
+ def __init__(self, spec: ModuleSpec) -> None:
14
+ self.title = spec.name or "Disks"
15
+ self.limits = Thresholds(spec)
16
+ spec.done()
17
+
18
+ def fetch(self) -> Result:
19
+ rows = []
20
+ for part in psutil.disk_partitions(all=False):
21
+ try:
22
+ usage = psutil.disk_usage(part.mountpoint)
23
+ except OSError:
24
+ continue # unreadable mount (e.g. empty drive): not worth a row
25
+ value = (
26
+ f"{human_bytes(usage.used)} / {human_bytes(usage.total)} "
27
+ f"({usage.percent:.0f}%)"
28
+ )
29
+ rows.append(Row(part.mountpoint, value, self.limits.status(usage.percent)))
30
+ if not rows:
31
+ raise RuntimeError("no mounted filesystems found")
32
+ return Result(self.title, rows, worst(rows))
@@ -0,0 +1,31 @@
1
+ """System module: IP addresses per interface (loopback and link-local skipped)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import socket
7
+
8
+ import psutil
9
+
10
+ from server_motd.config import ModuleSpec
11
+ from server_motd.model import Result, Row
12
+
13
+
14
+ class Ips:
15
+ def __init__(self, spec: ModuleSpec) -> None:
16
+ self.title = spec.name or "IPs"
17
+ spec.done()
18
+
19
+ def fetch(self) -> Result:
20
+ rows = []
21
+ for iface, addrs in sorted(psutil.net_if_addrs().items()):
22
+ for addr in addrs:
23
+ if addr.family not in (socket.AF_INET, socket.AF_INET6):
24
+ continue
25
+ ip = ipaddress.ip_address(addr.address.split("%")[0])
26
+ if ip.is_loopback or ip.is_link_local:
27
+ continue
28
+ rows.append(Row(iface, str(ip)))
29
+ if not rows:
30
+ raise RuntimeError("no non-loopback addresses found")
31
+ return Result(self.title, rows)
@@ -0,0 +1,30 @@
1
+ """System module: load average (Linux only; Windows uses the cpu module).
2
+
3
+ Thresholds apply to the 1-minute load average divided by the CPU count.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ import psutil
11
+
12
+ from server_motd.config import ConfigError, ModuleSpec
13
+ from server_motd.model import Result, Row
14
+ from server_motd.modules._common import Thresholds
15
+
16
+
17
+ class Load:
18
+ def __init__(self, spec: ModuleSpec) -> None:
19
+ self.title = spec.name or "Load"
20
+ self.limits = Thresholds(spec)
21
+ if sys.platform == "win32":
22
+ raise ConfigError("load is not available on Windows; use the cpu module")
23
+ spec.done()
24
+
25
+ def fetch(self) -> Result:
26
+ one, five, fifteen = psutil.getloadavg()
27
+ per_core = one / (psutil.cpu_count() or 1)
28
+ status = self.limits.status(per_core)
29
+ value = f"{one:.2f} {five:.2f} {fifteen:.2f}"
30
+ return Result(self.title, [Row("Load", value, status)], status)
@@ -0,0 +1,29 @@
1
+ """System module: logged-in sessions. Thresholds on the session count."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import psutil
6
+
7
+ from server_motd.config import ModuleSpec
8
+ from server_motd.model import Result, Row, Status
9
+ from server_motd.modules._common import Thresholds
10
+
11
+
12
+ class Logins:
13
+ def __init__(self, spec: ModuleSpec) -> None:
14
+ self.title = spec.name or "Logins"
15
+ self.limits = Thresholds(spec)
16
+ spec.done()
17
+
18
+ def fetch(self) -> Result:
19
+ users = psutil.users()
20
+ status = self.limits.status(len(users))
21
+ if not users:
22
+ return Result(self.title, [Row("Sessions", "none", status)], status)
23
+ rows = []
24
+ for u in users:
25
+ where = u.terminal or "-"
26
+ if u.host:
27
+ where += f" ({u.host})"
28
+ rows.append(Row(u.name, where, Status.INFO))
29
+ return Result(self.title, rows, status)