Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions emhttp/plugins/dynamix.docker.manager/DockerContainers.page
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ function LockButton() {
$('#docker_list').sortable('destroy');
}
}
// Don't poll unless the page is actually open
function scheduleNextLoad(delay) {
if (typeof document.hidden !== 'undefined' && document.hidden) {
var onVisible = function() {
if (document.hidden) return;
document.removeEventListener('visibilitychange', onVisible);
loadlist();
};
document.addEventListener('visibilitychange', onVisible);
return;
}
setTimeout(loadlist, delay);
}
function loadlist(init) {
timers.docker = setTimeout(function(){$('div.spinner.fixed').show('slow');},500);
docker = [];
Expand All @@ -141,7 +154,7 @@ function loadlist(init) {
instance.content(TScontent);
}
});
$('head').append('<script>'+data[1]+'<\/script>');
(new Function(data[1]))();
$('.iconstatus').each(function(){
if ($(this).hasClass('stopped')) $('div.'+$(this).prop('id')).hide();
});
Expand All @@ -165,7 +178,7 @@ function loadlist(init) {
}
listview();
$('div.spinner.fixed').hide('slow');
if (data[2]==1) {$('#busy').show(); setTimeout(loadlist,5000);} else if ($('#busy').is(':visible')) {$('#busy').hide(); setTimeout(loadlist,3000);}
if (data[2]==1) {$('#busy').show(); scheduleNextLoad(5000);} else if ($('#busy').is(':visible')) {$('#busy').hide(); scheduleNextLoad(3000);}
<?if (_var($display,'resize')):?>
function resizeTableColumns() { // Handle table header fixed positioning after resize
$('#docker_containers thead,#docker_containers tbody').removeClass('fixed');
Expand Down Expand Up @@ -208,6 +221,9 @@ dockerload.on('message', function(msg){
$('#cpu-'+id[0]).css('width',w1);
}
});
// We don't need the event from the subscription. It's just to keep the worker running.
var tailscaleStatus = new NchanSubscriber('/sub/tailscalestatus',{subscriber:'websocket'});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add tailscale_status to Docker page Nchan scripts

This new subscription assumes a publisher is already running, but DockerContainers.page still declares only Nchan="docker_load" in its header, so DefaultPageLayout never starts nchan/tailscale_status. On a normal page load there is therefore no process publishing tailscalestatus, the cache file is never refreshed, and Tailscale status/tooltips regress to stale or empty data for running containers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

@elibosley elibosley May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tspader - this seems like a legitimate finding to me, can you verify and look into adding this to the dockercontainers.page

-Nchan="docker_load"
+Nchan="docker_load,tailscale_status"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required for dynamix to be able to stop the scripts on array stop etc.

tailscaleStatus.on('message', function(msg){});
$(function() {
$('.advancedview').switchButton({labels_placement:'left', on_label:"_(Advanced View)_", off_label:"_(Basic View)_", checked:$.cookie('docker_listview_mode')=='advanced'});
$('.advancedview').change(function(){
Expand All @@ -219,6 +235,7 @@ $(function() {
$.removeCookie('lockbutton');
loadlist(true);
dockerload.start();
tailscaleStatus.start();
});

</script>
76 changes: 55 additions & 21 deletions emhttp/plugins/dynamix.docker.manager/include/DockerClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public function getUserTemplatePath($Container) {
return $match ?: "$dir/$target";
}

public function downloadTemplates($Dest=null, $Urls=null) {
public function downloadTemplates($Dest=null, $Urls=null) {
/* Don't download any templates. Leave code in place for future reference. */
/* remove existing limetech templates that are all not valid */
exec("rm -rf /boot/config/plugins/dockerMan/templates/limetech");
Expand Down Expand Up @@ -309,17 +309,11 @@ private function getControlURL(&$ct, $myIP, $WebUI) {
}

private function getTailscaleJson($name) {
$TS_raw = [];
exec("docker exec -i ".$name." /bin/sh -c \"tailscale status --peers=false --json\" 2>/dev/null", $TS_raw);
if (!empty($TS_raw)) {
$TS_raw = implode("\n", $TS_raw);
return json_decode($TS_raw, true);
}
return '';
return DockerUtil::tailscaleStatus($name) ?: '';
}

public function getAllInfo($reload=false,$com=true,$communityApplications=false) {
global $driver, $dockerManPaths;
global $driver, $dockerManPaths, $docroot;
$DockerClient = new DockerClient();
$DockerUpdate = new DockerUpdate();
$host = DockerUtil::host();
Expand All @@ -335,11 +329,22 @@ public function getAllInfo($reload=false,$com=true,$communityApplications=false)
$tmp['autostart'] = in_array($name,$autoStart);
$tmp['cpuset'] = $ct['CPUset'];
$tmp['url'] = $ct['Url'] ?? $tmp['url'] ?? '';
// read docker label for WebUI & Icon
if (isset($ct['Icon'])) $tmp['icon'] = $ct['Icon'];
// Docker label for Shell.
if (isset($ct['Shell'])) $tmp['shell'] = $ct['Shell'];
// Pass the label URL as a download fallback only when there's no
// usable cached file. Otherwise, we'll check if the URL is a file,
// obviously fail, and search through every template in /boot
// looking for a match. Bad.
$labelIconUrl = $ct['Icon'] ?? null;
if (!$communityApplications) {
if (!is_file($tmp['icon']) || $reload) $tmp['icon'] = $this->getIcon($image,$name,$tmp['icon']);
$iconExists = !empty($tmp['icon'])
&& (is_file($tmp['icon']) || is_file($docroot . $tmp['icon']));
if (!$iconExists || $reload) {
$tmp['icon'] = $this->getIcon($image, $name, $labelIconUrl ?: ($tmp['icon'] ?? ''));
// Explicitly return the fallback asset, so that subsequent polls see
// the local file instead of rerunning the expensive template scan
if (empty($tmp['icon'])) $tmp['icon'] = '/plugins/dynamix.docker.manager/images/question.png';
}
}
if ($ct['Running']) {
$port = &$ct['Ports'][0];
Expand Down Expand Up @@ -629,7 +634,7 @@ public function inspectLocalVersion($image) {
$DockerClient = new DockerClient();
$inspect = $DockerClient->getDockerJSON('/images/'.$image.'/json');
if (empty($inspect['RepoDigests'])) return null;

$repoDigest = $inspect['RepoDigests'][array_key_last($inspect['RepoDigests'])];
$shaPos = strpos($repoDigest, '@sha256:');
if ($shaPos === false) return null;
Expand Down Expand Up @@ -1186,9 +1191,11 @@ public static function myIP($name, $version=4) {
}

public static function driver() {
static $cached;
if ($cached !== null) return $cached;
$list = [];
foreach (static::docker("network ls --format='{{.Name}}={{.Driver}}'",true) as $network) {[$net,$driver] = array_pad(explode('=',$network),2,''); $list[$net] = $driver;}
return $list;
return $cached = $list;
}

public static function custom() {
Expand All @@ -1211,18 +1218,45 @@ public static function ctMap($ct, $type='Name') {
}

public static function port() {
if (lan_port('br0')) return 'br0';
if (lan_port('bond0')) return 'bond0';
if (lan_port('eth0')) return 'eth0';
if (lan_port('wlan0')) return 'wlan0';
return '';
static $cached;
if ($cached !== null) return $cached;
if (lan_port('br0')) return $cached = 'br0';
if (lan_port('bond0')) return $cached = 'bond0';
if (lan_port('eth0')) return $cached = 'eth0';
if (lan_port('wlan0')) return $cached = 'wlan0';
return $cached = '';
}

public static function host() {
static $cached;
if ($cached !== null) return $cached;
$port = static::port();
if (!$port) return '';
if (!$port) return $cached = '';
$port = lan_port($port,true)!=1 && lan_port('wlan0') ? 'wlan0' : $port;
return exec("ip -br -4 addr show $port scope global | sed -r 's/\/[0-9]+//g' | awk '{print $3;exit}'");
return $cached = exec("ip -br -4 addr show $port scope global | sed -r 's/\/[0-9]+//g' | awk '{print $3;exit}'");
}

const TAILSCALE_CACHE_FILE = '/var/lib/docker/unraid-tailscale-status.json';

public static function tailscaleCache(): array {
static $cached;
if (is_array($cached)) return $cached;
if (!is_file(self::TAILSCALE_CACHE_FILE)) return $cached = [];
$data = @json_decode(@file_get_contents(self::TAILSCALE_CACHE_FILE), true);
return $cached = (is_array($data) ? $data : []);
}

public static function tailscaleStatus(string $name) {
return static::tailscaleCache()['containers'][$name]['data'] ?? null;
}

public static function tailscaleDerpMap() {
return static::tailscaleCache()['derp'] ?? null;
}

public static function tailscaleLatestVersion(): ?string {
$v = static::tailscaleCache()['version'] ?? null;
return is_array($v) ? ($v['TarballsVersion'] ?? null) : null;
}
}
?>
51 changes: 4 additions & 47 deletions emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,54 +51,11 @@
$autostart = (array)@file($autostart_file,FILE_IGNORE_NEW_LINES);
$names = array_map('var_split',$autostart);

// Grab Tailscale json from container
function tailscale_stats($name) {
exec("docker exec -i ".$name." /bin/sh -c \"tailscale status --json | jq '{Self: .Self, ExitNodeStatus: .ExitNodeStatus, Version: .Version}'\" 2>/dev/null", $TS_stats);
if (!empty($TS_stats)) {
$TS_stats = implode("\n", $TS_stats);
return json_decode($TS_stats, true);
}
return '';
}

// Download Tailscal JSON and return Array, refresh file if older than 24 hours
function tailscale_json_dl($file, $url) {
$dl_status = 0;
if (!is_dir('/tmp/tailscale')) {
mkdir('/tmp/tailscale', 0777, true);
}
if (!file_exists($file)) {
exec("wget -T 3 -q -O ".$file." ".$url, $output, $dl_status);
} else {
$fileage = time() - filemtime($file);
if ($fileage > 86400) {
unlink($file);
exec("wget -T 3 -q -O ".$file." ".$url, $output, $dl_status);
}
}
if ($dl_status === 0) {
return json_decode(@file_get_contents($file), true);
} elseif ($dl_status === 0 && is_file($file)) {
return json_decode(@file_get_contents($file), true);
} else {
unlink($file);
return '';
}
}

// Grab Tailscale DERP map JSON
$TS_derp_url = 'https://login.tailscale.com/derpmap/default';
$TS_derp_file = '/tmp/tailscale/tailscale-derpmap.json';
$TS_derp_list = tailscale_json_dl($TS_derp_file, $TS_derp_url);

// Grab Tailscale version JSON
$TS_version_url = 'https://pkgs.tailscale.com/stable/?mode=json';
$TS_version_file = '/tmp/tailscale/tailscale-latest-version.json';
// Extract tarbal version string
$TS_latest_version = tailscale_json_dl($TS_version_file, $TS_version_url);
if (!empty($TS_latest_version)) {
$TS_latest_version = $TS_latest_version["TarballsVersion"];
return DockerUtil::tailscaleStatus($name) ?: '';
}
$TS_derp_list = DockerUtil::tailscaleDerpMap() ?: '';
$TS_latest_version = DockerUtil::tailscaleLatestVersion() ?: '';

function my_lang_time($text) {
[$number, $text] = my_explode(' ',$text,2);
Expand Down Expand Up @@ -163,7 +120,7 @@ function my_lang_log($text) {
} elseif (!isset($ct['Ports']['vlan']) || strpos($ct['NetworkMode'],'container:')!==false) {
foreach ($ct['Ports'] as $port) {
if (_var($port,'PublicPort') && _var($port,'Driver') == 'bridge') {
if (_var($port, "HostIp") != "") $hostip = _var($port, "HostIp"); else $hostip = $host;
if (_var($port, "HostIp") != "") $hostip = _var($port, "HostIp"); else $hostip = $host;
$ports_external[] = sprintf('%s:%s', $hostip, strtoupper(_var($port,'PublicPort')));
}
if ((!isset($ct['Networks']['host'])) || (!isset($ct['Networks']['vlan']))) {
Expand Down
123 changes: 123 additions & 0 deletions emhttp/plugins/dynamix.docker.manager/nchan/tailscale_status
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/php -q
<?PHP
/* Copyright 2005-2025, Lime Technology
* Copyright 2012-2025, Bergware International.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* A JSON cache of Tailscale status for each running container, plus some
* container agnostic metadata pulled from the network.
*/
?>
<?
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
require_once "$docroot/webGui/include/publish.php";

const TS_CACHE_FILE = '/var/lib/docker/unraid-tailscale-status.json';
const TS_DERP_URL = 'https://login.tailscale.com/derpmap/default';
const TS_VERSION_URL = 'https://pkgs.tailscale.com/stable/?mode=json';
const TS_METADATA_REFRESH_S = 6 * 60 * 60;
const TS_POLL_INTERVAL_S = 10;
const TS_IDLE_INTERVAL_S = 60;
const TS_EXEC_TIMEOUT_S = 1;

function ts_fetch_json(string $url, int $timeout = 5) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_FAILONERROR => true,
]);
$out = curl_exec($ch);
curl_close($ch);
if (!$out) return null;
$data = json_decode($out, true);
return is_array($data) ? $data : null;
}

// Gather containers with the label "tailscale.hostname" or "tailscale.webui", since
// these are what trigger a request for status upstream
function ts_list_containers(): array {
$names = [];
foreach (['hostname', 'webui'] as $kind) {
$raw = shell_exec('docker ps --filter "label=net.unraid.docker.tailscale.' . $kind . '" --format "{{.Names}}" 2>/dev/null') ?? '';
foreach (preg_split('/\R/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $name) {
$names[trim($name)] = true;
}
}
return array_keys($names);
}

function ts_container_status(string $name) {
$out = [];
$rc = 0;
$cmd = sprintf(
'timeout %ds docker exec -i %s /bin/sh -c %s 2>/dev/null',
TS_EXEC_TIMEOUT_S,
escapeshellarg($name),
escapeshellarg('tailscale status --peers=false --json')
);
exec($cmd, $out, $rc);
if ($rc !== 0 || empty($out)) return null;
$data = json_decode(implode("\n", $out), true);
return is_array($data) ? $data : null;
}

function ts_load_state(): array {
$state = ['derp' => null, 'derp_ts' => 0, 'version' => null, 'version_ts' => 0, 'containers' => [], 'ts' => 0];
if (is_file(TS_CACHE_FILE)) {
$existing = @json_decode(@file_get_contents(TS_CACHE_FILE), true);
if (is_array($existing)) $state = array_replace($state, array_intersect_key($existing, $state));
}
return $state;
}

function ts_save_state(array $state): void {
$tmp = TS_CACHE_FILE . '.tmp';
if (@file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_SLASHES)) !== false) {
@rename($tmp, TS_CACHE_FILE);
}
}

$state = ts_load_state();

while (true) {
$now = time();

// Tailscale metadata
if ($now - ($state['derp_ts'] ?? 0) > TS_METADATA_REFRESH_S) {
$derp = ts_fetch_json(TS_DERP_URL);
if ($derp) { $state['derp'] = $derp; $state['derp_ts'] = $now; }
}
if ($now - ($state['version_ts'] ?? 0) > TS_METADATA_REFRESH_S) {
$version = ts_fetch_json(TS_VERSION_URL);
if ($version) { $state['version'] = $version; $state['version_ts'] = $now; }
}

// Per-container status
$containers = ts_list_containers();
$fresh = [];
foreach ($containers as $name) {
$status = ts_container_status($name);
if ($status !== null) {
$fresh[$name] = ['data' => $status, 'ts' => $now];
} elseif (isset($state['containers'][$name]) && ($now - $state['containers'][$name]['ts']) < 60) {
$fresh[$name] = $state['containers'][$name];
}
}
$state['containers'] = $fresh;
$state['ts'] = $now;

ts_save_state($state);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this state hasn't changed, probably not worth saving, you can do the MD5 comparison and share the logic with the publish call as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of saving it at all, just store it in a static var. Also has the advantage of not having anything cached that survives an array stop / start or reboot


// Notify any watchers that the cache moved. We could push actual data here
// to get live tooltip updates, but that's overkill. A notification is all
// that we really want.
publish('tailscalestatus', json_encode(['ts' => $now]), 1, true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For save and publish maybe worth only doing if it has changed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarification - we typically save an MD5 of the payload and then compare before publishing again, to preserve publish cycles. This is a simple optimization that may help out here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Publishing has to be done @SimonFair @elibosley @tspader

See my full comment #2641 (comment)

sleep(empty($containers) ? TS_IDLE_INTERVAL_S : TS_POLL_INTERVAL_S);
}
Loading