Skip to content

Commit 307b159

Browse files
ggilestroclaude
andcommitted
fix(node): Show accurate service status in Docker mode
Replace the broken available_on_docker flag with docker_container metadata that maps each daemon to its companion container's host:port. In Docker mode, use TCP health checks instead of systemctl to determine service status. Services with containers (backup_mysql, backup_unified, update_node, git-daemon, vsftpd) now correctly show as active. Services without containers are marked not_available. Toggle switches are disabled in Docker mode since services are managed by Docker Compose. Folder paths are shown as read-only with a note about volume mounts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b98cd7f commit 307b159

File tree

5 files changed

+179
-47
lines changed

5 files changed

+179
-47
lines changed

Docker/node/docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ services:
2525
#network_mode: host # Comment out when running in Windows
2626
ports:
2727
- "80:80/tcp"
28-
- "5353:5353/udp"
2928

3029
ethoscope-node-backup:
3130
<<: *node-base

src/node/ethoscope_node/api/node_api.py

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,73 +7,77 @@
77

88
import datetime
99
import os
10+
import socket
1011
import subprocess
1112

1213
import netifaces
1314

1415
from .base import BaseAPI, error_decorator
1516

1617
# System daemons configuration
18+
# docker_container: maps to the companion Docker container's hostname and port
19+
# on the Docker network. Used for health checks in Docker mode.
20+
# None means the service has no Docker container equivalent.
1721
SYSTEM_DAEMONS = {
1822
"ethoscope_backup_mysql": {
1923
"description": "The service that collects data from the ethoscope mariadb and syncs them with the node.",
20-
"available_on_docker": True,
2124
"conflicts_with": [],
25+
"docker_container": {"host": "ethoscope-node-backup", "port": 8090},
2226
},
2327
"ethoscope_backup_video": {
2428
"description": "The service that collects videos in h264 chunks from the ethoscopes and syncs them with the node",
25-
"available_on_docker": True,
2629
"conflicts_with": ["ethoscope_backup_unified"],
30+
"docker_container": None, # Covered by ethoscope_backup_unified in Docker
2731
},
2832
"ethoscope_backup_unified": {
2933
"description": "The service that collects videos and SQLite dbs from the ethoscopes and syncs them with the node",
30-
"available_on_docker": True,
3134
"conflicts_with": ["ethoscope_backup_video", "ethoscope_backup_sqlite"],
35+
"docker_container": {"host": "ethoscope-node-rsync-backup", "port": 8093},
3236
},
3337
"ethoscope_backup_sqlite": {
3438
"description": "The service that collects SQLite db from the ethoscopes and syncs them with the node",
35-
"available_on_docker": True,
3639
"conflicts_with": ["ethoscope_backup_unified"],
40+
"docker_container": None, # Covered by ethoscope_backup_unified in Docker
3741
},
3842
"ethoscope_update_node": {
3943
"description": "The service used to update the nodes and the ethoscopes.",
40-
"available_on_docker": True,
4144
"conflicts_with": [],
45+
"docker_container": {"host": "ethoscope-node-update", "port": 8888},
4246
},
4347
"git-daemon.socket": {
4448
"description": "The GIT server that handles git updates for the node and ethoscopes.",
45-
"available_on_docker": False,
4649
"conflicts_with": [],
50+
"docker_container": {"host": "ethoscope-git-server", "port": 9418},
4751
},
4852
"ntpd": {
4953
"description": "The NTPd service is syncing time with the ethoscopes.",
50-
"available_on_docker": False,
5154
"conflicts_with": [],
55+
"docker_container": None,
5256
},
5357
"sshd": {
5458
"description": "The SSH daemon allows power users to access the node terminal from remote.",
55-
"available_on_docker": False,
5659
"conflicts_with": [],
60+
"docker_container": None,
5761
},
5862
"vsftpd": {
5963
"description": "The FTP server on the node, used to access the local ethoscope data",
60-
"available_on_docker": False,
6164
"conflicts_with": [],
65+
"docker_container": {"host": "ethoscope-vsftpd", "port": 21},
6266
},
6367
"ethoscope_virtuascope": {
6468
"description": "A virtual ethoscope running on the node. Useful for offline tracking",
65-
"available_on_docker": False,
6669
"conflicts_with": [],
70+
"docker_container": None,
6771
},
6872
"ethoscope_tunnel": {
6973
"description": "Cloudflare tunnel service for remote access to this node via the internet. Requires token.",
70-
"available_on_docker": False,
7174
"conflicts_with": [],
75+
"docker_container": None,
7276
},
7377
"ethoscope_sensor_virtual": {
7478
"description": "A virtual sensor collecting real world data about. Requires token.",
75-
"available_on_docker": False,
7679
"conflicts_with": [],
80+
"docker_container": None,
7781
},
7882
}
7983

@@ -244,30 +248,66 @@ def _get_node_system_info(self):
244248
"NEEDS_UPDATE": needs_update,
245249
}
246250

251+
def _check_container_health(self, host, port, timeout=2):
252+
"""Check if a Docker container is reachable via TCP.
253+
254+
Args:
255+
host (str): Container hostname on the Docker network.
256+
port (int): Port the container listens on.
257+
timeout (int): Connection timeout in seconds.
258+
259+
Returns:
260+
str: "active" if reachable, "inactive" otherwise.
261+
"""
262+
try:
263+
with socket.create_connection((host, port), timeout=timeout):
264+
return "active"
265+
except (ConnectionRefusedError, socket.timeout, OSError):
266+
return "inactive"
267+
247268
def _get_daemon_status(self):
248-
"""Get status of system daemons."""
269+
"""Get status of system daemons.
270+
271+
In Docker mode, uses TCP health checks against companion containers
272+
instead of systemctl. Services without a docker_container mapping
273+
are marked as not_available.
274+
"""
249275
daemons = SYSTEM_DAEMONS.copy()
250276
systemctl = self.server.systemctl
251277
is_dockerized = self.server.is_dockerized
252278

253279
for daemon_name in daemons.keys():
254280
try:
255-
with os.popen(f"{systemctl} is-active {daemon_name}") as df:
256-
is_active = df.read().strip()
257-
258-
is_not_available_on_docker = not daemons[daemon_name][
259-
"available_on_docker"
260-
]
281+
container = daemons[daemon_name].get("docker_container")
282+
283+
if is_dockerized:
284+
if container:
285+
is_active = self._check_container_health(
286+
container["host"], container["port"]
287+
)
288+
not_available = False
289+
else:
290+
is_active = "inactive"
291+
not_available = True
292+
else:
293+
with os.popen(f"{systemctl} is-active {daemon_name}") as df:
294+
is_active = df.read().strip()
295+
not_available = False
261296

262297
daemons[daemon_name].update(
263298
{
264299
"active": is_active,
265-
"not_available": (is_dockerized and is_not_available_on_docker),
300+
"not_available": not_available,
301+
"docker_managed": is_dockerized and container is not None,
266302
}
267303
)
268304
except Exception:
269305
daemons[daemon_name].update(
270-
{"active": "unknown", "not_available": False}
306+
{
307+
"active": "unknown",
308+
"not_available": False,
309+
"docker_managed": False,
310+
}
271311
)
272312

273313
return daemons
@@ -358,6 +398,9 @@ def _execute_command(self, cmd_name):
358398

359399
def _toggle_daemon(self, daemon_name, status):
360400
"""Toggle system daemon on/off, enforcing service conflicts."""
401+
if self.server.is_dockerized:
402+
return "Service is managed by Docker Compose. Use docker compose to control it."
403+
361404
systemctl = self.server.systemctl
362405
result = []
363406

src/node/static/js/controllers/moreController.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ function maxLengthCheck(object) {
308308
$http.get('/node/daemons')
309309
.then(function(response) { var data = response.data;
310310
$scope.daemons = data;
311+
// Detect Docker mode from daemon metadata
312+
$scope.isDockerized = Object.keys(data).some(function(k) {
313+
return data[k].docker_managed;
314+
});
311315
});
312316

313317
$http.get('/node/folders')

src/node/static/pages/more.html

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -383,18 +383,23 @@ <h4 class="modal-title" id="myModalLabel">Restart the node</h4>
383383
<div class="col-lg-8">
384384
<h2>Node Administration</h2>
385385
<ul class="list-group">
386-
<h3>Backup daemons and settings:</h3>
387-
<!-- <p class="log" ng-repeat="(key,entry) in daemons | toArray |orderBy: 'key'">{{key}}: {{entry}}</p></pre> -->
388-
389-
<li class="list-group-item" ng-repeat="(key, entry) in daemons">
390-
391-
<span ng-if="entry.not_available">
392-
<label>{{key}}
393-
<i class="fas fa-ban not-available-icon fa-2x" title="Currently not available in a Dockerised instance."></i>
394-
</label>
386+
<h3>Services</h3>
387+
<p ng-if="isDockerized" class="text-muted mb-2"><small><i class="fab fa-docker" style="color: #0db7ed;"></i> Services are managed by Docker Compose. Use <code>docker compose</code> to start/stop them.</small></p>
388+
389+
<li class="list-group-item" ng-repeat="(key, entry) in daemons" ng-if="!entry.not_available">
390+
391+
<span ng-if="entry.docker_managed">
392+
<div class="d-flex justify-content-between align-items-center">
393+
<div>
394+
<strong>{{key}}</strong>
395+
<span class="badge ml-2" ng-class="{'badge-success': entry.active == 'active', 'badge-danger': entry.active != 'active'}">{{entry.active}}</span>
396+
<i class="fab fa-docker ml-1" style="color: #0db7ed;" title="Managed by Docker Compose"></i>
397+
</div>
398+
</div>
399+
<small class="text-muted">{{entry.description}}</small>
395400
</span>
396401

397-
<span ng-if="!entry.not_available">
402+
<span ng-if="!entry.docker_managed">
398403
<label class="toggle-check" ng-class="{'text-muted': nodeManagement.isDisabledByConflict(key)}">{{key}}
399404
<input type="checkbox" class="toggle-check-input"
400405
ng-model="isActive"
@@ -413,18 +418,26 @@ <h3>Backup daemons and settings:</h3>
413418
</p>
414419
</span>
415420
</li>
421+
422+
<li class="list-group-item text-muted" ng-repeat="(key, entry) in daemons" ng-if="entry.not_available" style="padding: 0.5rem 1.25rem;">
423+
<i class="fas fa-ban mr-2" style="color: #ccc;"></i>
424+
<span>{{key}}</span>
425+
<small class="ml-2" style="color: #aaa;">not available in Docker</small>
426+
</li>
416427
</ul>
417428

418429
<ul class="list-group col-12">
419430
<h3>Folders</h3>
431+
<p ng-if="isDockerized" class="text-info">
432+
<small><i class="fab fa-docker"></i> These are container-internal paths. Volume mounts are configured in <code>docker-compose.yml</code>.</small>
433+
</p>
420434
<form>
421435
<li class="list-group-item input" ng-repeat="(name, entry) in folders">
422-
<!-- <input type="file" onchange="angular.element(this).scope().nodeManagement.updateFolder(event)" name="{{name}}" id="{{name}}-btn" style="display: visible;" webkitdirectory mozdirectory msdirectory odirectory directory multiple > -->
423-
<label style="display: inline;" title={{entry.description}}>{{name}}</label><input type="text" ng-model="folders[name].path" class="w3-input" style="display: inline-block; position: relative; float: right; width: 260px; bg-color: gray;" size="40" title={{entry.description}}> <!--<button style="display: inline-block; position: relative; float: right;" ng-click="nodeManagement.browseFolder(name);"><i class="fa fa-folder-open-o" ></i></button>-->
436+
<label style="display: inline;" title={{entry.description}}>{{name}}</label><input type="text" ng-model="folders[name].path" class="w3-input" style="display: inline-block; position: relative; float: right; width: 260px;" size="40" title={{entry.description}} ng-readonly="isDockerized">
424437
<p>{{entry.description}}</p>
425438
</li>
426439
<br>
427-
<input class="btn btn-success btn-xl" type=submit value="Save" ng-click="nodeManagement.saveFolders()">
440+
<input ng-if="!isDockerized" class="btn btn-success btn-xl" type=submit value="Save" ng-click="nodeManagement.saveFolders()">
428441
</form>
429442
</ul>
430443
<ul class="list-group col-12">

src/node/tests/unit/api/test_node_api.py

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ def mock_ifaddresses(iface):
445445

446446
@patch("os.popen")
447447
def test_get_daemon_status_all_active(self, mock_popen):
448-
"""Test getting daemon status when all active."""
448+
"""Test getting daemon status when all active on bare metal."""
449449
mock_file = Mock()
450450
mock_file.read.return_value = "active\n"
451451
mock_file.__enter__ = Mock(return_value=mock_file)
@@ -457,25 +457,57 @@ def test_get_daemon_status_all_active(self, mock_popen):
457457
# Check a few daemons
458458
self.assertEqual(result["ethoscope_backup_mysql"]["active"], "active")
459459
self.assertFalse(result["ethoscope_backup_mysql"]["not_available"])
460+
self.assertFalse(result["ethoscope_backup_mysql"]["docker_managed"])
460461

461-
@patch("os.popen")
462-
def test_get_daemon_status_docker_filtering(self, mock_popen):
463-
"""Test daemon status filters unavailable daemons in docker."""
462+
@patch("ethoscope_node.api.node_api.NodeAPI._check_container_health")
463+
def test_get_daemon_status_docker_with_containers(self, mock_health):
464+
"""Test daemon status uses TCP health checks in Docker mode."""
464465
self.mock_server.is_dockerized = True
465-
466-
mock_file = Mock()
467-
mock_file.read.return_value = "inactive\n"
468-
mock_file.__enter__ = Mock(return_value=mock_file)
469-
mock_file.__exit__ = Mock(return_value=False)
470-
mock_popen.return_value = mock_file
466+
mock_health.return_value = "active"
471467

472468
result = self.api._get_daemon_status()
473469

474-
# Daemons available in docker should not be marked unavailable
470+
# Daemons with docker_container should be active and docker_managed
471+
self.assertEqual(result["ethoscope_backup_mysql"]["active"], "active")
475472
self.assertFalse(result["ethoscope_backup_mysql"]["not_available"])
473+
self.assertTrue(result["ethoscope_backup_mysql"]["docker_managed"])
474+
475+
# git-daemon.socket and vsftpd now have containers
476+
self.assertEqual(result["git-daemon.socket"]["active"], "active")
477+
self.assertFalse(result["git-daemon.socket"]["not_available"])
478+
self.assertTrue(result["git-daemon.socket"]["docker_managed"])
479+
480+
self.assertEqual(result["vsftpd"]["active"], "active")
481+
self.assertTrue(result["vsftpd"]["docker_managed"])
476482

477-
# Daemons not available in docker should be marked unavailable
483+
@patch("ethoscope_node.api.node_api.NodeAPI._check_container_health")
484+
def test_get_daemon_status_docker_no_container(self, mock_health):
485+
"""Test daemons without containers are not_available in Docker."""
486+
self.mock_server.is_dockerized = True
487+
488+
result = self.api._get_daemon_status()
489+
490+
# Daemons without docker_container should be not_available
478491
self.assertTrue(result["sshd"]["not_available"])
492+
self.assertFalse(result["sshd"]["docker_managed"])
493+
self.assertTrue(result["ntpd"]["not_available"])
494+
self.assertTrue(result["ethoscope_virtuascope"]["not_available"])
495+
496+
# backup_video/sqlite have no container (covered by unified)
497+
self.assertTrue(result["ethoscope_backup_video"]["not_available"])
498+
self.assertTrue(result["ethoscope_backup_sqlite"]["not_available"])
499+
500+
@patch("ethoscope_node.api.node_api.NodeAPI._check_container_health")
501+
def test_get_daemon_status_docker_container_down(self, mock_health):
502+
"""Test Docker container that is unreachable shows as inactive."""
503+
self.mock_server.is_dockerized = True
504+
mock_health.return_value = "inactive"
505+
506+
result = self.api._get_daemon_status()
507+
508+
self.assertEqual(result["ethoscope_backup_mysql"]["active"], "inactive")
509+
self.assertFalse(result["ethoscope_backup_mysql"]["not_available"])
510+
self.assertTrue(result["ethoscope_backup_mysql"]["docker_managed"])
479511

480512
@patch("os.popen")
481513
def test_get_daemon_status_exception(self, mock_popen):
@@ -488,6 +520,39 @@ def test_get_daemon_status_exception(self, mock_popen):
488520
for daemon in result.values():
489521
self.assertEqual(daemon["active"], "unknown")
490522
self.assertFalse(daemon["not_available"])
523+
self.assertFalse(daemon["docker_managed"])
524+
525+
@patch("ethoscope_node.api.node_api.socket.create_connection")
526+
def test_check_container_health_active(self, mock_conn):
527+
"""Test container health check returns active when reachable."""
528+
mock_sock = MagicMock()
529+
mock_conn.return_value.__enter__ = Mock(return_value=mock_sock)
530+
mock_conn.return_value.__exit__ = Mock(return_value=False)
531+
532+
result = self.api._check_container_health("ethoscope-node-backup", 8090)
533+
534+
self.assertEqual(result, "active")
535+
mock_conn.assert_called_once_with(("ethoscope-node-backup", 8090), timeout=2)
536+
537+
@patch("ethoscope_node.api.node_api.socket.create_connection")
538+
def test_check_container_health_inactive(self, mock_conn):
539+
"""Test container health check returns inactive when unreachable."""
540+
mock_conn.side_effect = ConnectionRefusedError()
541+
542+
result = self.api._check_container_health("ethoscope-node-backup", 8090)
543+
544+
self.assertEqual(result, "inactive")
545+
546+
@patch("ethoscope_node.api.node_api.socket.create_connection")
547+
def test_check_container_health_timeout(self, mock_conn):
548+
"""Test container health check returns inactive on timeout."""
549+
import socket as _socket
550+
551+
mock_conn.side_effect = _socket.timeout()
552+
553+
result = self.api._check_container_health("ethoscope-node-backup", 8090)
554+
555+
self.assertEqual(result, "inactive")
491556

492557
@patch("ethoscope_node.api.node_api.BaseAPI.get_request_json")
493558
@patch("os.popen")
@@ -765,6 +830,14 @@ def test_toggle_daemon_stop(self, mock_popen):
765830
mock_log.assert_called_once()
766831
self.assertIn("Stopping", mock_log.call_args[0][0])
767832

833+
def test_toggle_daemon_blocked_in_docker(self):
834+
"""Test toggling daemon is blocked in Docker mode."""
835+
self.mock_server.is_dockerized = True
836+
837+
result = self.api._toggle_daemon("ethoscope_backup_mysql", True)
838+
839+
self.assertIn("Docker Compose", result)
840+
768841

769842
if __name__ == "__main__":
770843
unittest.main()

0 commit comments

Comments
 (0)