mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
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:
parent
331e1eceda
commit
e51e5fb2fb
7 changed files with 353 additions and 73 deletions
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue