controller: group services into Core vs Optional & Maintenance

The Controller listed every wrolpi-* service in one flat table, which was
confusing for new users who couldn't tell always-on infrastructure from
one-off/dev/maintenance units.

Services are now split into two groups computed once in the backend and
exposed as `service.group`:

* Core Services      - persistent services enabled at boot (api, app, caddy,
                       kiwix, ...), the normal running system.
* Optional & Maintenance - one-off/dev/maintenance tasks (bootstrap, repair,
                       upgrade, *-dev, discovered wrolpi-*), plus persistent
                       services the user has disabled at boot (e.g. Samba with
                       no shares configured).

Rule: core iff kind == "persistent" and enabled; otherwise optional. Group
membership only changes when the user toggles Boot, never on start/stop or
polling, so rows stay put. Within each group, failed/stopped services sort
first so problems stay visible.

Feature parity across all three render paths: the React ControllerPage, the
server-side Jinja template, and the client-side poll refresh. Docker
containers have no group field and fall back to a single ungrouped list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-22 19:41:17 -06:00 • committed by lrnselfreliance
parent 331e1eceda
commit e51e5fb2fb
7 changed files with 353 additions and 73 deletions

View file

@ -41,6 +41,7 @@ DEFAULT_CONFIG = {
"viewable": True,
"use_https": False,
"description": "Python API (Development)",
"kind": "task",
"show_only_when_running": True,
},
{
@ -58,6 +59,7 @@ DEFAULT_CONFIG = {
"viewable": False,
"use_https": False,
"description": "React frontend (Development)",
"kind": "task",
"show_only_when_running": True,
},
{
@ -110,6 +112,7 @@ DEFAULT_CONFIG = {
"viewable": False,
"use_https": False,
"description": "WROLPi upgrade process",
"kind": "task",
"show_only_when_running": True,
},
],

View file

@ -40,6 +40,45 @@ def get_service_config(name: str) -> Optional[dict]:
return None
def get_service_kind(service_config: dict) -> str:
"""
Classify a service as 'persistent' or 'task'.
'persistent' services are the always-on parts of a running WROLPi (api,
app, caddy, kiwix, ...). 'task' services are one-off/dev/maintenance units
(bootstrap, repair, upgrade, *-dev) that run on demand and are not expected
to stay running.
An explicit "kind" in the service config wins. Otherwise services flagged
show_only_when_running (dev/upgrade units) are treated as tasks, and
everything else defaults to persistent.
"""
kind = service_config.get("kind")
if kind:
return kind
if service_config.get("show_only_when_running"):
return "task"
return "persistent"
def classify_service_group(kind: str, enabled: bool) -> str:
"""
Group a service for display in the Controller UI.
Returns:
"core" - a persistent service enabled at boot: the normal running
system the user cares about day-to-day.
"optional" - one-off/dev/maintenance tasks, or persistent services the
user has disabled at boot (e.g. Samba with no shares).
Membership only changes when the user toggles Boot for a persistent
service, never on start/stop or polling, so rows stay put.
"""
if kind == "persistent" and enabled:
return "core"
return "optional"
def _run_systemctl(command: str, service: str, timeout: int = 30) -> dict:
"""
Run a systemctl command.
@ -118,6 +157,7 @@ def get_service_status(name: str) -> dict:
)
load_state = load_result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
kind = get_service_kind(service_config)
return {
"name": name,
"systemd_name": systemd_name,
@ -130,6 +170,8 @@ def get_service_status(name: str) -> dict:
"view_path": service_config.get("view_path", ""),
"use_https": service_config.get("use_https", False),
"description": service_config.get("description", ""),
"kind": kind,
"group": classify_service_group(kind, False),
"error": "systemctl not available",
}
@ -145,18 +187,22 @@ def get_service_status(name: str) -> dict:
else:
status = "unknown"
enabled = is_enabled == "enabled"
kind = get_service_kind(service_config)
return {
"name": name,
"systemd_name": systemd_name,
"status": status,
"installed": load_state not in ("not-found", ""),
"active": is_active,
"enabled": is_enabled == "enabled",
"enabled": enabled,
"port": service_config.get("port"),
"viewable": service_config.get("viewable", False),
"view_path": service_config.get("view_path", ""),
"use_https": service_config.get("use_https", False),
"description": service_config.get("description", ""),
"kind": kind,
"group": classify_service_group(kind, enabled),
}
@ -241,6 +287,8 @@ def get_discovered_service_status(name: str) -> dict:
"view_path": "",
"use_https": False,
"description": "",
"kind": "task",
"group": "optional",
"error": "systemctl not available",
}
@ -266,6 +314,9 @@ def get_discovered_service_status(name: str) -> dict:
"view_path": "",
"use_https": False,
"description": "",
# Discovered wrolpi-* units are always one-off/maintenance tasks.
"kind": "task",
"group": "optional",
}

View file

@ -228,8 +228,13 @@ async def dashboard(request: Request):
else:
services = get_all_services_status()
# Sort services by name for consistent display
services = sorted(services, key=lambda s: s["name"])
# Sort services so problems surface first, then alphabetically. The
# template partitions on each service's "group" (core vs optional).
_status_order = {"failed": 0, "unknown": 1, "stopped": 2, "running": 3}
services = sorted(
services,
key=lambda s: (_status_order.get(s.get("status"), 1), s["name"]),
)
# Root CA download is only useful after the media drive is mounted (so an
# existing CA on the drive is visible) and repair has finished generating

View file

@ -241,6 +241,20 @@
background: #1a3a5c;
}
/* Service group separators (Core vs Optional & Maintenance) */
tr.service-group-label td {
background: #0f3460;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: #a9b7d0;
padding-top: 0.9rem;
}
tr.service-group-label:hover {
background: transparent;
}
/* Status indicators */
.status-running {
color: #4ecca3;
@ -988,8 +1002,55 @@
</div>
</section>
{% macro service_row(service, docker_mode, host) %}
<tr>
<td>
{{ service.name }}
{% if service.port %}<span class="sub">:{{ service.port }}</span>{% endif %}
</td>
<td class="status-{{ service.status }}">{{ service.status }}</td>
<td>
{% if service.status == 'running' %}
<button class="btn btn-danger" onclick="serviceAction('{{ service.name }}', 'stop')">Stop</button>
{% else %}
<button class="btn btn-primary" onclick="serviceAction('{{ service.name }}', 'start')">Start
</button>
{% endif %}
<button class="btn btn-warning" onclick="serviceAction('{{ service.name }}', 'restart')">Restart
</button>
<button class="btn btn-info" onclick="showLogs('{{ service.name }}', true)">Logs</button>
{% if service.viewable and service.status == 'running' and service.port %}
{% set is_default_port = (service.use_https and service.port == 443) or (not service.use_https and service.port == 80) %}
<a href="{{ 'https' if service.use_https else 'http' }}://{{ host }}{% if not is_default_port %}:{{ service.port }}{% endif %}{{ service.view_path|default('') }}"
target="_blank" class="btn btn-violet">View</a>
{% endif %}
</td>
{% if not docker_mode %}
<td>
{% if service.name == 'wrolpi-upgrade' %}
<span style="color: #666;">--</span>
{% else %}
<label class="toggle">
<input type="checkbox"
id="boot-{{ service.name }}"
{% if service.enabled %}checked{% endif %}
onchange="toggleBoot('{{ service.name }}', this.checked)">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label" id="boot-label-{{ service.name }}">
{{ 'Enabled' if service.enabled else 'Disabled' }}
</span>
{% endif %}
</td>
{% endif %}
</tr>
{% endmacro %}
<section id="services">
<h2>Services</h2>
{% set core = services | rejectattr('group', 'equalto', 'optional') | list %}
{% set optional = services | selectattr('group', 'equalto', 'optional') | list %}
{% set span = 3 if docker_mode else 4 %}
<table>
<thead>
<tr>
@ -1002,55 +1063,22 @@
</tr>
</thead>
<tbody>
{% for service in services %}
<tr>
<td>
{{ service.name }}
{% if service.port %}<span class="sub">:{{ service.port }}</span>{% endif %}
</td>
<td class="status-{{ service.status }}">{{ service.status }}</td>
<td>
{% if service.status == 'running' %}
<button class="btn btn-danger" onclick="serviceAction('{{ service.name }}', 'stop')">Stop</button>
{% else %}
<button class="btn btn-primary" onclick="serviceAction('{{ service.name }}', 'start')">Start
</button>
{% endif %}
<button class="btn btn-warning" onclick="serviceAction('{{ service.name }}', 'restart')">Restart
</button>
<button class="btn btn-info" onclick="showLogs('{{ service.name }}', true)">Logs</button>
{% if service.viewable and service.status == 'running' and service.port %}
{% set is_default_port = (service.use_https and service.port == 443) or (not service.use_https and service.port == 80) %}
<a href="{{ 'https' if service.use_https else 'http' }}://{{ host }}{% if not is_default_port %}:{{ service.port }}{% endif %}{{ service.view_path|default('') }}"
target="_blank" class="btn btn-violet">View</a>
{% endif %}
</td>
{% if not docker_mode %}
<td>
{% if service.name == 'wrolpi-upgrade' %}
<span style="color: #666;">--</span>
{% else %}
<label class="toggle">
<input type="checkbox"
id="boot-{{ service.name }}"
{% if service.enabled %}checked{% endif %}
onchange="toggleBoot('{{ service.name }}', this.checked)">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label" id="boot-label-{{ service.name }}">
{{ 'Enabled' if service.enabled else 'Disabled' }}
</span>
{% endif %}
</td>
{% if services %}
{% if core and optional %}
<tr class="service-group-label"><td colspan="{{ span }}"><strong>Core Services</strong></td></tr>
{% endif %}
{% for service in core %}{{ service_row(service, docker_mode, host) }}{% endfor %}
{% if optional %}
<tr class="service-group-label"><td colspan="{{ span }}"><strong>Optional &amp; Maintenance</strong></td></tr>
{% for service in optional %}{{ service_row(service, docker_mode, host) }}{% endfor %}
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{% if docker_mode %}3{% else %}4{% endif %}" style="text-align: center; color: #666;">
<td colspan="{{ span }}" style="text-align: center; color: #666;">
Service status not yet implemented
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</section>
@ -2728,10 +2756,10 @@
// Check if we're in docker mode by looking for Boot column
const isDockerMode = !document.querySelector('th:last-child')?.textContent?.includes('Boot');
const colspan = isDockerMode ? 3 : 4;
let html = '';
for (const service of services) {
html += `<tr>
function serviceRowHtml(service) {
let row = `<tr>
<td>
${service.name}
${service.port ? `<span class="sub">:${service.port}</span>` : ''}
@ -2740,12 +2768,12 @@
<td>`;
if (service.status === 'running') {
html += `<button class="btn btn-danger" onclick="serviceAction('${service.name}', 'stop')">Stop</button>`;
row += `<button class="btn btn-danger" onclick="serviceAction('${service.name}', 'stop')">Stop</button>`;
} else {
html += `<button class="btn btn-primary" onclick="serviceAction('${service.name}', 'start')">Start</button>`;
row += `<button class="btn btn-primary" onclick="serviceAction('${service.name}', 'start')">Start</button>`;
}
html += `<button class="btn btn-warning" onclick="serviceAction('${service.name}', 'restart')">Restart</button>
row += `<button class="btn btn-warning" onclick="serviceAction('${service.name}', 'restart')">Restart</button>
<button class="btn btn-info" onclick="showLogs('${service.name}', true)">Logs</button>`;
if (service.viewable && service.status === 'running' && service.port) {
@ -2754,16 +2782,16 @@
const viewPath = service.view_path || '';
const isDefaultPort = (protocol === 'https' && service.port === 443) || (protocol === 'http' && service.port === 80);
const portSuffix = isDefaultPort ? '' : ':' + service.port;
html += `<a href="${protocol}://${host}${portSuffix}${viewPath}" target="_blank" class="btn btn-violet">View</a>`;
row += `<a href="${protocol}://${host}${portSuffix}${viewPath}" target="_blank" class="btn btn-violet">View</a>`;
}
html += '</td>';
row += '</td>';
if (!isDockerMode) {
if (service.name === 'wrolpi-upgrade') {
html += `<td><span style="color: #666;">--</span></td>`;
row += `<td><span style="color: #666;">--</span></td>`;
} else {
html += `<td>
row += `<td>
<label class="toggle">
<input type="checkbox"
id="boot-${service.name}"
@ -2778,11 +2806,34 @@
}
}
html += '</tr>';
row += '</tr>';
return row;
}
// Sort problems first, then alphabetically, and split into the two
// Controller groups (Core vs Optional & Maintenance).
const statusOrder = {failed: 0, unknown: 1, stopped: 2, running: 3};
const byStatusThenName = (a, b) => {
const sa = statusOrder[a.status] ?? 1;
const sb = statusOrder[b.status] ?? 1;
if (sa !== sb) return sa - sb;
return a.name.localeCompare(b.name);
};
const core = services.filter(s => s.group !== 'optional').sort(byStatusThenName);
const optional = services.filter(s => s.group === 'optional').sort(byStatusThenName);
const labelRow = (title) => `<tr class="service-group-label"><td colspan="${colspan}"><strong>${title}</strong></td></tr>`;
let html = '';
if (core.length && optional.length) {
html += labelRow('Core Services');
}
html += core.map(serviceRowHtml).join('');
if (optional.length) {
html += labelRow('Optional &amp; Maintenance');
html += optional.map(serviceRowHtml).join('');
}
if (services.length === 0) {
const colspan = isDockerMode ? 3 : 4;
html = `<tr><td colspan="${colspan}" style="text-align: center; color: #666;">No services</td></tr>`;
}

View file

@ -13,6 +13,8 @@ from controller.lib.systemd import (
discover_wrolpi_services,
get_all_services_status,
get_discovered_service_status,
get_service_kind,
classify_service_group,
_get_systemd_name,
start_service,
stop_service,
@ -490,3 +492,73 @@ class TestActionsOnDiscoveredServices:
result = get_service_status("wrolpi-fix-media-permissions")
assert result["name"] == "wrolpi-fix-media-permissions"
assert result["status"] == "running"
class TestGetServiceKind:
"""Tests for get_service_kind classification."""
def test_explicit_kind_wins(self):
assert get_service_kind({"kind": "task"}) == "task"
assert get_service_kind({"kind": "persistent"}) == "persistent"
def test_show_only_when_running_is_a_task(self):
"""Dev/upgrade units (show_only_when_running) default to 'task'."""
assert get_service_kind({"show_only_when_running": True}) == "task"
def test_default_is_persistent(self):
"""A plain managed service is a persistent, always-on service."""
assert get_service_kind({}) == "persistent"
assert get_service_kind({"name": "wrolpi-api"}) == "persistent"
class TestClassifyServiceGroup:
"""Tests for classify_service_group."""
def test_persistent_and_enabled_is_core(self):
assert classify_service_group("persistent", True) == "core"
def test_persistent_but_disabled_is_optional(self):
"""A persistent service the user disabled at boot (e.g. Samba with no
shares) drops to the Optional & Maintenance group."""
assert classify_service_group("persistent", False) == "optional"
def test_task_is_always_optional(self):
"""One-off/dev/maintenance tasks are optional regardless of boot state."""
assert classify_service_group("task", True) == "optional"
assert classify_service_group("task", False) == "optional"
class TestServiceStatusGroup:
"""Group/kind are exposed on the status dicts the UI consumes."""
def test_enabled_persistent_service_is_core(self):
# is-active -> running, is-enabled -> enabled, LoadState -> loaded
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = [
mock.Mock(returncode=0, stdout="active", stderr=""),
mock.Mock(returncode=0, stdout="enabled", stderr=""),
mock.Mock(returncode=0, stdout="loaded", stderr=""),
]
result = get_service_status("wrolpi-api")
assert result["kind"] == "persistent"
assert result["enabled"] is True
assert result["group"] == "core"
def test_disabled_persistent_service_is_optional(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = [
mock.Mock(returncode=0, stdout="inactive", stderr=""),
mock.Mock(returncode=0, stdout="disabled", stderr=""),
mock.Mock(returncode=0, stdout="loaded", stderr=""),
]
result = get_service_status("smbd")
assert result["kind"] == "persistent"
assert result["enabled"] is False
assert result["group"] == "optional"
def test_discovered_service_is_optional_task(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = get_discovered_service_status("wrolpi-repair")
assert result["kind"] == "task"
assert result["group"] == "optional"