Files
unraid-podman/webui/plugins/podman/ajax/system.php
T
maggesandClaude Sonnet 5 23898ff62e
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 6s
Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs
Dashboard:
- Plain-count stat tiles (Running/Stopped/Pods/Images/Volumes/Networks)
  separated from a single "Resource Usage" card (CPU/Memory/Swap/Storage
  meter rows) instead of forcing both into one tile grid, which produced
  awkward spanning-tile/dead-cell layouts.
- Fixed CPU usage never changing (libpod's own cpuUtilization is computed
  once and never resampled) by computing it from /proc/stat deltas instead.
- Fixed memory usage reading far too high by using /proc/meminfo's
  MemAvailable instead of libpod's raw (non-reclaimable-aware) memFree.
- Added an Autostart Queue table reusing podman-autostart.sh's own
  failure-counter files.
- Dashboard and Containers now auto-refresh every ~2s (paused when the
  tab is hidden or a modal is open).

Toasts:
- Real success/warn/error/info toast notifications replacing every
  alert() used for one-way feedback, across every panel.

Container detail modal:
- 5 new tabs: Resources, Logs, Console, Events, Healthcheck.

Containers panel:
- Folders to group containers (name + icon), stored in the plugin's own
  folders.json — a folder's header always shows an icon+name+status chip
  per member, matching Unraid's own Docker page folders. "Move to
  Folder" becomes "Remove from Folder" once a container is already
  grouped.
- Containers can carry an icon URL and a WebUI URL (small button next to
  the name), both stored as container labels and auto-filled from
  templates where applicable.
- Settings: an "Add container" control for the Autostart order table.

Fixes:
- Context menus now measure their own rendered size and flip above the
  anchor when there isn't room below, instead of running off-screen.
- Containers table now uses table-layout:fixed with explicit column
  widths — auto layout was shifting every column (and the header) on
  every folder expand/collapse, and briefly again when a flex wrapper
  was mistakenly placed directly on a <td>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:32:34 +00:00

270 lines
10 KiB
PHP

<?php
/**
* ajax/system.php
*
* Backs the Dashboard panel's summary tiles and the service-status line
* shown in the page header — aggregates across several libpod endpoints
* into the one shape javascript/dashboard.js needs, so the frontend
* doesn't have to make (and wait on) five separate round trips.
*
* Actions (?action=...):
* summary GET -> counts, storage usage, engine version, ping status
* autostart_queue GET -> the autostart list in start order, each entry's
* configured delay, and its last-known outcome (reusing
* plugin/sbin/podman-autostart.sh's own failure-counter
* files rather than tracking a second copy of that state)
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'summary':
podman_json_response(system_summary($client, $podmanConfig));
break;
case 'autostart_queue':
podman_json_response(system_autostart_queue($client, $podmanConfig));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<string,mixed> */
function system_summary(PodmanClient $client, PodmanConfig $config): array
{
if (!$client->ping()) {
return [
'reachable' => false,
'socketPath' => $config->socketPath,
];
}
$containers = $client->listContainers(true);
$running = 0;
foreach ($containers as $c) {
if (strtolower((string) ($c['State'] ?? '')) === 'running') {
$running++;
}
}
$pods = $client->listPods();
$images = $client->listImages();
$volumes = $client->listVolumes();
$networks = $client->listNetworks();
$info = $client->info();
$df = $client->systemDf();
$imagesSize = 0;
foreach (($df['Images'] ?? []) as $img) {
$imagesSize += (int) ($img['Size'] ?? 0);
}
$host = $info['host'] ?? [];
$store = $info['store'] ?? [];
$meminfo = podman_read_meminfo();
// libpod's host.memFree/swapFree are the kernel's raw "free" counters —
// they exclude reclaimable buffers/cache, so on a host that's been up a
// while they make used memory look dramatically higher than reality
// (found live: 67% "used" here vs. the 19% `free -h` actually reports).
// /proc/meminfo's MemAvailable is the same estimate `free -h`'s
// "available" column and most monitoring tools use, so read it
// directly instead of trusting libpod's numbers for this.
$memTotal = $meminfo['MemTotal'] ?? (int) ($host['memTotal'] ?? 0);
$memAvailable = $meminfo['MemAvailable'] ?? (int) ($host['memFree'] ?? 0);
$swapTotal = $meminfo['SwapTotal'] ?? (int) ($host['swapTotal'] ?? 0);
$swapFree = $meminfo['SwapFree'] ?? (int) ($host['swapFree'] ?? 0);
$graphAllocated = (int) ($store['graphRootAllocated'] ?? 0);
$graphUsed = (int) ($store['graphRootUsed'] ?? 0);
return [
'reachable' => true,
'socketPath' => $config->socketPath,
// libpod's /info nests the version block under lowercase "version"
// (unlike most other libpod endpoints, which are PascalCase
// throughout) — verified live against a real podman system service.
'podmanVersion' => $info['version']['Version'] ?? null,
'containers' => [
'total' => count($containers),
'running' => $running,
'stopped' => count($containers) - $running,
],
'pods' => count($pods),
'images' => count($images),
'volumes' => count($volumes),
'networks' => count($networks),
'storage' => [
'imagesSizeBytes' => $imagesSize,
'imagesSizeFormatted' => podman_format_bytes($imagesSize),
'graphUsedBytes' => $graphUsed,
'graphAllocatedBytes' => $graphAllocated,
'graphUsedFormatted' => podman_format_bytes($graphUsed),
'graphAllocatedFormatted' => podman_format_bytes($graphAllocated),
'graphUsedPercent' => $graphAllocated > 0 ? round($graphUsed / $graphAllocated * 100, 1) : null,
],
'host' => [
'cpuCount' => (int) ($host['cpus'] ?? 0),
'cpuPercent' => podman_read_cpu_percent() ?? (isset($host['cpuUtilization']['idlePercent'])
? round(100 - (float) $host['cpuUtilization']['idlePercent'], 1)
: null),
'memUsedBytes' => max(0, $memTotal - $memAvailable),
'memTotalBytes' => $memTotal,
'memUsedFormatted' => podman_format_bytes(max(0, $memTotal - $memAvailable)),
'memTotalFormatted' => podman_format_bytes($memTotal),
'memPercent' => $memTotal > 0 ? round(($memTotal - $memAvailable) / $memTotal * 100, 1) : null,
'swapUsedBytes' => max(0, $swapTotal - $swapFree),
'swapTotalBytes' => $swapTotal,
'uptime' => (string) ($host['uptime'] ?? ''),
],
];
}
/**
* The Dashboard's "Autostart Queue" table: same list/order Settings already
* reads via autostart_read() in settings.php, enriched with each entry's
* configured post-start delay and its last-known outcome — derived from
* plugin/sbin/podman-autostart.sh's own per-container failure-counter
* files (under autostart-failures/<name>) plus the container's actual
* current state, rather than a second, separately-tracked history.
*
* @return array<string,mixed>
*/
function system_autostart_queue(PodmanClient $client, PodmanConfig $config): array
{
if (!is_readable($config->autostartFile)) {
return ['entries' => []];
}
$names = [];
foreach (file($config->autostartFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim(preg_replace('/#.*$/', '', $line) ?? '');
if ($line !== '') {
$names[] = $line;
}
}
if (!$names) {
return ['entries' => []];
}
$delays = [];
if (is_readable($config->autostartDelayFile)) {
foreach (file($config->autostartDelayFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (preg_match('/^([^=]+)=(\d+)$/', trim($line), $m)) {
$delays[$m[1]] = (int) $m[2];
}
}
}
// Mirrors podman-autostart.sh's own MAX_CONSECUTIVE_FAILURES — that
// script is what actually decides when a container is Safe-Mode-paused;
// this only needs to agree on the same threshold to describe it
// accurately, not to make the decision itself.
$maxFailures = 3;
$failuresDir = $config->bootDir . '/autostart-failures';
$stateByName = [];
foreach ($client->listContainers(true) as $c) {
foreach (($c['Names'] ?? []) as $n) {
$stateByName[ltrim((string) $n, '/')] = strtolower((string) ($c['State'] ?? ''));
}
}
$entries = [];
foreach ($names as $i => $name) {
$failCount = 0;
$failFile = $failuresDir . '/' . $name;
if (is_readable($failFile)) {
$failCount = (int) trim((string) file_get_contents($failFile));
}
if ($failCount >= $maxFailures) {
$status = 'safe-mode';
$label = "Safe-Mode ({$failCount} failures)";
} elseif ($failCount > 0) {
$status = 'failed';
$label = "Failed ({$failCount}/{$maxFailures})";
} elseif (($stateByName[$name] ?? '') === 'running') {
$status = 'started';
$label = 'Started';
} elseif (array_key_exists($name, $stateByName)) {
$status = 'stopped';
$label = 'Not running';
} else {
$status = 'unknown';
$label = 'Container not found';
}
$entries[] = [
'position' => $i + 1,
'name' => $name,
'delaySeconds' => $delays[$name] ?? 0,
'status' => $status,
'statusLabel' => $label,
];
}
return ['entries' => $entries];
}
/**
* Reads the fields we need out of /proc/meminfo directly, in bytes.
* ajax/*.php runs as PHP-FPM on the Unraid host itself (not inside a
* container), so this is just a local file read — no shelling out needed.
*
* @return array<string,int>
*/
function podman_read_meminfo(): array
{
$lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$out = [];
foreach ($lines as $line) {
if (preg_match('/^(MemTotal|MemAvailable|SwapTotal|SwapFree):\s*(\d+)\s*kB$/', $line, $m)) {
$out[$m[1]] = ((int) $m[2]) * 1024;
}
}
return $out;
}
/**
* Live CPU usage via the classic /proc/stat delta technique: libpod's own
* /info.host.cpuUtilization turned out to be a value computed once and
* never resampled (verified live — three /info calls several seconds
* apart returned byte-identical numbers), which is why the Dashboard's
* CPU tile never moved. A single instantaneous read of /proc/stat can't
* give a percentage on its own either (its counters are cumulative
* jiffies since boot) — it needs two samples to diff. Since the Dashboard
* already re-fetches this endpoint every ~2s via auto-refresh, the
* previous sample is persisted to a small state file and diffed against
* the current one on each call, exactly like `top`/`htop` do between
* their own refresh ticks. Returns null (falls back to libpod's static
* figure) only on the very first call, before any previous sample exists.
*/
function podman_read_cpu_percent(): ?float
{
$stat = @file_get_contents('/proc/stat');
if ($stat === false || !preg_match('/^cpu\s+(\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+)/m', $stat, $m)) {
return null;
}
[, $user, $nice, $system, $idle, $iowait, $irq, $softirq, $steal] = array_map('intval', $m);
$total = $user + $nice + $system + $idle + $iowait + $irq + $softirq + $steal;
$idleAll = $idle + $iowait;
$stateFile = '/var/tmp/podman-cpu-sample';
$prevRaw = @file_get_contents($stateFile);
@file_put_contents($stateFile, $total . ' ' . $idleAll, LOCK_EX);
if ($prevRaw === false || !preg_match('/^(\d+) (\d+)$/', trim($prevRaw), $pm)) {
return null;
}
$deltaTotal = $total - (int) $pm[1];
$deltaIdle = $idleAll - (int) $pm[2];
if ($deltaTotal <= 0) {
return null;
}
return round((1 - $deltaIdle / $deltaTotal) * 100, 1);
}