Add reproducible build system, native Unraid plugin, and WebUI
- versions.env pins podman, conmon, crun, netavark, aardvark-dns, passt, and fuse-overlayfs to verified upstream source checksums; SlackBuild recipes, scripts/build-packages.sh, checksums.sh, release.sh, and update-versions.sh implement the reproducible pipeline; GitHub Actions workflows build in a Slackware container and publish releases without committing any binaries. - plugin/podman.plg installs/updates/removes all eight packages (the seven components plus the plugin's own unraid-podman scaffolding package) via upgradepkg, using the official Unraid array-event hook mechanism (event/disks_mounted, event/stopping) instead of editing /boot/config/go. rc.podman and the sbin/ helper scripts implement storage creation, config seeding/sync, preflight checks, autostart with per-container Safe-Mode, and package verify/update/rollback. - webui/plugins/podman implements the Dashboard, Containers, Pods, Images, Volumes, Networks, Logs, Terminal, Compose, and Settings panels against the approved mockup (webui/mockups/prototype.html), talking to podman system service exclusively via PodmanClient.php (libpod REST API over the Unix socket), with two documented exceptions: Terminal's one-shot exec model and Compose's use of the podman compose CLI, since libpod has no REST equivalent for either. - docs/ARCHITECTURE.md and docs/ROADMAP.md record the design decisions and honest current status (syntax-checked, unit- and integration-tested against fake sockets/servers; not yet run against a real Unraid/Podman/Slackware system). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# webui/
|
||||
|
||||
Dynamix-style WebUI pages, following Unraid's plugin GUI convention of
|
||||
`/usr/local/emhttp/plugins/<name>/`. `webui/plugins/podman/` is staged to
|
||||
that path by the `unraid-podman` scaffolding package (see
|
||||
`packages/unraid-podman/unraid-podman.SlackBuild`), which podman.plg installs
|
||||
alongside the seven compiled components.
|
||||
|
||||
**Status: implemented**, covering all ten sections from
|
||||
[docs/ARCHITECTURE.md, section 18](../docs/ARCHITECTURE.md#18-zukünftige-webui):
|
||||
Dashboard, Containers, Pods, Images, Volumes, Networks, Logs, Terminal,
|
||||
Compose, Settings. Not yet exercised against a real Unraid/Podman install —
|
||||
see [docs/ROADMAP.md](../docs/ROADMAP.md) for what "implemented" does and
|
||||
doesn't cover yet.
|
||||
|
||||
`webui/mockups/prototype.html` is the static, non-PHP clickable mockup this
|
||||
implementation was built against — kept as the visual reference; it is not
|
||||
staged into the package.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
webui/
|
||||
├── mockups/
|
||||
│ └── prototype.html # static approved mockup, not shipped
|
||||
└── plugins/
|
||||
└── podman/
|
||||
├── Podman.page # page shell: header, sub-nav, one container per panel
|
||||
├── include/
|
||||
│ ├── PodmanClient.php # libpod REST API client (talks to podman.sock only)
|
||||
│ ├── Config.php # reads podman.cfg (mirrors podman-common.sh)
|
||||
│ ├── bootstrap.php # shared include + error handling for ajax/*.php
|
||||
│ └── helpers.php # formatting + JSON-response helpers
|
||||
├── ajax/ # one endpoint per resource, each require()s bootstrap.php
|
||||
│ ├── containers.php # list/start/stop/restart/remove/logs
|
||||
│ ├── pods.php
|
||||
│ ├── images.php
|
||||
│ ├── volumes.php
|
||||
│ ├── networks.php
|
||||
│ ├── exec.php # Terminal — see its header comment for API scope
|
||||
│ ├── compose.php # Compose — the one deliberate CLI exception, see header
|
||||
│ ├── settings.php # plugin's own config, not a libpod resource
|
||||
│ └── system.php # Dashboard aggregation
|
||||
├── javascript/
|
||||
│ ├── app.js # shared AJAX helper + sub-tab router
|
||||
│ └── <panel>.js # one module per panel, registers with app.js
|
||||
├── styles/podman.css # design tokens ported 1:1 from the mockup
|
||||
├── event/ # official Unraid array-event hooks (see plugin/event/)
|
||||
└── images/ # plugin icon assets
|
||||
```
|
||||
|
||||
## Design constraints (see ARCHITECTURE.md for full rationale)
|
||||
|
||||
- Every panel talks to `podman system service` over its Unix socket via
|
||||
`PodmanClient` — no `exec()`/`shell_exec()` of the `podman` binary
|
||||
anywhere in `include/` or in the Containers/Pods/Images/Volumes/Networks/
|
||||
Logs endpoints.
|
||||
- **Two documented, deliberate exceptions**, not oversights:
|
||||
- `ajax/exec.php` (Terminal) uses the real exec REST API, but as
|
||||
one-command-in/output-out rather than a true interactive PTY — libpod's
|
||||
interactive exec needs a persistent hijacked connection that doesn't
|
||||
fit PHP-FPM's request lifecycle. See that file's header comment.
|
||||
- `ajax/compose.php` (Compose) shells out to the `podman compose` CLI via
|
||||
`proc_open()` with an argv array (never a shell string) — because no
|
||||
REST endpoint for Compose exists in libpod at all. See that file's
|
||||
header comment.
|
||||
- The API socket is root-equivalent; no unauthenticated network exposure
|
||||
beyond what Unraid's own WebUI auth already provides — see
|
||||
[.github/SECURITY.md](../.github/SECURITY.md).
|
||||
- Dark/light mode via CSS custom properties (`prefers-color-scheme` +
|
||||
`[data-theme]` override), matching Unraid's own theme mechanism — no
|
||||
separate theme toggle inside the plugin page.
|
||||
@@ -0,0 +1,949 @@
|
||||
<title>unraid-podman — WebUI Mockup</title>
|
||||
<style>
|
||||
/* =========================================================================
|
||||
Design tokens
|
||||
Grounded in Unraid's real webGUI chrome: near-black masthead, a warm
|
||||
construction-orange brand accent, dense admin-tool typography via the
|
||||
OS's own system font stack (Unraid itself ships no custom webfont).
|
||||
Semantic status colors (good/warn/bad/neutral) are kept separate from
|
||||
the brand accent, per the container-fleet domain's own vocabulary.
|
||||
========================================================================= */
|
||||
:root {
|
||||
--bg: #eef0f2;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4f5f7;
|
||||
--surface-3: #e9ebee;
|
||||
--border: #dde1e6;
|
||||
--text: #1c2024;
|
||||
--text-dim: #5b6572;
|
||||
--text-faint: #8a94a1;
|
||||
--accent: #d8541a;
|
||||
--accent-strong: #b8420f;
|
||||
--accent-contrast: #fff8f3;
|
||||
--good: #1a8f4c;
|
||||
--good-bg: #e4f6ea;
|
||||
--warn: #9a6b00;
|
||||
--warn-bg: #fdf1d6;
|
||||
--bad: #c22b3a;
|
||||
--bad-bg: #fbe6e8;
|
||||
--neutral: #5b6572;
|
||||
--neutral-bg: #e9ebee;
|
||||
--shadow: 0 1px 2px rgba(20, 22, 26, 0.06), 0 4px 12px rgba(20, 22, 26, 0.05);
|
||||
--mast-bg: #16181c;
|
||||
--mast-fg: #d7dae0;
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #15171b;
|
||||
--surface: #1c1f24;
|
||||
--surface-2: #23262c;
|
||||
--surface-3: #2b2f36;
|
||||
--border: #34383f;
|
||||
--text: #e7e9ec;
|
||||
--text-dim: #9aa1ab;
|
||||
--text-faint: #6b7280;
|
||||
--accent: #ef7f3f;
|
||||
--accent-strong: #f6975f;
|
||||
--accent-contrast: #1a1002;
|
||||
--good: #4cc785;
|
||||
--good-bg: #123322;
|
||||
--warn: #e0b23d;
|
||||
--warn-bg: #3a2e0d;
|
||||
--bad: #ef6470;
|
||||
--bad-bg: #3a1519;
|
||||
--neutral: #9aa1ab;
|
||||
--neutral-bg: #2b2f36;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
--mast-bg: #0e0f11;
|
||||
--mast-fg: #cdd1d7;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36;
|
||||
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
|
||||
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
|
||||
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
|
||||
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
|
||||
--mast-bg: #0e0f11; --mast-fg: #cdd1d7;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee;
|
||||
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
|
||||
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
|
||||
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
|
||||
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
|
||||
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
|
||||
--mast-bg: #16181c; --mast-fg: #d7dae0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3 { text-wrap: balance; margin: 0; font-weight: 600; }
|
||||
::selection { background: var(--accent); color: var(--accent-contrast); }
|
||||
a { color: inherit; }
|
||||
button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
/* ---- Masthead (real Unraid top bar, non-interactive context) ---- */
|
||||
.mast {
|
||||
background: var(--mast-bg);
|
||||
color: var(--mast-fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 16px;
|
||||
height: 46px;
|
||||
}
|
||||
.mast .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; letter-spacing: .02em; }
|
||||
.mast .brand .mark {
|
||||
width: 22px; height: 22px; border-radius: 5px;
|
||||
background: linear-gradient(155deg, var(--accent), var(--accent-strong));
|
||||
display: grid; place-items: center; color: #fff; font-size: 11px; font-weight: 800;
|
||||
}
|
||||
.mast .server { font-size: 12.5px; color: var(--mast-fg); opacity: .75; }
|
||||
.mast .spacer { flex: 1; }
|
||||
.mast .mast-icons { display: flex; gap: 16px; font-size: 13px; opacity: .7; }
|
||||
|
||||
.mainnav {
|
||||
background: color-mix(in srgb, var(--mast-bg) 88%, var(--surface));
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mainnav a {
|
||||
display: block;
|
||||
padding: 10px 14px 8px;
|
||||
font-size: 12px;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mast-fg);
|
||||
opacity: .55;
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.mainnav a.active {
|
||||
opacity: 1;
|
||||
color: var(--accent-strong);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---- Page header ---- */
|
||||
.pagehead {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pagehead .titlewrap { display: flex; align-items: center; gap: 12px; }
|
||||
.pagehead .icon {
|
||||
width: 38px; height: 38px; border-radius: 9px;
|
||||
background: var(--surface-3);
|
||||
display: grid; place-items: center;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pagehead h1 { font-size: 21px; }
|
||||
.pagehead .meta { font-size: 12.5px; color: var(--text-dim); margin-top: 2px; }
|
||||
.pagehead .meta .dot { color: var(--good); }
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 8px 14px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: border-color .12s, background .12s;
|
||||
}
|
||||
.btn:hover { border-color: var(--text-faint); }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
||||
.btn-danger { color: var(--bad); }
|
||||
.btn-danger:hover { border-color: var(--bad); }
|
||||
.btn-icon { padding: 6px 8px; }
|
||||
.btn[disabled] { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ---- Sub navigation (the 10 sections) ---- */
|
||||
.subnav {
|
||||
max-width: 1280px;
|
||||
margin: 14px auto 0;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.subnav button {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.subnav button:hover { color: var(--text); }
|
||||
.subnav button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
|
||||
.subnav button .n {
|
||||
font-size: 10.5px; padding: 1px 5px; border-radius: 8px;
|
||||
background: var(--surface-3); color: var(--text-dim);
|
||||
}
|
||||
|
||||
.main { max-width: 1280px; margin: 0 auto; padding: 20px 24px 64px; }
|
||||
.panel { display: none; animation: fade .15s ease; }
|
||||
.panel.active { display: block; }
|
||||
@keyframes fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .panel { animation: none; } }
|
||||
|
||||
/* ---- Generic building blocks ---- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card-pad { padding: 16px 18px; }
|
||||
.card-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-head h2 { font-size: 14.5px; }
|
||||
.card-head .sub { font-size: 12px; color: var(--text-faint); font-weight: 500; }
|
||||
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.stat-grid { grid-template-columns: repeat(6, 1fr); }
|
||||
@media (max-width: 1080px) { .stat-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 620px) { .stat-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
|
||||
.stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; }
|
||||
.stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; }
|
||||
.stat .bar { height: 5px; border-radius: 3px; background: var(--surface-3); margin-top: 10px; overflow: hidden; }
|
||||
.stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
|
||||
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 9px; border-radius: 100px;
|
||||
font-size: 11.5px; font-weight: 700; letter-spacing: .01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip .d { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.chip-good { background: var(--good-bg); color: var(--good); }
|
||||
.chip-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.chip-bad { background: var(--bad-bg); color: var(--bad); }
|
||||
.chip-neutral { background: var(--neutral-bg); color: var(--neutral); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th {
|
||||
text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--text-faint); font-weight: 700;
|
||||
padding: 10px 18px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
tbody td { padding: 11px 18px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.row-name .ico {
|
||||
width: 26px; height: 26px; border-radius: 6px; flex: none;
|
||||
background: var(--surface-3); display: grid; place-items: center; font-size: 12px;
|
||||
border: 1px solid var(--border); color: var(--text-dim);
|
||||
}
|
||||
.row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
|
||||
.actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
|
||||
.usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
|
||||
.usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
||||
.usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
|
||||
.usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.search {
|
||||
flex: 1; min-width: 180px;
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px;
|
||||
padding: 7px 11px; font-size: 13px; color: var(--text);
|
||||
}
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.filterset { display: flex; gap: 4px; background: var(--surface-2); padding: 3px; border-radius: 8px; }
|
||||
.filterset button {
|
||||
appearance: none; border: none; background: none; color: var(--text-dim);
|
||||
font-size: 12px; font-weight: 700; padding: 5px 10px; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.filterset button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
|
||||
|
||||
.two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
|
||||
@media (max-width: 920px) { .two-col { grid-template-columns: 1fr; } }
|
||||
|
||||
.activity-list { list-style: none; margin: 0; padding: 6px 0; }
|
||||
.activity-list li { display: flex; gap: 10px; padding: 9px 18px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
|
||||
.activity-list li:last-child { border-bottom: none; }
|
||||
.activity-list .dot { width: 7px; height: 7px; border-radius: 50%; margin-top: 5px; flex: none; }
|
||||
.activity-list .txt strong { font-weight: 600; }
|
||||
.activity-list .when { color: var(--text-faint); font-size: 11px; margin-top: 1px; }
|
||||
|
||||
.empty-note { padding: 28px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
|
||||
|
||||
/* ---- Pods ---- */
|
||||
.pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.pod-card:last-child { margin-bottom: 0; }
|
||||
.pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
||||
.pod-head .name { font-weight: 700; font-size: 13.5px; }
|
||||
.pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
|
||||
.pod-body table { font-size: 12.5px; }
|
||||
.pod-body td, .pod-body th { padding: 8px 16px 8px 40px; }
|
||||
|
||||
/* ---- Logs ---- */
|
||||
.logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
|
||||
@media (max-width: 760px) { .logs-layout { grid-template-columns: 1fr; } }
|
||||
.logs-side { border-right: 1px solid var(--border); }
|
||||
.logs-side .item { padding: 10px 14px; font-size: 12.5px; border-bottom: 1px solid var(--border); cursor: default; display: flex; align-items: center; gap: 8px; }
|
||||
.logs-side .item.active { background: var(--surface-2); font-weight: 700; box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.log-toolbar { display: flex; gap: 8px; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
||||
.log-pane {
|
||||
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
|
||||
padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65;
|
||||
}
|
||||
:root:not([data-theme="dark"]) .log-pane { background: #14161a; }
|
||||
.log-pane .l { white-space: pre-wrap; word-break: break-word; }
|
||||
.log-pane .ts { color: #6b7280; }
|
||||
.log-pane .lvl-info { color: #6fb2f5; }
|
||||
.log-pane .lvl-warn { color: #e0b23d; }
|
||||
.log-pane .lvl-error { color: #ef6470; }
|
||||
|
||||
/* ---- Terminal ---- */
|
||||
.term-wrap { padding: 16px 18px; }
|
||||
.term-select { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; font-size: 12.5px; color: var(--text-dim); }
|
||||
.term-select select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 6px 9px; color: var(--text);
|
||||
}
|
||||
.term {
|
||||
background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px;
|
||||
border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7;
|
||||
}
|
||||
.term .prompt { color: #4cc785; }
|
||||
.term .path { color: #6fb2f5; }
|
||||
.term .cur { display: inline-block; width: 7px; height: 14px; background: #d7dbe0; vertical-align: text-bottom; animation: blink 1s step-end infinite; }
|
||||
@media (prefers-reduced-motion: reduce) { .term .cur { animation: none; } }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ---- Compose ---- */
|
||||
.compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
|
||||
@media (max-width: 800px) { .compose-layout { grid-template-columns: 1fr; } }
|
||||
.compose-side { border-right: 1px solid var(--border); }
|
||||
.compose-proj { padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: default; }
|
||||
.compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.compose-proj .name { font-weight: 700; font-size: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
|
||||
.compose-body-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); gap: 10px; flex-wrap: wrap; }
|
||||
.yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; }
|
||||
:root:not([data-theme="dark"]) .yaml { background: #14161a; }
|
||||
.yaml .k { color: #ef9f76; }
|
||||
.yaml .s { color: #9fd88a; }
|
||||
.yaml .c { color: #6b7280; font-style: italic; }
|
||||
.yaml .p { color: #6fb2f5; }
|
||||
|
||||
/* ---- Settings ---- */
|
||||
.settings-grid { display: grid; gap: 14px; }
|
||||
.field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
.field-row label { font-weight: 600; font-size: 13px; }
|
||||
.field-row .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; max-width: 46ch; }
|
||||
.field-row input[type="text"], .field-row input[type="number"], .field-row select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px;
|
||||
padding: 8px 10px; font-size: 13px; color: var(--text); width: 100%; max-width: 340px;
|
||||
}
|
||||
.field-row .unit { font-size: 12px; color: var(--text-dim); margin-left: 8px; }
|
||||
.switch { position: relative; display: inline-block; width: 38px; height: 22px; }
|
||||
.switch input { display: none; }
|
||||
.switch .track { position: absolute; inset: 0; background: var(--surface-3); border: 1px solid var(--border); border-radius: 20px; transition: .15s; }
|
||||
.switch .track::after { content: ""; position: absolute; width: 16px; height: 16px; border-radius: 50%; background: var(--surface); top: 2px; left: 2px; transition: .15s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
|
||||
.switch input:checked + .track::after { transform: translateX(16px); background: #fff; }
|
||||
.danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
|
||||
.danger-card .card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
|
||||
.danger-card .card-head h2 { color: var(--bad); }
|
||||
|
||||
.badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
|
||||
|
||||
footer.note { max-width: 1280px; margin: 0 auto; padding: 4px 24px 40px; font-size: 11.5px; color: var(--text-faint); }
|
||||
</style>
|
||||
|
||||
<!-- =========================================================================
|
||||
Real Unraid chrome (masthead + main nav) — recreated for context, not
|
||||
interactive. Only "Podman" is the actual subject of this mockup.
|
||||
========================================================================= -->
|
||||
<div class="mast">
|
||||
<div class="brand"><span class="mark">U</span> unraid</div>
|
||||
<div class="server">TOWER · 14d 6h uptime · Array Started</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="mast-icons"><span>ⓘ Updates</span><span>🔔</span><span>👤 root</span></div>
|
||||
</div>
|
||||
<nav class="mainnav">
|
||||
<a>Dashboard</a><a>Main</a><a>Shares</a><a>Users</a><a>Settings</a><a>Plugins</a><a>Docker</a><a>VMs</a>
|
||||
<a class="active">Podman</a><a>Tools</a>
|
||||
</nav>
|
||||
|
||||
<header class="pagehead">
|
||||
<div class="titlewrap">
|
||||
<div class="icon" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Podman</h1>
|
||||
<div class="meta"><span class="dot">●</span> podman.sock connected · 5 of 8 containers running · v6.0.1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn">↻ Refresh</button>
|
||||
<button class="btn btn-primary">+ New Container</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="subnav" id="subnav">
|
||||
<button class="active" data-panel="dashboard">Dashboard</button>
|
||||
<button data-panel="containers">Containers <span class="n">8</span></button>
|
||||
<button data-panel="pods">Pods <span class="n">2</span></button>
|
||||
<button data-panel="images">Images <span class="n">14</span></button>
|
||||
<button data-panel="volumes">Volumes <span class="n">9</span></button>
|
||||
<button data-panel="networks">Networks <span class="n">3</span></button>
|
||||
<button data-panel="logs">Logs</button>
|
||||
<button data-panel="terminal">Terminal</button>
|
||||
<button data-panel="compose">Compose</button>
|
||||
<button data-panel="settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<!-- ============================= DASHBOARD ============================= -->
|
||||
<section class="panel active" id="panel-dashboard">
|
||||
<div class="grid stat-grid">
|
||||
<div class="stat"><div class="label">Running</div><div class="value tnum">5 <small>/ 8</small></div></div>
|
||||
<div class="stat"><div class="label">Pods</div><div class="value tnum">2</div></div>
|
||||
<div class="stat"><div class="label">Images</div><div class="value tnum">14</div></div>
|
||||
<div class="stat"><div class="label">Volumes</div><div class="value tnum">9</div></div>
|
||||
<div class="stat"><div class="label">Networks</div><div class="value tnum">3</div></div>
|
||||
<div class="stat">
|
||||
<div class="label">Storage Used</div>
|
||||
<div class="value tnum">62.4 <small>/ 100 GB</small></div>
|
||||
<div class="bar"><span style="width:62%"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col" style="margin-top:14px;">
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Resource Usage</h2><span class="sub">last 60 min</span></div>
|
||||
<div class="card-pad">
|
||||
<svg viewBox="0 0 560 140" width="100%" height="140" preserveAspectRatio="none" role="img" aria-label="CPU and memory usage over the last 60 minutes">
|
||||
<defs>
|
||||
<linearGradient id="cpuFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="var(--accent)" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="var(--accent)" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke="var(--border)" stroke-width="1">
|
||||
<line x1="0" y1="10" x2="560" y2="10"/><line x1="0" y1="45" x2="560" y2="45"/>
|
||||
<line x1="0" y1="80" x2="560" y2="80"/><line x1="0" y1="115" x2="560" y2="115"/>
|
||||
</g>
|
||||
<path d="M0,100 L40,92 L80,96 L120,70 L160,74 L200,60 L240,66 L280,50 L320,58 L360,42 L400,48 L440,36 L480,40 L520,30 L560,34 L560,140 L0,140 Z" fill="url(#cpuFill)"/>
|
||||
<path d="M0,100 L40,92 L80,96 L120,70 L160,74 L200,60 L240,66 L280,50 L320,58 L360,42 L400,48 L440,36 L480,40 L520,30 L560,34" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<circle cx="560" cy="34" r="3.5" fill="var(--accent)"/>
|
||||
<path d="M0,120 L40,118 L80,121 L120,112 L160,116 L200,108 L240,110 L280,104 L320,109 L360,100 L400,103 L440,96 L480,99 L520,92 L560,95" fill="none" stroke="var(--text-faint)" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="1 4"/>
|
||||
<circle cx="560" cy="95" r="3" fill="var(--text-faint)"/>
|
||||
</svg>
|
||||
<div style="display:flex; gap:18px; margin-top:6px; font-size:11.5px; color:var(--text-dim);">
|
||||
<span><span style="color:var(--accent);">●</span> CPU · 24%</span>
|
||||
<span><span style="color:var(--text-faint);">●</span> Memory · 6.1 / 16 GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Recent Activity</h2></div>
|
||||
<ul class="activity-list">
|
||||
<li><span class="dot" style="background:var(--good)"></span><div class="txt"><div><strong>sonarr</strong> started</div><div class="when">2 min ago</div></div></li>
|
||||
<li><span class="dot" style="background:var(--bad)"></span><div class="txt"><div><strong>postgres</strong> health check failed</div><div class="when">11 min ago</div></div></li>
|
||||
<li><span class="dot" style="background:var(--accent)"></span><div class="txt"><div>Pulled <strong>radarr:latest</strong> (image updated)</div><div class="when">38 min ago</div></div></li>
|
||||
<li><span class="dot" style="background:var(--good)"></span><div class="txt"><div>Autostart chain completed (7/8)</div><div class="when">6h ago · array start</div></div></li>
|
||||
<li><span class="dot" style="background:var(--warn)"></span><div class="txt"><div><strong>watchtower</strong> paused (autostart Safe-Mode)</div><div class="when">6h ago</div></div></li>
|
||||
<li><span class="dot" style="background:var(--neutral)"></span><div class="txt"><div>Network <strong>media-net</strong> created</div><div class="when">2d ago</div></div></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:14px;">
|
||||
<div class="card-head"><h2>Autostart Queue</h2><span class="sub">/boot/config/plugins/podman/autostart</span></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Container</th><th>Delay</th><th>Last Result</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="tnum">1</td><td>postgres</td><td class="tnum">—</td><td><span class="chip chip-good"><span class="d"></span>started</span></td></tr>
|
||||
<tr><td class="tnum">2</td><td>nextcloud</td><td class="tnum">5s</td><td><span class="chip chip-good"><span class="d"></span>started</span></td></tr>
|
||||
<tr><td class="tnum">3</td><td>sonarr</td><td class="tnum">—</td><td><span class="chip chip-good"><span class="d"></span>started</span></td></tr>
|
||||
<tr><td class="tnum">4</td><td>radarr</td><td class="tnum">—</td><td><span class="chip chip-good"><span class="d"></span>started</span></td></tr>
|
||||
<tr><td class="tnum">5</td><td>watchtower</td><td class="tnum">—</td><td><span class="chip chip-warn"><span class="d"></span>Safe-Mode (3 failures)</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= CONTAINERS ============================= -->
|
||||
<section class="panel" id="panel-containers">
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<input class="search" type="text" placeholder="Search containers by name or image…">
|
||||
<div class="filterset">
|
||||
<button class="active">All 8</button><button>Running 5</button><button>Stopped 2</button><button>Unhealthy 1</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Name</th><th>Image</th><th>CPU</th><th>Memory</th><th>Ports</th><th>Uptime</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="chip chip-good"><span class="d"></span>running</span></td>
|
||||
<td><div class="row-name"><span class="ico">Px</span>plex</div></td>
|
||||
<td class="mono row-sub">lscr.io/linuxserver/plex:latest</td>
|
||||
<td class="tnum">18%</td>
|
||||
<td><div class="usage-mini"><div class="track"><span style="width:41%"></span></div><span class="num tnum">1.6G</span></div></td>
|
||||
<td class="mono row-sub">32400/tcp</td>
|
||||
<td class="tnum">14d 6h</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Restart">↻</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Shell">⎋</button><button class="btn btn-icon" title="Stop">■</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-good"><span class="d"></span>running</span></td>
|
||||
<td><div class="row-name"><span class="ico">So</span>sonarr</div></td>
|
||||
<td class="mono row-sub">lscr.io/linuxserver/sonarr:4.0</td>
|
||||
<td class="tnum">3%</td>
|
||||
<td><div class="usage-mini"><div class="track"><span style="width:22%"></span></div><span class="num tnum">420M</span></div></td>
|
||||
<td class="mono row-sub">8989/tcp</td>
|
||||
<td class="tnum">6h 12m</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Restart">↻</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Shell">⎋</button><button class="btn btn-icon" title="Stop">■</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-good"><span class="d"></span>running</span></td>
|
||||
<td><div class="row-name"><span class="ico">Ra</span>radarr <span class="badge-update">update</span></div></td>
|
||||
<td class="mono row-sub">lscr.io/linuxserver/radarr:5.2</td>
|
||||
<td class="tnum">2%</td>
|
||||
<td><div class="usage-mini"><div class="track"><span style="width:19%"></span></div><span class="num tnum">380M</span></div></td>
|
||||
<td class="mono row-sub">7878/tcp</td>
|
||||
<td class="tnum">6h 12m</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Restart">↻</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Shell">⎋</button><button class="btn btn-icon" title="Stop">■</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-bad"><span class="d"></span>unhealthy</span></td>
|
||||
<td><div class="row-name"><span class="ico">Pg</span>postgres</div></td>
|
||||
<td class="mono row-sub">postgres:16-alpine</td>
|
||||
<td class="tnum">1%</td>
|
||||
<td><div class="usage-mini"><div class="track"><span style="width:34%"></span></div><span class="num tnum">210M</span></div></td>
|
||||
<td class="mono row-sub">5432/tcp</td>
|
||||
<td class="tnum">2d 1h</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Restart">↻</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Shell">⎋</button><button class="btn btn-icon" title="Stop">■</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-good"><span class="d"></span>running</span></td>
|
||||
<td><div class="row-name"><span class="ico">Nc</span>nextcloud</div></td>
|
||||
<td class="mono row-sub">nextcloud:29-apache</td>
|
||||
<td class="tnum">6%</td>
|
||||
<td><div class="usage-mini"><div class="track"><span style="width:58%"></span></div><span class="num tnum">890M</span></div></td>
|
||||
<td class="mono row-sub">443/tcp, 80/tcp</td>
|
||||
<td class="tnum">6h 12m</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Restart">↻</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Shell">⎋</button><button class="btn btn-icon" title="Stop">■</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-neutral"><span class="d"></span>exited</span></td>
|
||||
<td><div class="row-name"><span class="ico">Pr</span>prowlarr</div></td>
|
||||
<td class="mono row-sub">lscr.io/linuxserver/prowlarr:1</td>
|
||||
<td class="tnum">—</td>
|
||||
<td class="tnum">—</td>
|
||||
<td class="mono row-sub">9696/tcp</td>
|
||||
<td class="tnum">—</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Start">▶</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Remove">🗑</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-warn"><span class="d"></span>Safe-Mode</span></td>
|
||||
<td><div class="row-name"><span class="ico">Wt</span>watchtower</div></td>
|
||||
<td class="mono row-sub">containrrr/watchtower:latest</td>
|
||||
<td class="tnum">—</td>
|
||||
<td class="tnum">—</td>
|
||||
<td class="mono row-sub">—</td>
|
||||
<td class="tnum">—</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Start">▶</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Remove">🗑</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="chip chip-good"><span class="d"></span>running</span></td>
|
||||
<td><div class="row-name"><span class="ico">Ha</span>homeassistant</div></td>
|
||||
<td class="mono row-sub">ghcr.io/home-assistant/home-assistant</td>
|
||||
<td class="tnum">4%</td>
|
||||
<td><div class="usage-mini"><div class="track"><span style="width:30%"></span></div><span class="num tnum">610M</span></div></td>
|
||||
<td class="mono row-sub">8123/tcp</td>
|
||||
<td class="tnum">3d 4h</td>
|
||||
<td class="actions"><button class="btn btn-icon" title="Restart">↻</button><button class="btn btn-icon" title="Logs">📄</button><button class="btn btn-icon" title="Shell">⎋</button><button class="btn btn-icon" title="Stop">■</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= PODS ============================= -->
|
||||
<section class="panel" id="panel-pods">
|
||||
<div class="pod-card">
|
||||
<div class="pod-head">
|
||||
<span class="chip chip-good"><span class="d"></span>running</span>
|
||||
<span class="name">media-stack</span>
|
||||
<span class="infra">infra: localhost/podman-pause · shared network namespace</span>
|
||||
</div>
|
||||
<div class="pod-body table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Container</th><th>Image</th><th>Status</th><th>Ports</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>sonarr</td><td class="mono row-sub">linuxserver/sonarr:4.0</td><td><span class="chip chip-good"><span class="d"></span>running</span></td><td class="mono row-sub">8989/tcp</td><td class="actions"><button class="btn btn-icon">📄</button></td></tr>
|
||||
<tr><td>radarr</td><td class="mono row-sub">linuxserver/radarr:5.2</td><td><span class="chip chip-good"><span class="d"></span>running</span></td><td class="mono row-sub">7878/tcp</td><td class="actions"><button class="btn btn-icon">📄</button></td></tr>
|
||||
<tr><td>prowlarr</td><td class="mono row-sub">linuxserver/prowlarr:1</td><td><span class="chip chip-neutral"><span class="d"></span>exited</span></td><td class="mono row-sub">9696/tcp</td><td class="actions"><button class="btn btn-icon">📄</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pod-card">
|
||||
<div class="pod-head">
|
||||
<span class="chip chip-good"><span class="d"></span>running</span>
|
||||
<span class="name">home-stack</span>
|
||||
<span class="infra">infra: localhost/podman-pause · shared network namespace</span>
|
||||
</div>
|
||||
<div class="pod-body table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Container</th><th>Image</th><th>Status</th><th>Ports</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>homeassistant</td><td class="mono row-sub">ghcr.io/home-assistant/home-assistant</td><td><span class="chip chip-good"><span class="d"></span>running</span></td><td class="mono row-sub">8123/tcp</td><td class="actions"><button class="btn btn-icon">📄</button></td></tr>
|
||||
<tr><td>mosquitto</td><td class="mono row-sub">eclipse-mosquitto:2</td><td><span class="chip chip-good"><span class="d"></span>running</span></td><td class="mono row-sub">1883/tcp</td><td class="actions"><button class="btn btn-icon">📄</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= IMAGES ============================= -->
|
||||
<section class="panel" id="panel-images">
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<input class="search" type="text" placeholder="Search images…">
|
||||
<button class="btn">⬇ Pull Image</button>
|
||||
<button class="btn">Prune Unused</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Repository</th><th>Tag</th><th>Image ID</th><th>Size</th><th>Created</th><th>Used By</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>lscr.io/linuxserver/plex</td><td class="mono">latest</td><td class="mono row-sub">a3f9c2e1b7d4</td><td class="tnum">1.42 GB</td><td class="tnum">3d ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>lscr.io/linuxserver/sonarr</td><td class="mono">4.0</td><td class="mono row-sub">7e21ac9f0b12</td><td class="tnum">612 MB</td><td class="tnum">6h ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>lscr.io/linuxserver/radarr <span class="badge-update">update</span></td><td class="mono">5.2</td><td class="mono row-sub">44b6f1d8ce90</td><td class="tnum">598 MB</td><td class="tnum">38m ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>postgres</td><td class="mono">16-alpine</td><td class="mono row-sub">c1a08e4477fd</td><td class="tnum">243 MB</td><td class="tnum">9d ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>nextcloud</td><td class="mono">29-apache</td><td class="mono row-sub">9d3c72b1a5e6</td><td class="tnum">890 MB</td><td class="tnum">12d ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>containrrr/watchtower</td><td class="mono">latest</td><td class="mono row-sub">0f5b9a2d7c31</td><td class="tnum">28 MB</td><td class="tnum">40d ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>ghcr.io/home-assistant/home-assistant</td><td class="mono">2026.7</td><td class="mono row-sub">b7e4f0a19c22</td><td class="tnum">1.1 GB</td><td class="tnum">3d ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>eclipse-mosquitto</td><td class="mono">2</td><td class="mono row-sub">2a6d0e5f8b41</td><td class="tnum">14 MB</td><td class="tnum">40d ago</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>lscr.io/linuxserver/prowlarr</td><td class="mono">1</td><td class="mono row-sub">d8f231b9a706</td><td class="tnum">340 MB</td><td class="tnum">11d ago</td><td class="tnum">0</td><td class="actions"><button class="btn btn-icon">🗑</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= VOLUMES ============================= -->
|
||||
<section class="panel" id="panel-volumes">
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<input class="search" type="text" placeholder="Search volumes…">
|
||||
<button class="btn btn-primary">+ New Volume</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Driver</th><th>Mountpoint</th><th>Size</th><th>Used By</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>postgres-data</td><td class="chip chip-neutral">local</td><td class="mono row-sub">…/storage/volumes/postgres-data/_data</td><td class="tnum">1.8 GB</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">📁</button><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>nextcloud-data</td><td class="chip chip-neutral">local</td><td class="mono row-sub">…/storage/volumes/nextcloud-data/_data</td><td class="tnum">14.2 GB</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">📁</button><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>ha-config</td><td class="chip chip-neutral">local</td><td class="mono row-sub">…/storage/volumes/ha-config/_data</td><td class="tnum">340 MB</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">📁</button><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>mosquitto-data</td><td class="chip chip-neutral">local</td><td class="mono row-sub">…/storage/volumes/mosquitto-data/_data</td><td class="tnum">4 MB</td><td class="tnum">1</td><td class="actions"><button class="btn btn-icon">📁</button><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr><td>unused-cache</td><td class="chip chip-neutral">local</td><td class="mono row-sub">…/storage/volumes/unused-cache/_data</td><td class="tnum">610 MB</td><td class="tnum">0</td><td class="actions"><button class="btn btn-icon">📁</button><button class="btn btn-icon">🗑</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-note" style="border-top:1px solid var(--border); text-align:left; padding:12px 18px;">
|
||||
App data mapped from Unraid shares (e.g. <span class="mono">/mnt/user/appdata/…</span>) is managed as bind mounts per-container, not listed here — see each container's edit dialog.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= NETWORKS ============================= -->
|
||||
<section class="panel" id="panel-networks">
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<input class="search" type="text" placeholder="Search networks…">
|
||||
<button class="btn btn-primary">+ New Network</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Driver</th><th>Subnet</th><th>Gateway</th><th>Containers</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>podman0</strong> <span class="row-sub">(default)</span></td><td class="chip chip-neutral">bridge</td><td class="mono">10.89.0.0/24</td><td class="mono">10.89.0.1</td><td class="tnum">3</td><td class="actions"><button class="btn btn-icon">✎</button></td></tr>
|
||||
<tr><td>media-net</td><td class="chip chip-neutral">bridge</td><td class="mono">10.89.1.0/24</td><td class="mono">10.89.1.1</td><td class="tnum">3</td><td class="actions"><button class="btn btn-icon">✎</button><button class="btn btn-icon">🗑</button></td></tr>
|
||||
<tr>
|
||||
<td>iot-macvlan</td><td class="chip chip-neutral">macvlan</td><td class="mono">192.168.10.0/24</td><td class="mono">192.168.10.1</td><td class="tnum">1</td>
|
||||
<td class="actions"><button class="btn btn-icon">✎</button><button class="btn btn-icon">🗑</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-note" style="border-top:1px solid var(--border); text-align:left; padding:12px 18px; display:flex; gap:8px; align-items:flex-start;">
|
||||
<span class="chip chip-warn" style="margin-top:1px;"><span class="d"></span>note</span>
|
||||
<span>macvlan networks can trigger a known kernel issue with some NIC drivers on Unraid ("macvlan call trap"). See the docs before attaching containers that need host-network access on the same interface.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= LOGS ============================= -->
|
||||
<section class="panel" id="panel-logs">
|
||||
<div class="card">
|
||||
<div class="logs-layout">
|
||||
<div class="logs-side">
|
||||
<div class="item active">● postgres</div>
|
||||
<div class="item">● plex</div>
|
||||
<div class="item">● sonarr</div>
|
||||
<div class="item">● radarr</div>
|
||||
<div class="item">● nextcloud</div>
|
||||
<div class="item">● homeassistant</div>
|
||||
<div class="item">● mosquitto</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="log-toolbar">
|
||||
<input class="search" type="text" placeholder="Filter log output…" style="max-width:280px;">
|
||||
<span class="filterset"><button class="active">Follow</button><button>Paused</button></span>
|
||||
<div class="spacer" style="flex:1;"></div>
|
||||
<button class="btn btn-icon" title="Download">⇩</button>
|
||||
</div>
|
||||
<div class="log-pane" role="log">
|
||||
<div class="l"><span class="ts">2026-07-11T09:58:02Z</span> <span class="lvl-info">INFO</span> database system is ready to accept connections</div>
|
||||
<div class="l"><span class="ts">2026-07-11T09:58:14Z</span> <span class="lvl-info">INFO</span> checkpoint starting: time</div>
|
||||
<div class="l"><span class="ts">2026-07-11T10:04:41Z</span> <span class="lvl-warn">WARN</span> could not receive data from client: Connection reset by peer</div>
|
||||
<div class="l"><span class="ts">2026-07-11T10:07:52Z</span> <span class="lvl-error">ERROR</span> connection to server was lost</div>
|
||||
<div class="l"><span class="ts">2026-07-11T10:07:53Z</span> <span class="lvl-info">INFO</span> restartpoint starting: time</div>
|
||||
<div class="l"><span class="ts">2026-07-11T10:07:58Z</span> <span class="lvl-info">INFO</span> database system is ready to accept connections</div>
|
||||
<div class="l"><span class="ts">2026-07-11T10:11:03Z</span> <span class="lvl-warn">WARN</span> autovacuum: found orphan temp table in database "app"</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= TERMINAL ============================= -->
|
||||
<section class="panel" id="panel-terminal">
|
||||
<div class="card">
|
||||
<div class="term-wrap">
|
||||
<div class="term-select">
|
||||
Exec into:
|
||||
<select><option>sonarr</option><option>plex</option><option>postgres</option><option>nextcloud</option></select>
|
||||
Shell: <select><option>/bin/bash</option><option>/bin/sh</option></select>
|
||||
<button class="btn" style="margin-left:auto;">New Session</button>
|
||||
</div>
|
||||
<div class="term">
|
||||
<div><span class="prompt">root@sonarr</span>:<span class="path">/config</span>$ podman exec -it sonarr /bin/bash</div>
|
||||
<div>root@sonarr:/config# ls -la</div>
|
||||
<div>drwxr-xr-x 6 abc abc 4096 Jul 11 09:12 .</div>
|
||||
<div>drwxr-xr-x 1 root root 4096 Jun 02 17:03 ..</div>
|
||||
<div>-rw-r--r-- 1 abc abc 22016 Jul 11 09:58 sonarr.db</div>
|
||||
<div>drwxr-xr-x 2 abc abc 4096 Jul 11 06:00 logs</div>
|
||||
<div> </div>
|
||||
<div>root@sonarr:/config# tail -n 3 logs/sonarr.txt</div>
|
||||
<div>10:11:02|Info|RssSyncService: Starting RSS Sync</div>
|
||||
<div>10:11:04|Info|RssSyncService: RSS Sync Completed. Reported 12 releases</div>
|
||||
<div>10:11:04|Info|DownloadService: Report sent to SABnzbd</div>
|
||||
<div> </div>
|
||||
<div><span class="prompt">root@sonarr</span>:<span class="path">/config</span># <span class="cur"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= COMPOSE ============================= -->
|
||||
<section class="panel" id="panel-compose">
|
||||
<div class="card">
|
||||
<div class="compose-layout">
|
||||
<div class="compose-side">
|
||||
<div class="compose-proj active">
|
||||
<div class="name">media-stack <span class="chip chip-good"><span class="d"></span>up</span></div>
|
||||
<div class="path">/boot/config/plugins/podman/compose/media-stack</div>
|
||||
</div>
|
||||
<div class="compose-proj">
|
||||
<div class="name">home-stack <span class="chip chip-good"><span class="d"></span>up</span></div>
|
||||
<div class="path">/boot/config/plugins/podman/compose/home-stack</div>
|
||||
</div>
|
||||
<div class="compose-proj">
|
||||
<div class="name">staging <span class="chip chip-neutral"><span class="d"></span>down</span></div>
|
||||
<div class="path">/boot/config/plugins/podman/compose/staging</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="compose-body-head">
|
||||
<strong>media-stack / compose.yaml</strong>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="btn">⬇ Pull</button>
|
||||
<button class="btn">■ Down</button>
|
||||
<button class="btn btn-primary">▶ Up</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="yaml"><span class="c"># managed via podman kube play — see docs/ARCHITECTURE.md</span>
|
||||
<span class="k">services</span>:
|
||||
<span class="k">sonarr</span>:
|
||||
<span class="k">image</span>: <span class="s">lscr.io/linuxserver/sonarr:4.0</span>
|
||||
<span class="k">ports</span>:
|
||||
- <span class="s">"8989:8989"</span>
|
||||
<span class="k">volumes</span>:
|
||||
- <span class="p">/mnt/user/appdata/sonarr</span>:<span class="p">/config</span>
|
||||
- <span class="p">/mnt/user/media</span>:<span class="p">/media</span>
|
||||
<span class="k">environment</span>:
|
||||
<span class="k">PUID</span>: <span class="s">"99"</span>
|
||||
<span class="k">PGID</span>: <span class="s">"100"</span>
|
||||
<span class="k">restart</span>: <span class="s">unless-stopped</span>
|
||||
|
||||
<span class="k">radarr</span>:
|
||||
<span class="k">image</span>: <span class="s">lscr.io/linuxserver/radarr:5.2</span>
|
||||
<span class="k">ports</span>:
|
||||
- <span class="s">"7878:7878"</span>
|
||||
<span class="k">volumes</span>:
|
||||
- <span class="p">/mnt/user/appdata/radarr</span>:<span class="p">/config</span>
|
||||
- <span class="p">/mnt/user/media</span>:<span class="p">/media</span>
|
||||
<span class="k">restart</span>: <span class="s">unless-stopped</span>
|
||||
|
||||
<span class="k">prowlarr</span>:
|
||||
<span class="k">image</span>: <span class="s">lscr.io/linuxserver/prowlarr:1</span>
|
||||
<span class="k">ports</span>:
|
||||
- <span class="s">"9696:9696"</span>
|
||||
<span class="k">restart</span>: <span class="s">unless-stopped</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= SETTINGS ============================= -->
|
||||
<section class="panel" id="panel-settings">
|
||||
<div class="settings-grid">
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Storage</h2></div>
|
||||
<div class="field-row">
|
||||
<label>Storage path</label>
|
||||
<div>
|
||||
<input type="text" value="/mnt/cache/system/podman" class="mono">
|
||||
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE). See Architecture docs.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>podman.img size</label>
|
||||
<div><input type="number" value="20" style="max-width:100px;"><span class="unit">GB</span>
|
||||
<div class="bar" style="max-width:340px; margin-top:10px;"><span style="width:62%"></span></div>
|
||||
<div class="hint">62.4 GB used of 20 GB nominal — image will need to grow soon.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Networking</h2></div>
|
||||
<div class="field-row"><label>Default bridge subnet</label><div><input type="text" value="10.89.0.0/24" class="mono"></div></div>
|
||||
<div class="field-row"><label>DNS backend</label><div><select><option>aardvark-dns (recommended)</option><option>none</option></select></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Autostart & Lifecycle</h2></div>
|
||||
<div class="field-row"><label>Start podman on array start</label><div><label class="switch"><input type="checkbox" checked><span class="track"></span></label></div></div>
|
||||
<div class="field-row"><label>Container stop timeout</label><div><input type="number" value="10" style="max-width:100px;"><span class="unit">seconds</span></div></div>
|
||||
<div class="field-row"><label>Autostart Safe-Mode threshold</label><div><input type="number" value="3" style="max-width:100px;"><span class="unit">consecutive failures</span>
|
||||
<div class="hint">watchtower is currently paused — see Dashboard.</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Updates</h2><span class="sub">unraid-podman 0.1.0</span></div>
|
||||
<div class="field-row"><label>Update channel</label><div><select><option>Stable</option><option>Pre-release</option></select></div></div>
|
||||
<div class="field-row"><label>Package versions</label>
|
||||
<div class="hint mono" style="max-width:none;">
|
||||
podman 6.0.1 · conmon 2.2.1 · crun 1.28 · netavark 2.0.0 · aardvark-dns 2.0.0 · passt git6ef3d1c · fuse-overlayfs 1.17
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card danger-card">
|
||||
<div class="card-head"><h2>Danger Zone</h2></div>
|
||||
<div class="field-row">
|
||||
<label>Roll back plugin</label>
|
||||
<div><button class="btn">Restore previous version (0.0.9)</button></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Remove plugin</label>
|
||||
<div>
|
||||
<button class="btn btn-danger">Uninstall unraid-podman…</button>
|
||||
<div class="hint">Your configuration, backups, and container storage are preserved by default. Full data removal requires a separate confirmation.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="note">Mockup only — static sample data, not connected to a live podman.sock. See webui/README.md.</footer>
|
||||
|
||||
<script>
|
||||
// Tab switching: the only interactive behavior this static mockup needs.
|
||||
const buttons = document.querySelectorAll('#subnav button');
|
||||
buttons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
buttons.forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('.panel').forEach(function (p) { p.classList.remove('active'); });
|
||||
const target = document.getElementById('panel-' + btn.dataset.panel);
|
||||
if (target) target.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
Menu="Podman"
|
||||
Title="Podman"
|
||||
Icon="podman"
|
||||
---
|
||||
<?php
|
||||
/**
|
||||
* Podman.page — Unraid Dynamix-style GUI page for unraid-podman.
|
||||
*
|
||||
* This file only renders the page shell: the header, the sub-navigation
|
||||
* for the ten sections, and one (mostly empty) container per panel. Every
|
||||
* panel fills itself in via AJAX after load — see javascript/app.js's
|
||||
* router and each javascript/<panel>.js module — so this file has no
|
||||
* business logic of its own and does not talk to PodmanClient directly.
|
||||
* See docs/ARCHITECTURE.md section 18 for the overall WebUI design and
|
||||
* webui/mockups/prototype.html for the approved visual reference this
|
||||
* page's markup mirrors (same structure, same CSS classes, real data
|
||||
* instead of static samples).
|
||||
*/
|
||||
?>
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css">
|
||||
|
||||
<div class="podman-plugin">
|
||||
|
||||
<header class="podman-pagehead">
|
||||
<div class="titlewrap">
|
||||
<div class="icon" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Podman</h1>
|
||||
<div class="meta" id="podman-header-meta">Connecting…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button class="podman-btn" id="podman-refresh-all">↻ Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="podman-subnav">
|
||||
<button class="active" data-panel="dashboard">Dashboard</button>
|
||||
<button data-panel="containers">Containers</button>
|
||||
<button data-panel="pods">Pods</button>
|
||||
<button data-panel="images">Images</button>
|
||||
<button data-panel="volumes">Volumes</button>
|
||||
<button data-panel="networks">Networks</button>
|
||||
<button data-panel="logs">Logs</button>
|
||||
<button data-panel="terminal">Terminal</button>
|
||||
<button data-panel="compose">Compose</button>
|
||||
<button data-panel="settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<main class="podman-main">
|
||||
|
||||
<!-- ============================= DASHBOARD ============================= -->
|
||||
<section class="podman-panel active" id="podman-panel-dashboard">
|
||||
<div class="podman-grid podman-stat-grid">
|
||||
<div class="podman-stat"><div class="label">Running</div><div class="value tnum"><span id="stat-running">—</span> <small id="stat-running-total"></small></div></div>
|
||||
<div class="podman-stat"><div class="label">Pods</div><div class="value tnum" id="stat-pods">—</div></div>
|
||||
<div class="podman-stat"><div class="label">Images</div><div class="value tnum" id="stat-images">—</div></div>
|
||||
<div class="podman-stat"><div class="label">Volumes</div><div class="value tnum" id="stat-volumes">—</div></div>
|
||||
<div class="podman-stat"><div class="label">Networks</div><div class="value tnum" id="stat-networks">—</div></div>
|
||||
<div class="podman-stat"><div class="label">Images on Disk</div><div class="value tnum" id="stat-images-size">—</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= CONTAINERS ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-containers">
|
||||
<div class="podman-card">
|
||||
<div class="podman-toolbar">
|
||||
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
|
||||
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
|
||||
<button class="active" data-filter="all" id="containers-count-all">All</button>
|
||||
<button data-filter="running" id="containers-count-running">Running</button>
|
||||
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Name</th><th>Image</th><th>CPU/Mem</th><th>Ports</th><th>Uptime</th><th></th></tr></thead>
|
||||
<tbody id="containers-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= PODS ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-pods"></section>
|
||||
|
||||
<!-- ============================= IMAGES ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-images">
|
||||
<div class="podman-card">
|
||||
<div class="podman-toolbar">
|
||||
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
|
||||
<button class="podman-btn" id="images-pull-btn">⬇ Pull Image</button>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Repository</th><th>Tag</th><th>Image ID</th><th>Size</th><th>Created</th><th>Used By</th><th></th></tr></thead>
|
||||
<tbody id="images-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= VOLUMES ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-volumes">
|
||||
<div class="podman-card">
|
||||
<div class="podman-toolbar">
|
||||
<div style="flex:1;"></div>
|
||||
<button class="podman-btn podman-btn-primary" id="volumes-create-btn">+ New Volume</button>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Driver</th><th>Mountpoint</th><th>Used By</th><th></th></tr></thead>
|
||||
<tbody id="volumes-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="podman-empty-note" style="border-top:1px solid var(--border); text-align:left; padding:12px 18px;">
|
||||
App data mapped from Unraid shares (e.g. <span class="mono">/mnt/user/appdata/…</span>) is managed as bind mounts per-container and does not appear here.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= NETWORKS ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-networks">
|
||||
<div class="podman-card">
|
||||
<div class="podman-toolbar">
|
||||
<div style="flex:1;"></div>
|
||||
<button class="podman-btn podman-btn-primary" id="networks-create-btn">+ New Network</button>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Driver</th><th>Subnet</th><th>Gateway</th><th>Containers</th><th></th></tr></thead>
|
||||
<tbody id="networks-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= LOGS ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-logs">
|
||||
<div class="podman-card">
|
||||
<div class="podman-logs-layout">
|
||||
<div class="podman-logs-side" id="logs-sidebar"></div>
|
||||
<div>
|
||||
<div class="podman-toolbar">
|
||||
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
|
||||
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
|
||||
<button class="active" data-follow="true">Follow</button>
|
||||
<button data-follow="false">Paused</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="podman-log-pane" id="log-pane" role="log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= TERMINAL ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-terminal">
|
||||
<div class="podman-card">
|
||||
<div class="podman-card-pad">
|
||||
<div style="display:flex; gap:8px; align-items:center; margin-bottom:12px; font-size:12.5px; color:var(--text-dim);">
|
||||
Exec into: <select id="term-container-select"></select>
|
||||
</div>
|
||||
<div class="podman-term" id="term-output"></div>
|
||||
<input class="podman-term-input" id="term-input" type="text" placeholder="Type a command and press Enter… (one-shot exec — see Compose panel note on API scope)" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= COMPOSE ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-compose">
|
||||
<div class="podman-card">
|
||||
<div class="podman-compose-layout">
|
||||
<div class="podman-compose-side" id="compose-sidebar"></div>
|
||||
<div>
|
||||
<div class="podman-toolbar">
|
||||
<strong id="compose-title" style="flex:1;">—</strong>
|
||||
<button class="podman-btn" id="compose-action-pull">⬇ Pull</button>
|
||||
<button class="podman-btn" id="compose-action-down">■ Down</button>
|
||||
<button class="podman-btn podman-btn-primary" id="compose-action-up">▶ Up</button>
|
||||
</div>
|
||||
<pre class="podman-yaml" id="compose-yaml"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= SETTINGS ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-settings">
|
||||
<div class="podman-grid">
|
||||
<div class="podman-card">
|
||||
<div class="podman-card-head"><h2>Storage</h2></div>
|
||||
<div class="podman-field-row">
|
||||
<label for="settings-storage-path">Storage path</label>
|
||||
<div>
|
||||
<input type="text" id="settings-storage-path" class="mono">
|
||||
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="podman-field-row">
|
||||
<label for="settings-storage-size">podman.img size</label>
|
||||
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="podman-card">
|
||||
<div class="podman-card-head"><h2>Autostart & Lifecycle</h2></div>
|
||||
<div class="podman-field-row">
|
||||
<label for="settings-enabled">Start podman on array start</label>
|
||||
<div><input type="checkbox" id="settings-enabled"></div>
|
||||
</div>
|
||||
<div class="podman-field-row">
|
||||
<label for="settings-stop-timeout">Container stop timeout</label>
|
||||
<div><input type="number" id="settings-stop-timeout" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">seconds</span></div>
|
||||
</div>
|
||||
<div class="podman-field-row">
|
||||
<label>Autostart order</label>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
|
||||
<tbody id="autostart-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="podman-field-row">
|
||||
<label></label>
|
||||
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="podman-card">
|
||||
<div class="podman-card-head"><h2>Installed Packages</h2></div>
|
||||
<div class="podman-field-row">
|
||||
<label>Versions</label>
|
||||
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/plugins/podman/javascript/app.js"></script>
|
||||
<script src="/plugins/podman/javascript/dashboard.js"></script>
|
||||
<script src="/plugins/podman/javascript/containers.js"></script>
|
||||
<script src="/plugins/podman/javascript/pods.js"></script>
|
||||
<script src="/plugins/podman/javascript/images.js"></script>
|
||||
<script src="/plugins/podman/javascript/volumes.js"></script>
|
||||
<script src="/plugins/podman/javascript/networks.js"></script>
|
||||
<script src="/plugins/podman/javascript/logs.js"></script>
|
||||
<script src="/plugins/podman/javascript/terminal.js"></script>
|
||||
<script src="/plugins/podman/javascript/compose.js"></script>
|
||||
<script src="/plugins/podman/javascript/settings.js"></script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/compose.php
|
||||
*
|
||||
* Backs the Compose panel — and is the ONE deliberate exception to
|
||||
* "talk to podman.sock, never shell out" in this entire WebUI.
|
||||
*
|
||||
* Why: libpod's REST API has no endpoint that understands
|
||||
* docker-compose.yml/compose.yaml at all. The closest thing,
|
||||
* POST /libpod/kube/play, takes Kubernetes YAML, not Compose YAML, and
|
||||
* correctly translating arbitrary Compose files into Kube manifests is a
|
||||
* substantial project of its own (this is exactly what `podman compose` /
|
||||
* `podman-compose` already do). Reimplementing that translation from
|
||||
* scratch here — instead of using the `podman compose` CLI, which IS the
|
||||
* project's own supported way to run Compose files — would be more
|
||||
* fragile, not more "API-native": there is no API to be native to for
|
||||
* this one feature. So this file, and only this file, runs the
|
||||
* `podman compose` CLI via proc_open(), with strict input validation
|
||||
* (project names are matched against a fixed pattern, never interpolated
|
||||
* into a shell string) rather than passing user input through a shell.
|
||||
*
|
||||
* Compose projects live under $bootDir/compose/<project>/compose.yaml —
|
||||
* see docs/ARCHITECTURE.md; this mirrors how autostart/networks/backups
|
||||
* are all rooted under /boot/config/plugins/podman/ for the same
|
||||
* boot-persistence reasons.
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> known projects with up/down status
|
||||
* get GET (&project=...) -> raw compose.yaml content
|
||||
* up POST {"project": "..."}
|
||||
* down POST {"project": "..."}
|
||||
* pull POST {"project": "..."}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$composeDir = $podmanConfig->bootDir . '/compose';
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(compose_list($composeDir));
|
||||
break;
|
||||
|
||||
case 'get':
|
||||
$project = (string) ($_GET['project'] ?? '');
|
||||
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
|
||||
break;
|
||||
|
||||
case 'up':
|
||||
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
|
||||
break;
|
||||
|
||||
case 'down':
|
||||
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['down']));
|
||||
break;
|
||||
|
||||
case 'pull':
|
||||
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['pull']));
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
function require_project(array $body): string
|
||||
{
|
||||
$project = (string) ($body['project'] ?? '');
|
||||
if (!is_valid_project_name($project)) {
|
||||
podman_json_error('Missing or invalid project name', 400);
|
||||
}
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project names come from the user (a form field when creating a new
|
||||
* compose project, or a value round-tripped from list()). Restricting
|
||||
* them to a fixed safe character set here — BEFORE they're ever used to
|
||||
* build a filesystem path or a command argument — is what makes it safe
|
||||
* to pass them to proc_open() at all.
|
||||
*/
|
||||
function is_valid_project_name(string $name): bool
|
||||
{
|
||||
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function compose_list(string $composeDir): array
|
||||
{
|
||||
if (!is_dir($composeDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$projects = [];
|
||||
foreach (scandir($composeDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_valid_project_name($entry)) {
|
||||
continue;
|
||||
}
|
||||
$yamlPath = $composeDir . '/' . $entry . '/compose.yaml';
|
||||
if (!is_file($yamlPath)) {
|
||||
continue;
|
||||
}
|
||||
$projects[] = [
|
||||
'name' => $entry,
|
||||
'path' => $yamlPath,
|
||||
'status' => compose_status($composeDir, $entry),
|
||||
];
|
||||
}
|
||||
|
||||
usort($projects, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/** Best-effort "up"/"down" status via `podman compose ps`; degrades to "unknown" rather than failing the whole list. */
|
||||
function compose_status(string $composeDir, string $project): string
|
||||
{
|
||||
$result = run_compose_command($composeDir, $project, ['ps', '--format', 'json'], 5);
|
||||
if ($result['exitCode'] !== 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
$decoded = json_decode($result['output'], true);
|
||||
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function compose_read(string $composeDir, string $project): string
|
||||
{
|
||||
if (!is_valid_project_name($project)) {
|
||||
podman_json_error('Invalid project name', 400);
|
||||
}
|
||||
$path = $composeDir . '/' . $project . '/compose.yaml';
|
||||
$content = is_file($path) ? file_get_contents($path) : false;
|
||||
if ($content === false) {
|
||||
podman_json_error("compose.yaml not found for project '{$project}'", 404);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
function compose_run(string $composeDir, string $project, array $subcommand): array
|
||||
{
|
||||
$result = run_compose_command($composeDir, $project, $subcommand, 300);
|
||||
if ($result['exitCode'] !== 0) {
|
||||
podman_json_error("podman compose " . implode(' ', $subcommand) . " failed:\n" . $result['output'], 502);
|
||||
}
|
||||
return ['output' => $result['output']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `podman compose -f <project>/compose.yaml <subcommand...>` via
|
||||
* proc_open with an argv array (never a shell string — proc_open with an
|
||||
* array argument bypasses the shell entirely, so there is no injection
|
||||
* surface even though $project has already been validated above too).
|
||||
*
|
||||
* @param array<int,string> $subcommand
|
||||
* @return array{exitCode:int,output:string}
|
||||
*/
|
||||
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
|
||||
{
|
||||
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
|
||||
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
|
||||
|
||||
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
|
||||
if (!is_resource($process)) {
|
||||
return ['exitCode' => 127, 'output' => 'Could not start podman compose process'];
|
||||
}
|
||||
|
||||
stream_set_timeout($pipes[1], $timeoutSeconds);
|
||||
$stdout = stream_get_contents($pipes[1]) ?: '';
|
||||
$stderr = stream_get_contents($pipes[2]) ?: '';
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/containers.php
|
||||
*
|
||||
* Backs the Containers panel (and the container rows nested inside the
|
||||
* Pods panel — see ajax/pods.php). All actions go through PodmanClient,
|
||||
* i.e. the real libpod REST API over podman.sock; nothing here shells out.
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> normalized array of containers for the table view
|
||||
* inspect GET (&id=...) -> raw inspect JSON, for a detail dialog
|
||||
* start POST {"id": "..."}
|
||||
* stop POST {"id": "...", "timeout": 10}
|
||||
* restart POST {"id": "...", "timeout": 10}
|
||||
* remove POST {"id": "...", "force": false}
|
||||
* logs GET (&id=...&tail=200) -> plain text
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(containers_list($client));
|
||||
break;
|
||||
|
||||
case 'inspect':
|
||||
$id = (string) ($_GET['id'] ?? '');
|
||||
if ($id === '') {
|
||||
podman_json_error('Missing id', 400);
|
||||
}
|
||||
podman_json_response($client->inspectContainer($id));
|
||||
break;
|
||||
|
||||
case 'logs':
|
||||
$id = (string) ($_GET['id'] ?? '');
|
||||
if ($id === '') {
|
||||
podman_json_error('Missing id', 400);
|
||||
}
|
||||
$tail = (int) ($_GET['tail'] ?? 200);
|
||||
podman_json_response(['text' => $client->containerLogs($id, $tail)]);
|
||||
break;
|
||||
|
||||
case 'start':
|
||||
$body = podman_read_json_body();
|
||||
$client->startContainer(require_id($body));
|
||||
podman_json_response(['status' => 'started']);
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
$body = podman_read_json_body();
|
||||
$client->stopContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
|
||||
podman_json_response(['status' => 'stopped']);
|
||||
break;
|
||||
|
||||
case 'restart':
|
||||
$body = podman_read_json_body();
|
||||
$client->restartContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
|
||||
podman_json_response(['status' => 'restarted']);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$client->removeContainer(require_id($body), (bool) ($body['force'] ?? false));
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
function require_id(array $body): string
|
||||
{
|
||||
$id = (string) ($body['id'] ?? '');
|
||||
if ($id === '') {
|
||||
podman_json_error('Missing id in request body', 400);
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes libpod's /containers/json entries into exactly what the
|
||||
* Containers table (javascript/containers.js) renders — keeping this
|
||||
* shaping logic server-side means the frontend never has to know libpod's
|
||||
* raw field names/quirks (e.g. Names is an array, State vs Status, etc).
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
function containers_list(PodmanClient $client): array
|
||||
{
|
||||
$raw = $client->listContainers(true);
|
||||
$out = [];
|
||||
|
||||
foreach ($raw as $c) {
|
||||
$names = $c['Names'] ?? [];
|
||||
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
|
||||
|
||||
$ports = [];
|
||||
foreach (($c['Ports'] ?? []) as $p) {
|
||||
if (isset($p['host_port'], $p['container_port'])) {
|
||||
$ports[] = "{$p['host_port']}:{$p['container_port']}/" . ($p['protocol'] ?? 'tcp');
|
||||
} elseif (isset($p['container_port'])) {
|
||||
$ports[] = "{$p['container_port']}/" . ($p['protocol'] ?? 'tcp');
|
||||
}
|
||||
}
|
||||
|
||||
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
|
||||
$state = strtolower((string) ($c['State'] ?? 'unknown'));
|
||||
|
||||
$out[] = [
|
||||
'id' => (string) ($c['Id'] ?? ''),
|
||||
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
|
||||
'name' => $name,
|
||||
'image' => (string) ($c['Image'] ?? ''),
|
||||
'state' => $state,
|
||||
'status' => (string) ($c['Status'] ?? ''),
|
||||
'health' => $c['Health']['Status'] ?? null,
|
||||
'ports' => $ports,
|
||||
'pod' => $c['Pod'] ?? null,
|
||||
'podName' => $c['PodName'] ?? null,
|
||||
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
|
||||
'createdAt' => podman_parse_time($c['Created'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/exec.php
|
||||
*
|
||||
* Backs the Terminal panel — and this is the one panel where "exclusively
|
||||
* via podman system service, no shell hacks" needs an honest caveat
|
||||
* spelled out rather than silently glossed over:
|
||||
*
|
||||
* libpod's real exec API (POST /containers/{id}/exec, then
|
||||
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never
|
||||
* shells out to the `podman` binary. But that API's interactive mode works
|
||||
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw
|
||||
* bidirectional byte stream for the lifetime of the shell session. That
|
||||
* model assumes a long-lived process holding the socket open on both ends
|
||||
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit
|
||||
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh,
|
||||
* independent, short-lived process with no memory of any previous one.
|
||||
*
|
||||
* Rather than fake interactivity with something that would break on the
|
||||
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
|
||||
* offers a deliberately simpler, honest contract: one command in, its
|
||||
* complete output back, using Tty=true so output reads like a real
|
||||
* terminal (colors, prompts-in-output, etc. survive) but with no
|
||||
* persistent shell state (`cd` does not carry over between calls — see
|
||||
* the "cwd" parameter below, which javascript/terminal.js tracks
|
||||
* client-side and resends every time instead).
|
||||
*
|
||||
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
|
||||
* would need a WebSocket-capable process sitting between the browser and
|
||||
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
|
||||
* follow-up rather than implemented as a shell-out workaround.
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'run':
|
||||
$body = podman_read_json_body();
|
||||
$id = (string) ($body['id'] ?? '');
|
||||
$commandLine = (string) ($body['cmd'] ?? '');
|
||||
$cwd = (string) ($body['cwd'] ?? '');
|
||||
|
||||
if ($id === '' || trim($commandLine) === '') {
|
||||
podman_json_error('Missing id or cmd in request body', 400);
|
||||
}
|
||||
|
||||
// The command line is run through the container's own shell
|
||||
// (sh -c) so the user can type ordinary shell syntax (pipes,
|
||||
// globs, env vars) in the terminal box, exactly like a real
|
||||
// shell prompt would accept — still one real exec API call, just
|
||||
// with /bin/sh as the interpreter instead of us parsing shell
|
||||
// syntax ourselves in PHP.
|
||||
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
|
||||
|
||||
podman_json_response(['output' => $output]);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/images.php
|
||||
*
|
||||
* Backs the Images panel.
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> normalized image list
|
||||
* pull POST {"reference": "docker.io/library/postgres:16"}
|
||||
* remove POST {"id": "...", "force": false}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(images_list($client));
|
||||
break;
|
||||
|
||||
case 'pull':
|
||||
$body = podman_read_json_body();
|
||||
$reference = (string) ($body['reference'] ?? '');
|
||||
if ($reference === '') {
|
||||
podman_json_error('Missing reference in request body', 400);
|
||||
}
|
||||
podman_json_response($client->pullImage($reference));
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$id = (string) ($body['id'] ?? '');
|
||||
if ($id === '') {
|
||||
podman_json_error('Missing id in request body', 400);
|
||||
}
|
||||
$client->removeImage($id, (bool) ($body['force'] ?? false));
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function images_list(PodmanClient $client): array
|
||||
{
|
||||
$raw = $client->listImages();
|
||||
|
||||
// In-use counts let the frontend show "0" (safe to remove) vs a
|
||||
// positive count, without a separate round trip per image.
|
||||
$usageCounts = [];
|
||||
foreach ($client->listContainers(true) as $c) {
|
||||
$imageId = (string) ($c['ImageID'] ?? '');
|
||||
if ($imageId !== '') {
|
||||
$usageCounts[$imageId] = ($usageCounts[$imageId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($raw as $img) {
|
||||
$id = (string) ($img['Id'] ?? '');
|
||||
$repoTags = $img['RepoTags'] ?? [];
|
||||
$repository = '<none>';
|
||||
$tag = '<none>';
|
||||
if (is_array($repoTags) && count($repoTags) > 0 && is_string($repoTags[0]) && str_contains($repoTags[0], ':')) {
|
||||
[$repository, $tag] = explode(':', $repoTags[0], 2);
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'id' => $id,
|
||||
'shortId' => podman_short_id($id),
|
||||
'repository' => $repository,
|
||||
'tag' => $tag,
|
||||
'sizeBytes' => (int) ($img['Size'] ?? 0),
|
||||
'sizeFormatted' => podman_format_bytes((int) ($img['Size'] ?? 0)),
|
||||
// Unlike containers' Created/StartedAt (RFC3339 strings), libpod
|
||||
// reports image Created as a Unix timestamp integer directly.
|
||||
'createdAt' => isset($img['Created']) ? (int) $img['Created'] : null,
|
||||
'usedBy' => $usageCounts[$id] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
usort($out, static fn($a, $b) => strcmp($a['repository'], $b['repository']));
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/networks.php
|
||||
*
|
||||
* Backs the Networks panel. See docs/ARCHITECTURE.md section 8 for why
|
||||
* networks created here are netavark-backed and deliberately isolated
|
||||
* from Docker's own docker0/custom-network space.
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> normalized network list with subnet/gateway/usage
|
||||
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
|
||||
* remove POST {"name": "...", "force": false}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(networks_list($client));
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$body = podman_read_json_body();
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
podman_json_response($client->createNetwork(
|
||||
$name,
|
||||
(string) ($body['driver'] ?? 'bridge'),
|
||||
isset($body['subnet']) ? (string) $body['subnet'] : null,
|
||||
isset($body['gateway']) ? (string) $body['gateway'] : null
|
||||
));
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
// podman0, the default bridge, refuses removal API-side — no
|
||||
// special-casing needed here, PodmanApiException surfaces podman's
|
||||
// own rejection message as-is.
|
||||
$client->removeNetwork($name, (bool) ($body['force'] ?? false));
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function networks_list(PodmanClient $client): array
|
||||
{
|
||||
$raw = $client->listNetworks();
|
||||
|
||||
$usageCounts = [];
|
||||
foreach ($client->listContainers(true) as $c) {
|
||||
$nets = $c['Networks'] ?? [];
|
||||
if (is_array($nets)) {
|
||||
foreach ($nets as $netName) {
|
||||
if (is_string($netName)) {
|
||||
$usageCounts[$netName] = ($usageCounts[$netName] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($raw as $n) {
|
||||
$name = (string) ($n['name'] ?? '');
|
||||
$subnets = $n['subnets'] ?? [];
|
||||
$subnet = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['subnet'] ?? '') : '';
|
||||
$gateway = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['gateway'] ?? '') : '';
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'driver' => (string) ($n['driver'] ?? 'bridge'),
|
||||
'subnet' => $subnet,
|
||||
'gateway' => $gateway,
|
||||
'isDefault' => $name === 'podman',
|
||||
'containers' => $usageCounts[$name] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/pods.php
|
||||
*
|
||||
* Backs the Pods panel. A pod is rendered as its own card with the member
|
||||
* containers nested inside (see javascript/pods.js) — listPods() gives us
|
||||
* the membership, and we cross-reference the container list for per-member
|
||||
* status/image/ports rather than issuing one inspect call per container.
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> pods with nested container summaries
|
||||
* start POST {"name": "..."}
|
||||
* stop POST {"name": "...", "timeout": 10}
|
||||
* remove POST {"name": "...", "force": false}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(pods_list($client));
|
||||
break;
|
||||
|
||||
case 'start':
|
||||
$body = podman_read_json_body();
|
||||
$client->startPod(require_name($body));
|
||||
podman_json_response(['status' => 'started']);
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
$body = podman_read_json_body();
|
||||
$client->stopPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
|
||||
podman_json_response(['status' => 'stopped']);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
function require_name(array $body): string
|
||||
{
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function pods_list(PodmanClient $client): array
|
||||
{
|
||||
$pods = $client->listPods();
|
||||
$containersByPod = [];
|
||||
foreach (containers_grouped_by_pod($client) as $podId => $members) {
|
||||
$containersByPod[$podId] = $members;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($pods as $p) {
|
||||
$id = (string) ($p['Id'] ?? '');
|
||||
$out[] = [
|
||||
'id' => $id,
|
||||
'name' => (string) ($p['Name'] ?? ''),
|
||||
'status' => strtolower((string) ($p['Status'] ?? 'unknown')),
|
||||
'containersTotal' => (int) ($p['NumContainers'] ?? count($containersByPod[$id] ?? [])),
|
||||
'infraId' => $p['InfraId'] ?? null,
|
||||
'members' => $containersByPod[$id] ?? [],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,array<string,mixed>>> keyed by pod id */
|
||||
function containers_grouped_by_pod(PodmanClient $client): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($client->listContainers(true) as $c) {
|
||||
$podId = $c['Pod'] ?? null;
|
||||
if (!is_string($podId) || $podId === '') {
|
||||
continue;
|
||||
}
|
||||
$names = $c['Names'] ?? [];
|
||||
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
|
||||
$grouped[$podId][] = [
|
||||
'id' => (string) ($c['Id'] ?? ''),
|
||||
'name' => $name,
|
||||
'image' => (string) ($c['Image'] ?? ''),
|
||||
'state' => strtolower((string) ($c['State'] ?? 'unknown')),
|
||||
];
|
||||
}
|
||||
return $grouped;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/settings.php
|
||||
*
|
||||
* Backs the Settings panel. Unlike every other ajax/*.php file, this one
|
||||
* is NOT primarily a PodmanClient consumer — plugin settings (podman.cfg,
|
||||
* the autostart list, installed package versions) are unraid-podman's own
|
||||
* data on /boot, not a libpod-managed resource, so there is no API to call
|
||||
* here in the first place. Writes go straight to the same files
|
||||
* plugin/sbin/podman-config.sh and podman-autostart.sh read, using the
|
||||
* same paths (see include/Config.php, which mirrors podman-common.sh's
|
||||
* path constants).
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* get GET -> current settings + autostart list + package versions
|
||||
* save POST {"storagePath": "...", "storageImageSizeGb": 20,
|
||||
* "enabled": true, "stopTimeoutSeconds": 10}
|
||||
* autostart_save POST {"names": ["postgres", "nextcloud", ...]}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'get':
|
||||
podman_json_response(settings_get($podmanConfig));
|
||||
break;
|
||||
|
||||
case 'save':
|
||||
$body = podman_read_json_body();
|
||||
settings_save($podmanConfig, $body);
|
||||
podman_json_response(['status' => 'saved']);
|
||||
break;
|
||||
|
||||
case 'autostart_save':
|
||||
$body = podman_read_json_body();
|
||||
$names = $body['names'] ?? null;
|
||||
if (!is_array($names)) {
|
||||
podman_json_error('Missing names array in request body', 400);
|
||||
}
|
||||
autostart_save($podmanConfig, $names);
|
||||
podman_json_response(['status' => 'saved']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
function settings_get(PodmanConfig $config): array
|
||||
{
|
||||
return [
|
||||
'storagePath' => $config->storagePath,
|
||||
'storageImageSizeGb' => $config->storageImageSizeGb,
|
||||
'enabled' => $config->enabled,
|
||||
'stopTimeoutSeconds' => $config->stopTimeoutSeconds,
|
||||
'autostart' => autostart_read($config),
|
||||
'packageVersions' => installed_package_versions(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
function settings_save(PodmanConfig $config, array $input): void
|
||||
{
|
||||
$storagePath = isset($input['storagePath']) ? (string) $input['storagePath'] : $config->storagePath;
|
||||
if (!storage_path_is_safe($storagePath)) {
|
||||
podman_json_error(
|
||||
"storagePath ({$storagePath}) is under /mnt/user (FUSE/shfs). " .
|
||||
'The overlay storage driver needs a real mounted filesystem — use a cache pool or a specific disk path instead.',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'# Rewritten by the unraid-podman WebUI (ajax/settings.php).',
|
||||
'# Applies on the next "rc.podman restart" — see plugin/rc.d/rc.podman.',
|
||||
'STORAGE_PATH="' . $storagePath . '"',
|
||||
'STORAGE_IMAGE_SIZE_GB="' . (int) ($input['storageImageSizeGb'] ?? $config->storageImageSizeGb) . '"',
|
||||
'PODMAN_ENABLED="' . ((bool) ($input['enabled'] ?? $config->enabled) ? 'yes' : 'no') . '"',
|
||||
'STOP_TIMEOUT="' . (int) ($input['stopTimeoutSeconds'] ?? $config->stopTimeoutSeconds) . '"',
|
||||
'CONFIG_SCHEMA_VERSION="1"',
|
||||
'',
|
||||
];
|
||||
|
||||
$target = $config->bootDir . '/podman.cfg';
|
||||
if (file_put_contents($target, implode("\n", $lines), LOCK_EX) === false) {
|
||||
podman_json_error("Could not write {$target} — check permissions on /boot/config/plugins/podman/", 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors plugin/sbin/podman-common.sh's podman_storage_path_is_safe() —
|
||||
* kept in sync deliberately (both reject the same /mnt/user prefix, for
|
||||
* the same reason) rather than shelling out to the bash version, since
|
||||
* this is a one-line string check, not worth a process spawn for.
|
||||
*/
|
||||
function storage_path_is_safe(string $path): bool
|
||||
{
|
||||
return $path !== '/mnt/user' && !str_starts_with($path, '/mnt/user/');
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
function autostart_read(PodmanConfig $config): array
|
||||
{
|
||||
if (!is_readable($config->autostartFile)) {
|
||||
return [];
|
||||
}
|
||||
$lines = file($config->autostartFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$names = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim(preg_replace('/#.*$/', '', $line) ?? '');
|
||||
if ($line !== '') {
|
||||
$names[] = $line;
|
||||
}
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $names */
|
||||
function autostart_save(PodmanConfig $config, array $names): void
|
||||
{
|
||||
$lines = array_map(static fn($n) => (string) $n, $names);
|
||||
$content = implode("\n", $lines) . (count($lines) > 0 ? "\n" : '');
|
||||
if (file_put_contents($config->autostartFile, $content, LOCK_EX) === false) {
|
||||
podman_json_error("Could not write {$config->autostartFile}", 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads /usr/local/share/unraid-podman/installed-versions.env — the same
|
||||
* manifest plugin/sbin/podman-verify-packages.sh and
|
||||
* podman-update-packages.sh use (see plugin/podman.plg's postinstall step,
|
||||
* which generates it) — so Settings shows exactly what those tools would
|
||||
* report, not a second, possibly-diverging source of truth.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
function installed_package_versions(): array
|
||||
{
|
||||
$path = '/usr/local/share/unraid-podman/installed-versions.env';
|
||||
if (!is_readable($path)) {
|
||||
return [];
|
||||
}
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$out = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^([A-Z_][A-Z0-9_]*)="?([^"]*)"?$/', $line, $m)) {
|
||||
$out[$m[1]] = $m[2];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'summary':
|
||||
podman_json_response(system_summary($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);
|
||||
}
|
||||
|
||||
return [
|
||||
'reachable' => true,
|
||||
'socketPath' => $config->socketPath,
|
||||
'podmanVersion' => $info['Version']['Version'] ?? null,
|
||||
'containers' => [
|
||||
'total' => count($containers),
|
||||
'running' => $running,
|
||||
],
|
||||
'pods' => count($pods),
|
||||
'images' => count($images),
|
||||
'volumes' => count($volumes),
|
||||
'networks' => count($networks),
|
||||
'storage' => [
|
||||
'imagesSizeBytes' => $imagesSize,
|
||||
'imagesSizeFormatted' => podman_format_bytes($imagesSize),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/volumes.php
|
||||
*
|
||||
* Backs the Volumes panel. Bind-mounted appdata (e.g. /mnt/user/appdata/...)
|
||||
* intentionally does not appear here — only Podman-managed named volumes
|
||||
* do, since bind mounts aren't a libpod-managed resource at all (see
|
||||
* docs/ARCHITECTURE.md section 9).
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> normalized volume list, with usedBy counts
|
||||
* create POST {"name": "...", "driver": "local"}
|
||||
* remove POST {"name": "...", "force": false}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(volumes_list($client));
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$body = podman_read_json_body();
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local')));
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
$client->removeVolume($name, (bool) ($body['force'] ?? false));
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function volumes_list(PodmanClient $client): array
|
||||
{
|
||||
$raw = $client->listVolumes();
|
||||
|
||||
$usageCounts = [];
|
||||
foreach ($client->listContainers(true) as $c) {
|
||||
foreach (($c['Mounts'] ?? []) as $mount) {
|
||||
$volName = $mount['Name'] ?? null;
|
||||
if (is_string($volName) && $volName !== '') {
|
||||
$usageCounts[$volName] = ($usageCounts[$volName] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($raw as $v) {
|
||||
$name = (string) ($v['Name'] ?? '');
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'driver' => (string) ($v['Driver'] ?? 'local'),
|
||||
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
|
||||
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
|
||||
'usedBy' => $usageCounts[$name] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/**
|
||||
* Config.php
|
||||
*
|
||||
* Reads unraid-podman's own settings so the WebUI and the shell-based
|
||||
* plugin scripts under plugin/sbin/ agree on where everything lives —
|
||||
* this file is the PHP-side counterpart of plugin/sbin/podman-common.sh's
|
||||
* podman_load_cfg() function, and intentionally mirrors its defaults and
|
||||
* paths exactly (see that script for the shell equivalent).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class PodmanConfig
|
||||
{
|
||||
public string $storagePath;
|
||||
public int $storageImageSizeGb;
|
||||
public bool $enabled;
|
||||
public int $stopTimeoutSeconds;
|
||||
public string $socketPath;
|
||||
public string $bootDir;
|
||||
public string $autostartFile;
|
||||
public string $autostartDelayFile;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
// Defaults mirror podman-common.sh's podman_load_cfg() defaults.
|
||||
$this->bootDir = '/boot/config/plugins/podman';
|
||||
$this->storagePath = '/mnt/cache/system/podman';
|
||||
$this->storageImageSizeGb = 20;
|
||||
$this->enabled = true;
|
||||
$this->stopTimeoutSeconds = 10;
|
||||
$this->socketPath = '/var/run/podman/podman.sock';
|
||||
$this->autostartFile = $this->bootDir . '/autostart';
|
||||
$this->autostartDelayFile = $this->bootDir . '/autostart-delay';
|
||||
}
|
||||
|
||||
public static function load(): self
|
||||
{
|
||||
$cfg = new self();
|
||||
|
||||
$cfgFile = $cfg->bootDir . '/podman.cfg';
|
||||
if (is_readable($cfgFile)) {
|
||||
$values = self::parseShellStyleFile($cfgFile);
|
||||
if (isset($values['STORAGE_PATH'])) {
|
||||
$cfg->storagePath = $values['STORAGE_PATH'];
|
||||
}
|
||||
if (isset($values['STORAGE_IMAGE_SIZE_GB'])) {
|
||||
$cfg->storageImageSizeGb = (int) $values['STORAGE_IMAGE_SIZE_GB'];
|
||||
}
|
||||
if (isset($values['PODMAN_ENABLED'])) {
|
||||
$cfg->enabled = strtolower($values['PODMAN_ENABLED']) === 'yes';
|
||||
}
|
||||
if (isset($values['STOP_TIMEOUT'])) {
|
||||
$cfg->stopTimeoutSeconds = (int) $values['STOP_TIMEOUT'];
|
||||
}
|
||||
}
|
||||
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the simple `KEY="value"` / `KEY=value` shell-sourceable format
|
||||
* used by podman.cfg (see config/podman.cfg.example) WITHOUT executing
|
||||
* it as shell — this file is read by an unprivileged PHP-FPM worker,
|
||||
* so treating it as data rather than sourcing it is a deliberate
|
||||
* safety boundary, not just a convenience.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private static function parseShellStyleFile(string $path): array
|
||||
{
|
||||
$result = [];
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^([A-Z_][A-Z0-9_]*)=(.*)$/', $line, $m)) {
|
||||
continue;
|
||||
}
|
||||
[$_, $key, $value] = $m;
|
||||
$value = trim($value);
|
||||
// Strip one layer of matching quotes, if present.
|
||||
if (strlen($value) >= 2 && (
|
||||
($value[0] === '"' && str_ends_with($value, '"')) ||
|
||||
($value[0] === "'" && str_ends_with($value, "'"))
|
||||
)) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function newClient(): PodmanClient
|
||||
{
|
||||
return new PodmanClient($this->socketPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
/**
|
||||
* PodmanClient.php
|
||||
*
|
||||
* Thin PHP client for the Podman libpod REST API, spoken exclusively over
|
||||
* the local Unix socket that plugin/rc.d/rc.podman starts
|
||||
* (`podman system service`, see docs/ARCHITECTURE.md section 18.1). Every
|
||||
* AJAX endpoint under webui/plugins/podman/ajax/ goes through this class —
|
||||
* none of them shell out to the `podman` binary. The one deliberate,
|
||||
* documented exception to "talk to the API, not the CLI" is Compose
|
||||
* support (see ajax/compose.php), because compose has no REST equivalent
|
||||
* in libpod at all, not because it was more convenient to shell out.
|
||||
*
|
||||
* Design notes:
|
||||
* - One cURL handle per request (kept simple; this is a low-traffic
|
||||
* admin UI, not a high-throughput proxy — a persistent handle pool
|
||||
* would be premature).
|
||||
* - Every method returns a plain PHP array/bool decoded from the API's
|
||||
* JSON response, or throws PodmanApiException on a non-2xx response
|
||||
* or transport failure, so callers can use a single try/catch instead
|
||||
* of checking a mixed-shape return value after every call.
|
||||
* - No business logic lives here — this class only knows how to speak
|
||||
* the libpod API. Formatting, filtering, and aggregation belong in the
|
||||
* ajax/*.php endpoints that use it (see e.g. ajax/system.php for
|
||||
* dashboard aggregation).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class PodmanApiException extends \RuntimeException
|
||||
{
|
||||
public int $httpStatus;
|
||||
|
||||
public function __construct(string $message, int $httpStatus = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
$this->httpStatus = $httpStatus;
|
||||
}
|
||||
}
|
||||
|
||||
final class PodmanClient
|
||||
{
|
||||
/** libpod REST API version this client targets. */
|
||||
private const API_VERSION = 'v4.0.0';
|
||||
|
||||
private string $socketPath;
|
||||
private int $timeoutSeconds;
|
||||
|
||||
public function __construct(string $socketPath, int $timeoutSeconds = 15)
|
||||
{
|
||||
$this->socketPath = $socketPath;
|
||||
$this->timeoutSeconds = $timeoutSeconds;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// System
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** GET /info — engine + host information (used by the Dashboard/Settings panels). */
|
||||
public function info(): array
|
||||
{
|
||||
return $this->request('GET', '/info');
|
||||
}
|
||||
|
||||
/** GET /system/df — image/container/volume disk usage summary. */
|
||||
public function systemDf(): array
|
||||
{
|
||||
return $this->request('GET', '/system/df');
|
||||
}
|
||||
|
||||
/** Quick reachability check — used by ajax/system.php's status endpoint. */
|
||||
public function ping(): bool
|
||||
{
|
||||
try {
|
||||
$this->request('GET', '/_ping', [], true);
|
||||
return true;
|
||||
} catch (PodmanApiException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Containers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** GET /containers/json — list containers. $all=true includes stopped ones. */
|
||||
public function listContainers(bool $all = true): array
|
||||
{
|
||||
return $this->request('GET', '/containers/json', ['all' => $all ? 'true' : 'false']);
|
||||
}
|
||||
|
||||
/** GET /containers/{id}/json — full inspect data for one container. */
|
||||
public function inspectContainer(string $id): array
|
||||
{
|
||||
return $this->request('GET', '/containers/' . rawurlencode($id) . '/json');
|
||||
}
|
||||
|
||||
/** GET /containers/{id}/stats?stream=false — one-shot CPU/memory snapshot. */
|
||||
public function containerStats(string $id): array
|
||||
{
|
||||
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
|
||||
}
|
||||
|
||||
public function startContainer(string $id): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
|
||||
}
|
||||
|
||||
public function stopContainer(string $id, int $timeoutSeconds = 10): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/stop', ['t' => (string) $timeoutSeconds], true);
|
||||
}
|
||||
|
||||
public function restartContainer(string $id, int $timeoutSeconds = 10): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/restart', ['t' => (string) $timeoutSeconds], true);
|
||||
}
|
||||
|
||||
public function removeContainer(string $id, bool $force = false): void
|
||||
{
|
||||
$this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
|
||||
* where possible) log text. Podman's non-TTY log stream uses the same
|
||||
* 8-byte-frame-header multiplexing as `attach`; we strip those frame
|
||||
* headers in demuxStream() so callers just get plain text lines.
|
||||
*/
|
||||
public function containerLogs(string $id, int $tail = 200, bool $timestamps = true): string
|
||||
{
|
||||
$query = [
|
||||
'stdout' => 'true',
|
||||
'stderr' => 'true',
|
||||
'tail' => (string) $tail,
|
||||
'timestamps' => $timestamps ? 'true' : 'false',
|
||||
];
|
||||
$raw = $this->requestRaw('GET', '/containers/' . rawurlencode($id) . '/logs', $query);
|
||||
return self::demuxStream($raw);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Exec — see webui/plugins/podman/ajax/exec.php for the important
|
||||
// caveat: this implements one-shot "run a command, return its output"
|
||||
// semantics over the exec API, not a true interactive PTY (which would
|
||||
// require a persistent bidirectional connection this stack doesn't
|
||||
// have — see that file's header comment for the full explanation).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates and immediately runs one command inside a container via the
|
||||
* real libpod exec API (POST /containers/{id}/exec, then
|
||||
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
|
||||
* Tty=true is used deliberately so the response is a plain byte stream
|
||||
* with no frame-header demultiplexing needed (see containerLogs() for
|
||||
* the non-TTY case, which does need it).
|
||||
*/
|
||||
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
|
||||
{
|
||||
$createBody = [
|
||||
'AttachStdin' => false,
|
||||
'AttachStdout' => true,
|
||||
'AttachStderr' => true,
|
||||
'Tty' => true,
|
||||
'Cmd' => $cmd,
|
||||
];
|
||||
if ($workingDir !== '') {
|
||||
$createBody['WorkingDir'] = $workingDir;
|
||||
}
|
||||
|
||||
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
|
||||
$execId = $created['Id'] ?? null;
|
||||
if (!is_string($execId) || $execId === '') {
|
||||
throw new PodmanApiException('exec create response did not include an Id');
|
||||
}
|
||||
|
||||
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
|
||||
'Detach' => false,
|
||||
'Tty' => true,
|
||||
]);
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Pods
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
public function listPods(): array
|
||||
{
|
||||
return $this->request('GET', '/pods/json');
|
||||
}
|
||||
|
||||
public function inspectPod(string $name): array
|
||||
{
|
||||
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
|
||||
}
|
||||
|
||||
public function startPod(string $name): void
|
||||
{
|
||||
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
|
||||
}
|
||||
|
||||
public function stopPod(string $name, int $timeoutSeconds = 10): void
|
||||
{
|
||||
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
|
||||
}
|
||||
|
||||
public function removePod(string $name, bool $force = false): void
|
||||
{
|
||||
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Images
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
public function listImages(): array
|
||||
{
|
||||
return $this->request('GET', '/images/json');
|
||||
}
|
||||
|
||||
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
|
||||
public function pullImage(string $reference): array
|
||||
{
|
||||
return $this->request('POST', '/images/pull', ['reference' => $reference]);
|
||||
}
|
||||
|
||||
public function removeImage(string $id, bool $force = false): void
|
||||
{
|
||||
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Volumes
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
public function listVolumes(): array
|
||||
{
|
||||
return $this->request('GET', '/volumes/json');
|
||||
}
|
||||
|
||||
public function createVolume(string $name, string $driver = 'local'): array
|
||||
{
|
||||
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
|
||||
}
|
||||
|
||||
public function removeVolume(string $name, bool $force = false): void
|
||||
{
|
||||
$this->request('DELETE', '/volumes/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Networks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
public function listNetworks(): array
|
||||
{
|
||||
return $this->request('GET', '/networks/json');
|
||||
}
|
||||
|
||||
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
|
||||
{
|
||||
$body = ['name' => $name, 'driver' => $driver];
|
||||
if ($subnet !== null) {
|
||||
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
|
||||
}
|
||||
return $this->request('POST', '/networks/create', [], false, $body);
|
||||
}
|
||||
|
||||
public function removeNetwork(string $name, bool $force = false): void
|
||||
{
|
||||
$this->request('DELETE', '/networks/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Issues a request and JSON-decodes the response body.
|
||||
*
|
||||
* @param array<string,string> $query
|
||||
* @param bool $expectEmptyBody set true for endpoints that reply 204/200 with no/irrelevant body
|
||||
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
|
||||
{
|
||||
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
|
||||
if ($expectEmptyBody || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new PodmanApiException('Expected a JSON object/array response from ' . $path);
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a request and returns the raw response body as a string,
|
||||
* without JSON decoding — used for endpoints whose response isn't
|
||||
* JSON (logs, exec start) and internally by request().
|
||||
*
|
||||
* @param array<string,string> $query
|
||||
* @param array<mixed>|null $jsonBody
|
||||
*/
|
||||
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
|
||||
{
|
||||
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
|
||||
if (!empty($query)) {
|
||||
$url .= '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_UNIX_SOCKET_PATH => $this->socketPath,
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
if ($jsonBody !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jsonBody));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json']);
|
||||
}
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno !== 0) {
|
||||
throw new PodmanApiException(
|
||||
"Could not reach podman API socket ({$this->socketPath}): {$error}. Is rc.podman running?",
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
$detail = self::extractErrorMessage($body ?: '');
|
||||
throw new PodmanApiException("Podman API {$method} {$path} failed ({$status}): {$detail}", $status);
|
||||
}
|
||||
|
||||
return $body === false ? '' : $body;
|
||||
}
|
||||
|
||||
/** Best-effort extraction of libpod's {"cause":...,"message":...} error body shape. */
|
||||
private static function extractErrorMessage(string $body): string
|
||||
{
|
||||
$decoded = json_decode($body, true);
|
||||
if (is_array($decoded) && isset($decoded['message']) && is_string($decoded['message'])) {
|
||||
return $decoded['message'];
|
||||
}
|
||||
return $body !== '' ? $body : '(no response body)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips Docker/Podman's attach-stream frame headers from a
|
||||
* non-TTY multiplexed stdout/stderr stream. Each frame is an 8-byte
|
||||
* header — [stream type (1 byte), 0, 0, 0, big-endian uint32 length]
|
||||
* — followed by that many bytes of payload. Stream type 1 = stdout,
|
||||
* 2 = stderr; both are concatenated here since the UI just needs
|
||||
* readable log text, not separated channels.
|
||||
*/
|
||||
private static function demuxStream(string $raw): string
|
||||
{
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
// If the stream doesn't start with a recognizable frame header,
|
||||
// assume it's already plain text (e.g. a TTY-attached container's
|
||||
// logs, which libpod does not frame) and return it as-is.
|
||||
$firstByte = ord($raw[0]);
|
||||
if ($firstByte > 2) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
$out = '';
|
||||
$offset = 0;
|
||||
$len = strlen($raw);
|
||||
while ($offset + 8 <= $len) {
|
||||
$header = substr($raw, $offset, 8);
|
||||
$unpacked = unpack('Ctype/C3pad/Nsize', $header);
|
||||
if ($unpacked === false) {
|
||||
break;
|
||||
}
|
||||
$frameLen = $unpacked['size'];
|
||||
$offset += 8;
|
||||
if ($offset + $frameLen > $len) {
|
||||
// Truncated final frame — take what's left and stop.
|
||||
$out .= substr($raw, $offset);
|
||||
break;
|
||||
}
|
||||
$out .= substr($raw, $offset, $frameLen);
|
||||
$offset += $frameLen;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/**
|
||||
* bootstrap.php
|
||||
*
|
||||
* Required by every file under webui/plugins/podman/ajax/ as its very
|
||||
* first line. Centralizes the three things every endpoint would otherwise
|
||||
* repeat: loading the other include/ modules, turning any PodmanApiException
|
||||
* into the same JSON error envelope helpers.php's podman_json_error()
|
||||
* produces (so the frontend never has to special-case "the socket was
|
||||
* unreachable" vs. "podman returned a 404" vs. "something else threw"),
|
||||
* and constructing a ready-to-use PodmanClient from the on-disk config.
|
||||
*
|
||||
* Usage, at the top of an ajax/*.php file:
|
||||
* require __DIR__ . '/../include/bootstrap.php';
|
||||
* // $client (PodmanClient) and $podmanConfig (PodmanConfig) are now set.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/PodmanClient.php';
|
||||
require_once __DIR__ . '/Config.php';
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
|
||||
set_exception_handler(static function (\Throwable $e): void {
|
||||
if ($e instanceof PodmanApiException) {
|
||||
// A 0 status means "couldn't even reach the socket" (transport
|
||||
// failure) rather than an HTTP error podman itself returned —
|
||||
// surfaced as 503 (Service Unavailable) since that's the more
|
||||
// accurate signal to the frontend than a generic 500.
|
||||
$status = $e->httpStatus > 0 ? $e->httpStatus : 503;
|
||||
podman_json_error($e->getMessage(), $status);
|
||||
}
|
||||
podman_json_error('Internal error: ' . $e->getMessage(), 500);
|
||||
});
|
||||
|
||||
$podmanConfig = PodmanConfig::load();
|
||||
$client = $podmanConfig->newClient();
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* helpers.php
|
||||
*
|
||||
* Small, stateless formatting helpers shared by the ajax/*.php endpoints.
|
||||
* Kept separate from PodmanClient (which only knows the API) and from the
|
||||
* endpoints themselves (which only know their one resource), so the same
|
||||
* "format 1610612736 bytes as 1.5 GB" logic isn't duplicated across
|
||||
* images.php, volumes.php, and system.php.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function podman_format_bytes(int $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = (int) floor(log($bytes, 1024));
|
||||
$i = min($i, count($units) - 1);
|
||||
$value = $bytes / (1024 ** $i);
|
||||
return sprintf($value >= 100 || $i === 0 ? '%.0f %s' : '%.1f %s', $value, $units[$i]);
|
||||
}
|
||||
|
||||
/** Formats a duration in seconds as a compact "14d 6h" / "6h 12m" / "38m" style string. */
|
||||
function podman_format_duration(int $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return $seconds . 's';
|
||||
}
|
||||
$days = intdiv($seconds, 86400);
|
||||
$hours = intdiv($seconds % 86400, 3600);
|
||||
$minutes = intdiv($seconds % 3600, 60);
|
||||
|
||||
if ($days > 0) {
|
||||
return "{$days}d {$hours}h";
|
||||
}
|
||||
if ($hours > 0) {
|
||||
return "{$hours}h {$minutes}m";
|
||||
}
|
||||
return "{$minutes}m";
|
||||
}
|
||||
|
||||
/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */
|
||||
function podman_parse_time(?string $rfc3339): ?int
|
||||
{
|
||||
if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime($rfc3339);
|
||||
return $ts === false ? null : $ts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortens a full image/container ID to the 12-character form Docker/
|
||||
* Podman CLIs conventionally display, matching what users expect to see
|
||||
* (and copy-paste into `podman inspect <id>`, which accepts short IDs).
|
||||
*/
|
||||
function podman_short_id(string $id): string
|
||||
{
|
||||
// Some APIs prefix with "sha256:" for image IDs.
|
||||
$id = str_starts_with($id, 'sha256:') ? substr($id, 7) : $id;
|
||||
return substr($id, 0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON response and terminates the request — every ajax/*.php
|
||||
* endpoint's single exit point, so response shape (envelope with "ok" and
|
||||
* either "data" or "error") is consistent for the frontend's shared AJAX
|
||||
* helper (javascript/app.js's request() function) to rely on.
|
||||
*/
|
||||
function podman_json_response(mixed $data, int $httpStatus = 200): never
|
||||
{
|
||||
http_response_code($httpStatus);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => $httpStatus < 400, 'data' => $data], JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function podman_json_error(string $message, int $httpStatus = 500): never
|
||||
{
|
||||
http_response_code($httpStatus);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and JSON-decodes the request body for POST/DELETE actions that
|
||||
* take parameters (e.g. {"id": "..."}), with a friendly error on
|
||||
* malformed input instead of a fatal error deep inside an endpoint.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function podman_read_json_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
podman_json_error('Request body must be a JSON object', 400);
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* javascript/app.js
|
||||
*
|
||||
* Shared runtime for the Podman plugin page: the AJAX helper every panel
|
||||
* module uses to talk to webui/plugins/podman/ajax/*.php, small DOM
|
||||
* utilities to avoid repeating the same escaping/formatting logic in ten
|
||||
* places, and the sub-tab router that shows/hides panels and lazily
|
||||
* initializes each one's module the first time it's opened.
|
||||
*
|
||||
* Loaded first (before any panel module) — see Podman.page.
|
||||
*/
|
||||
window.Podman = (function () {
|
||||
'use strict';
|
||||
|
||||
const BASE = '/plugins/podman/ajax/';
|
||||
|
||||
/**
|
||||
* Calls one ajax/<file>.php?action=<action> endpoint and resolves with
|
||||
* response.data, or rejects with an Error carrying the server's message
|
||||
* — every ajax/*.php endpoint replies with the same {ok, data|error}
|
||||
* envelope (see include/helpers.php's podman_json_response/_error), so
|
||||
* this one function is the only place that envelope shape is known.
|
||||
*
|
||||
* @param {string} file e.g. "containers"
|
||||
* @param {string} action e.g. "list"
|
||||
* @param {('GET'|'POST')} method
|
||||
* @param {object|null} body sent as JSON for POST
|
||||
* @param {object} query extra query-string params (e.g. {id: "..."})
|
||||
*/
|
||||
function call(file, action, method, body, query) {
|
||||
const params = new URLSearchParams(Object.assign({ action: action }, query || {}));
|
||||
const url = BASE + file + '.php?' + params.toString();
|
||||
|
||||
const opts = { method: method, headers: {} };
|
||||
if (body !== undefined && body !== null) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return fetch(url, opts)
|
||||
.then(function (res) {
|
||||
return res.json().then(function (envelope) {
|
||||
if (!envelope.ok) {
|
||||
throw new Error(envelope.error || ('Request failed (' + res.status + ')'));
|
||||
}
|
||||
return envelope.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function get(file, action, query) {
|
||||
return call(file, action, 'GET', null, query);
|
||||
}
|
||||
|
||||
function post(file, action, body) {
|
||||
return call(file, action, 'POST', body || {}, {});
|
||||
}
|
||||
|
||||
// --- DOM helpers ---------------------------------------------------------
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return (value >= 100 || i === 0 ? value.toFixed(0) : value.toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null) return '—';
|
||||
if (seconds < 60) return seconds + 's';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return days + 'd ' + hours + 'h';
|
||||
if (hours > 0) return hours + 'h ' + minutes + 'm';
|
||||
return minutes + 'm';
|
||||
}
|
||||
|
||||
function formatRelativeTime(unixSeconds) {
|
||||
if (!unixSeconds) return '—';
|
||||
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
/** Status string (from libpod's container "State") -> chip color class. */
|
||||
function stateChipClass(state) {
|
||||
switch (state) {
|
||||
case 'running': return 'podman-chip-good';
|
||||
case 'paused': return 'podman-chip-warn';
|
||||
case 'exited':
|
||||
case 'created': return 'podman-chip-neutral';
|
||||
default: return 'podman-chip-bad';
|
||||
}
|
||||
}
|
||||
|
||||
function loadingRow(colspan, label) {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-loading">' + escapeHtml(label || 'Loading…') + '</td></tr>';
|
||||
}
|
||||
|
||||
function errorRow(colspan, message) {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||
}
|
||||
|
||||
// --- Panel router ----------------------------------------------------------
|
||||
|
||||
const panelModules = {};
|
||||
const initialized = {};
|
||||
|
||||
/** Called by each panel's own JS file (e.g. containers.js) to register itself. */
|
||||
function registerPanel(name, module) {
|
||||
panelModules[name] = module;
|
||||
}
|
||||
|
||||
function activatePanel(name) {
|
||||
document.querySelectorAll('.podman-subnav button').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.dataset.panel === name);
|
||||
});
|
||||
document.querySelectorAll('.podman-panel').forEach(function (panel) {
|
||||
panel.classList.toggle('active', panel.id === 'podman-panel-' + name);
|
||||
});
|
||||
|
||||
const module = panelModules[name];
|
||||
if (!module) return;
|
||||
|
||||
if (!initialized[name]) {
|
||||
initialized[name] = true;
|
||||
if (typeof module.init === 'function') module.init();
|
||||
} else if (typeof module.refresh === 'function') {
|
||||
module.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const subnav = document.querySelector('.podman-subnav');
|
||||
if (!subnav) return;
|
||||
|
||||
subnav.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-panel]');
|
||||
if (btn) activatePanel(btn.dataset.panel);
|
||||
});
|
||||
|
||||
const refreshBtn = el('podman-refresh-all');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', function () {
|
||||
const active = document.querySelector('.podman-subnav button.active');
|
||||
if (active) activatePanel(active.dataset.panel);
|
||||
});
|
||||
}
|
||||
|
||||
// Activate whichever panel is marked active in the initial HTML
|
||||
// (Dashboard, by default — see Podman.page).
|
||||
const initial = document.querySelector('.podman-subnav button.active');
|
||||
activatePanel(initial ? initial.dataset.panel : 'dashboard');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
|
||||
return {
|
||||
get: get,
|
||||
post: post,
|
||||
escapeHtml: escapeHtml,
|
||||
el: el,
|
||||
formatBytes: formatBytes,
|
||||
formatDuration: formatDuration,
|
||||
formatRelativeTime: formatRelativeTime,
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
registerPanel: registerPanel,
|
||||
activatePanel: activatePanel,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* javascript/compose.js
|
||||
*
|
||||
* Compose panel: project list + read-only YAML view + up/down/pull,
|
||||
* backed by ajax/compose.php. See that file's header comment — this is
|
||||
* the one panel whose backend shells out to the `podman compose` CLI,
|
||||
* because no REST equivalent for Compose exists in libpod.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let projects = [];
|
||||
let selected = null;
|
||||
|
||||
function statusChip(status) {
|
||||
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
|
||||
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
P.el('compose-sidebar').innerHTML = projects.map(function (p) {
|
||||
return '<div class="podman-compose-proj' + (p.name === selected ? ' active' : '') + '" data-name="' + P.escapeHtml(p.name) + '">' +
|
||||
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
|
||||
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
|
||||
'</div>';
|
||||
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
|
||||
}
|
||||
|
||||
function loadYaml(name) {
|
||||
P.el('compose-title').textContent = name + ' / compose.yaml';
|
||||
P.el('compose-yaml').textContent = 'Loading…';
|
||||
return P.get('compose', 'get', { project: name }).then(function (data) {
|
||||
P.el('compose-yaml').textContent = data.yaml;
|
||||
}).catch(function (err) {
|
||||
P.el('compose-yaml').textContent = 'Error: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function selectProject(name) {
|
||||
selected = name;
|
||||
renderSidebar();
|
||||
loadYaml(name);
|
||||
}
|
||||
|
||||
function loadProjects() {
|
||||
return P.get('compose', 'list').then(function (data) {
|
||||
projects = data;
|
||||
if (!selected && projects.length > 0) selected = projects[0].name;
|
||||
renderSidebar();
|
||||
if (selected) loadYaml(selected);
|
||||
}).catch(function (err) {
|
||||
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function runAction(action) {
|
||||
if (!selected) return;
|
||||
const btn = P.el('compose-action-' + action);
|
||||
btn.disabled = true;
|
||||
P.post('compose', action, { project: selected }).then(function (data) {
|
||||
alert((data.output || 'Done.').slice(0, 2000));
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
alert('podman compose ' + action + ' failed: ' + err.message);
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('compose-sidebar').addEventListener('click', function (e) {
|
||||
const item = e.target.closest('.podman-compose-proj[data-name]');
|
||||
if (item) selectProject(item.dataset.name);
|
||||
});
|
||||
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
|
||||
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
|
||||
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
|
||||
|
||||
return loadProjects();
|
||||
}
|
||||
|
||||
P.registerPanel('compose', { init: init, refresh: loadProjects });
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* javascript/containers.js
|
||||
*
|
||||
* Containers panel: table of all containers with lifecycle actions
|
||||
* (start/stop/restart/remove), backed entirely by ajax/containers.php.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let allContainers = [];
|
||||
let filter = 'all';
|
||||
let searchTerm = '';
|
||||
|
||||
function iconLabel(name) {
|
||||
return P.escapeHtml(name.slice(0, 2).toUpperCase());
|
||||
}
|
||||
|
||||
function rowHtml(c) {
|
||||
const cpuMem = c.state === 'running'
|
||||
? '<span class="podman-row-sub">running</span>'
|
||||
: '<span class="podman-row-sub">—</span>';
|
||||
|
||||
return '' +
|
||||
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
||||
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
||||
'<td><div class="podman-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</div></td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
||||
'<td>' + cpuMem + '</td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
||||
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
|
||||
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function actionButtons(c) {
|
||||
if (c.state === 'running') {
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">■</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>🗑</button>';
|
||||
}
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">🗑</button>';
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
return allContainers.filter(function (c) {
|
||||
if (filter === 'running' && c.state !== 'running') return false;
|
||||
if (filter === 'stopped' && c.state === 'running') return false;
|
||||
if (searchTerm && c.name.toLowerCase().indexOf(searchTerm) === -1 && c.image.toLowerCase().indexOf(searchTerm) === -1) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = P.el('containers-tbody');
|
||||
const visible = applyFilters();
|
||||
tbody.innerHTML = visible.length
|
||||
? visible.map(rowHtml).join('')
|
||||
: '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
|
||||
}
|
||||
|
||||
function renderCounts() {
|
||||
const running = allContainers.filter(function (c) { return c.state === 'running'; }).length;
|
||||
P.el('containers-count-all').textContent = 'All ' + allContainers.length;
|
||||
P.el('containers-count-running').textContent = 'Running ' + running;
|
||||
P.el('containers-count-stopped').textContent = 'Stopped ' + (allContainers.length - running);
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('containers-tbody');
|
||||
tbody.innerHTML = P.loadingRow(7);
|
||||
return P.get('containers', 'list').then(function (data) {
|
||||
allContainers = data;
|
||||
renderCounts();
|
||||
renderTable();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(7, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function handleAction(id, action, btn) {
|
||||
const doIt = function (extra) {
|
||||
btn.disabled = true;
|
||||
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
||||
alert('Action failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
if (action === 'remove') {
|
||||
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
||||
doIt({ force: true });
|
||||
} else {
|
||||
doIt({});
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('containers-search').addEventListener('input', function (e) {
|
||||
searchTerm = e.target.value.trim().toLowerCase();
|
||||
renderTable();
|
||||
});
|
||||
|
||||
document.querySelectorAll('#containers-filterset button').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('#containers-filterset button').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
filter = btn.dataset.filter;
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
P.el('containers-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const row = btn.closest('tr');
|
||||
handleAction(row.dataset.id, btn.dataset.action, btn);
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('containers', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* javascript/dashboard.js
|
||||
*
|
||||
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary.
|
||||
* The Activity list and the CPU/Memory sparkline in the mockup were
|
||||
* illustrative sample data with no backing API (libpod has no "recent
|
||||
* events for a container fleet" convenience endpoint beyond raw
|
||||
* /events streaming, which is a separate follow-up — see the note
|
||||
* rendered in place of it below) — rather than fake data pretending to be
|
||||
* live, this real implementation shows what's genuinely available now
|
||||
* (the summary counts) and a clear placeholder for what needs the events
|
||||
* stream, so nobody mistakes a mock for a working feature.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
|
||||
function render(summary) {
|
||||
if (!summary.reachable) {
|
||||
P.el('podman-panel-dashboard').innerHTML =
|
||||
'<div class="podman-card"><div class="podman-error">' +
|
||||
'Cannot reach the Podman API socket (' + P.escapeHtml(summary.socketPath) + '). ' +
|
||||
'Is rc.podman running? Try <code>rc.podman status</code> from a terminal.' +
|
||||
'</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
P.el('stat-running').textContent = summary.containers.running;
|
||||
P.el('stat-running-total').textContent = '/ ' + summary.containers.total;
|
||||
P.el('stat-pods').textContent = summary.pods;
|
||||
P.el('stat-images').textContent = summary.images;
|
||||
P.el('stat-volumes').textContent = summary.volumes;
|
||||
P.el('stat-networks').textContent = summary.networks;
|
||||
P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted;
|
||||
|
||||
const meta = P.el('podman-header-meta');
|
||||
if (meta) {
|
||||
meta.innerHTML =
|
||||
'<span class="dot-good">●</span> podman.sock connected' +
|
||||
(summary.podmanVersion ? ' · v' + P.escapeHtml(summary.podmanVersion) : '') +
|
||||
' · ' + summary.containers.running + ' of ' + summary.containers.total + ' containers running';
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
return P.get('system', 'summary').then(render).catch(function (err) {
|
||||
P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('dashboard', { init: load, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* javascript/images.js
|
||||
*
|
||||
* Images panel: table + a "Pull Image" action, backed by ajax/images.php.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let images = [];
|
||||
|
||||
function rowHtml(img) {
|
||||
const created = img.createdAt ? P.formatRelativeTime(img.createdAt) : '—';
|
||||
return '' +
|
||||
'<tr data-id="' + P.escapeHtml(img.id) + '">' +
|
||||
'<td>' + P.escapeHtml(img.repository) + '</td>' +
|
||||
'<td class="mono">' + P.escapeHtml(img.tag) + '</td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(img.shortId) + '</td>' +
|
||||
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
|
||||
'<td class="tnum">' + created + '</td>' +
|
||||
'<td class="tnum">' + img.usedBy + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = P.el('images-tbody');
|
||||
tbody.innerHTML = images.length
|
||||
? images.map(rowHtml).join('')
|
||||
: '<tr><td colspan="7" class="podman-empty-note">No images.</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('images-tbody');
|
||||
tbody.innerHTML = P.loadingRow(7);
|
||||
return P.get('images', 'list').then(function (data) {
|
||||
images = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(7, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('images-pull-btn').addEventListener('click', function () {
|
||||
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
|
||||
if (!reference) return;
|
||||
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
|
||||
alert('Pull failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
P.el('images-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const id = btn.closest('tr').dataset.id;
|
||||
if (!confirm('Remove this image?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('images', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* javascript/logs.js
|
||||
*
|
||||
* Logs panel: container selector sidebar + log pane, backed by
|
||||
* ajax/containers.php?action=logs. "Follow" polls on an interval rather
|
||||
* than opening a persistent stream — see javascript/terminal.js for the
|
||||
* same underlying constraint (PHP-FPM's request lifecycle) applied to a
|
||||
* different panel.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let containers = [];
|
||||
let selectedId = null;
|
||||
let follow = true;
|
||||
let pollHandle = null;
|
||||
|
||||
function levelClass(line) {
|
||||
if (/\berror\b/i.test(line)) return 'lvl-error';
|
||||
if (/\bwarn(ing)?\b/i.test(line)) return 'lvl-warn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderLog(text) {
|
||||
const pane = P.el('log-pane');
|
||||
const atBottom = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 20;
|
||||
const lines = text.split('\n').filter(function (l) { return l.length > 0; });
|
||||
pane.innerHTML = lines.map(function (line) {
|
||||
const cls = levelClass(line);
|
||||
return '<div class="l' + (cls ? ' ' + cls : '') + '">' + P.escapeHtml(line) + '</div>';
|
||||
}).join('') || '<div class="l podman-row-sub">(no output)</div>';
|
||||
if (atBottom) pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
function loadLogs() {
|
||||
if (!selectedId) return Promise.resolve();
|
||||
return P.get('containers', 'logs', { id: selectedId, tail: 300 }).then(function (data) {
|
||||
renderLog(data.text);
|
||||
}).catch(function (err) {
|
||||
P.el('log-pane').innerHTML = '<div class="l podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const side = P.el('logs-sidebar');
|
||||
side.innerHTML = containers.map(function (c) {
|
||||
return '<div class="item' + (c.id === selectedId ? ' active' : '') + '" data-id="' + P.escapeHtml(c.id) + '">' +
|
||||
'<span class="podman-chip ' + P.stateChipClass(c.state) + '" style="padding:2px 6px;"><span class="d"></span></span> ' +
|
||||
P.escapeHtml(c.name) + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectContainer(id) {
|
||||
selectedId = id;
|
||||
renderSidebar();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function setPolling(enabled) {
|
||||
follow = enabled;
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
if (follow) {
|
||||
pollHandle = setInterval(loadLogs, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('logs-sidebar').addEventListener('click', function (e) {
|
||||
const item = e.target.closest('.item[data-id]');
|
||||
if (item) selectContainer(item.dataset.id);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#logs-follow-toggle button').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('#logs-follow-toggle button').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
setPolling(btn.dataset.follow === 'true');
|
||||
});
|
||||
});
|
||||
|
||||
P.el('logs-filter').addEventListener('input', function (e) {
|
||||
const term = e.target.value.toLowerCase();
|
||||
P.el('log-pane').querySelectorAll('.l').forEach(function (line) {
|
||||
line.style.display = term === '' || line.textContent.toLowerCase().includes(term) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
return P.get('containers', 'list').then(function (data) {
|
||||
containers = data;
|
||||
if (containers.length > 0) {
|
||||
selectedId = containers[0].id;
|
||||
}
|
||||
renderSidebar();
|
||||
setPolling(true);
|
||||
return loadLogs();
|
||||
}).catch(function (err) {
|
||||
P.el('logs-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('logs', { init: init, refresh: loadLogs });
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* javascript/networks.js
|
||||
*
|
||||
* Networks panel: table + create/remove, backed by ajax/networks.php.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let networks = [];
|
||||
|
||||
function rowHtml(n) {
|
||||
const nameCell = n.isDefault
|
||||
? '<strong>' + P.escapeHtml(n.name) + '</strong> <span class="podman-row-sub">(default)</span>'
|
||||
: P.escapeHtml(n.name);
|
||||
const removeDisabled = n.isDefault || n.containers > 0;
|
||||
return '' +
|
||||
'<tr data-name="' + P.escapeHtml(n.name) + '">' +
|
||||
'<td>' + nameCell + '</td>' +
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(n.driver) + '</span></td>' +
|
||||
'<td class="mono">' + P.escapeHtml(n.subnet || '—') + '</td>' +
|
||||
'<td class="mono">' + P.escapeHtml(n.gateway || '—') + '</td>' +
|
||||
'<td class="tnum">' + n.containers + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(removeDisabled ? ' disabled' : '') + ' title="Remove">🗑</button></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = P.el('networks-tbody');
|
||||
tbody.innerHTML = networks.length
|
||||
? networks.map(rowHtml).join('')
|
||||
: '<tr><td colspan="6" class="podman-empty-note">No networks.</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('networks-tbody');
|
||||
tbody.innerHTML = P.loadingRow(6);
|
||||
return P.get('networks', 'list').then(function (data) {
|
||||
networks = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(6, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('networks-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New network name:');
|
||||
if (!name) return;
|
||||
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
|
||||
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
P.el('networks-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const name = btn.closest('tr').dataset.name;
|
||||
if (!confirm('Remove network "' + name + '"?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('networks', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* javascript/pods.js
|
||||
*
|
||||
* Pods panel: one card per pod with its member containers nested inside,
|
||||
* backed by ajax/pods.php (which itself cross-references containers.php's
|
||||
* data server-side — see that file for why).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
|
||||
function memberRow(m) {
|
||||
return '' +
|
||||
'<tr>' +
|
||||
'<td>' + P.escapeHtml(m.name) + '</td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(m.image) + '</td>' +
|
||||
'<td><span class="podman-chip ' + P.stateChipClass(m.state) + '"><span class="d"></span>' + P.escapeHtml(m.state) + '</span></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function podCard(pod) {
|
||||
const members = pod.members.length
|
||||
? pod.members.map(memberRow).join('')
|
||||
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
|
||||
|
||||
return '' +
|
||||
'<div class="podman-pod-card">' +
|
||||
'<div class="podman-pod-head">' +
|
||||
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
|
||||
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
|
||||
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
|
||||
'</div>' +
|
||||
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
|
||||
'<tbody>' + members + '</tbody></table></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const container = P.el('podman-panel-pods');
|
||||
return P.get('pods', 'list').then(function (pods) {
|
||||
container.innerHTML = pods.length
|
||||
? pods.map(podCard).join('')
|
||||
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
|
||||
}).catch(function (err) {
|
||||
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('pods', { init: load, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* javascript/settings.js
|
||||
*
|
||||
* Settings panel: reads/writes unraid-podman's own configuration via
|
||||
* ajax/settings.php (podman.cfg + the autostart list) — not a
|
||||
* PodmanClient/libpod concern, see that file's header comment.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let autostartNames = [];
|
||||
|
||||
function renderAutostart() {
|
||||
const tbody = P.el('autostart-tbody');
|
||||
tbody.innerHTML = autostartNames.length
|
||||
? autostartNames.map(function (name, i) {
|
||||
return '<tr data-index="' + i + '">' +
|
||||
'<td class="tnum">' + (i + 1) + '</td>' +
|
||||
'<td>' + P.escapeHtml(name) + '</td>' +
|
||||
'<td class="podman-actions">' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">↑</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">↓</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">🗑</button>' +
|
||||
'</td></tr>';
|
||||
}).join('')
|
||||
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
||||
}
|
||||
|
||||
function saveAutostart() {
|
||||
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
|
||||
alert('Could not save autostart order: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function fillForm(settings) {
|
||||
P.el('settings-storage-path').value = settings.storagePath;
|
||||
P.el('settings-storage-size').value = settings.storageImageSizeGb;
|
||||
P.el('settings-enabled').checked = settings.enabled;
|
||||
P.el('settings-stop-timeout').value = settings.stopTimeoutSeconds;
|
||||
|
||||
autostartNames = settings.autostart.slice();
|
||||
renderAutostart();
|
||||
|
||||
const versions = settings.packageVersions || {};
|
||||
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
|
||||
P.el('settings-package-versions').textContent = order
|
||||
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function load() {
|
||||
return P.get('settings', 'get').then(fillForm).catch(function (err) {
|
||||
alert('Could not load settings: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
const body = {
|
||||
storagePath: P.el('settings-storage-path').value.trim(),
|
||||
storageImageSizeGb: parseInt(P.el('settings-storage-size').value, 10) || 20,
|
||||
enabled: P.el('settings-enabled').checked,
|
||||
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
|
||||
};
|
||||
return P.post('settings', 'save', body).then(function () {
|
||||
alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.');
|
||||
}).catch(function (err) {
|
||||
alert('Save failed: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('settings-save-btn').addEventListener('click', save);
|
||||
|
||||
P.el('autostart-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const i = parseInt(btn.closest('tr').dataset.index, 10);
|
||||
const action = btn.dataset.action;
|
||||
|
||||
if (action === 'remove') {
|
||||
autostartNames.splice(i, 1);
|
||||
} else if (action === 'up' && i > 0) {
|
||||
[autostartNames[i - 1], autostartNames[i]] = [autostartNames[i], autostartNames[i - 1]];
|
||||
} else if (action === 'down' && i < autostartNames.length - 1) {
|
||||
[autostartNames[i + 1], autostartNames[i]] = [autostartNames[i], autostartNames[i + 1]];
|
||||
}
|
||||
renderAutostart();
|
||||
saveAutostart();
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('settings', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* javascript/terminal.js
|
||||
*
|
||||
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
|
||||
* file's header comment for the full, honest explanation of why this is
|
||||
* "type a command, see its output" rather than a true interactive PTY —
|
||||
* the short version is that libpod's interactive exec needs a persistent
|
||||
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
|
||||
* interactivity on top of that would break the moment a user ran anything
|
||||
* that expects a real terminal (vim, an interactive prompt, etc).
|
||||
*
|
||||
* `cd` is handled client-side: this module tracks a per-session `cwd` and
|
||||
* passes it as the exec's working directory on every call, so at least
|
||||
* directory navigation feels persistent even though nothing else is.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let cwd = '/';
|
||||
let containerId = null;
|
||||
|
||||
function appendLine(html) {
|
||||
const out = P.el('term-output');
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
out.appendChild(div);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function promptHtml() {
|
||||
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
|
||||
}
|
||||
|
||||
function runCommand(cmd) {
|
||||
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
|
||||
|
||||
// `cd <dir>` is intercepted client-side (see file header) rather than
|
||||
// sent as a real command, since a one-shot exec has no way to report
|
||||
// "the working directory changed" back to us otherwise.
|
||||
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
|
||||
if (cdMatch) {
|
||||
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
|
||||
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
|
||||
}).catch(function (err) {
|
||||
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
|
||||
});
|
||||
}
|
||||
|
||||
function populateContainerSelect(containers) {
|
||||
const select = P.el('term-container-select');
|
||||
select.innerHTML = containers
|
||||
.filter(function (c) { return c.state === 'running'; })
|
||||
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
|
||||
.join('');
|
||||
containerId = select.value || null;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const input = P.el('term-input');
|
||||
|
||||
P.el('term-container-select').addEventListener('change', function (e) {
|
||||
containerId = e.target.value;
|
||||
cwd = '/';
|
||||
P.el('term-output').innerHTML = '';
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter') return;
|
||||
const cmd = input.value;
|
||||
input.value = '';
|
||||
if (!containerId) {
|
||||
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
|
||||
return;
|
||||
}
|
||||
if (cmd.trim() === '') return;
|
||||
runCommand(cmd);
|
||||
});
|
||||
|
||||
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
|
||||
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('terminal', { init: init });
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* javascript/volumes.js
|
||||
*
|
||||
* Volumes panel: named-volume table + create/remove, backed by
|
||||
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
|
||||
* that file's header comment.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let volumes = [];
|
||||
|
||||
function rowHtml(v) {
|
||||
return '' +
|
||||
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
|
||||
'<td>' + P.escapeHtml(v.name) + '</td>' +
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</td>' +
|
||||
'<td class="tnum">' + v.usedBy + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = P.el('volumes-tbody');
|
||||
tbody.innerHTML = volumes.length
|
||||
? volumes.map(rowHtml).join('')
|
||||
: '<tr><td colspan="5" class="podman-empty-note">No named volumes.</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('volumes-tbody');
|
||||
tbody.innerHTML = P.loadingRow(5);
|
||||
return P.get('volumes', 'list').then(function (data) {
|
||||
volumes = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(5, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('volumes-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New volume name:');
|
||||
if (!name) return;
|
||||
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
P.el('volumes-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const name = btn.closest('tr').dataset.name;
|
||||
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
|
||||
btn.disabled = true;
|
||||
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('volumes', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* styles/podman.css
|
||||
*
|
||||
* Design tokens and component styles for the Podman plugin page. Ported
|
||||
* directly from the approved mockup (webui/mockups/prototype.html) so the
|
||||
* real implementation is visually identical to what was reviewed — the
|
||||
* masthead/main-nav chrome from that mockup is NOT included here, since
|
||||
* Podman.page renders inside Unraid's own real chrome, not a recreation
|
||||
* of it.
|
||||
*
|
||||
* Token strategy: custom properties on :root, redefined under
|
||||
* prefers-color-scheme (OS default) and under [data-theme] (explicit
|
||||
* override, e.g. from Unraid's own theme setting) — component rules below
|
||||
* only ever reference the tokens, never hardcode a color, so both themes
|
||||
* stay in sync automatically. See docs/ARCHITECTURE.md section 18.
|
||||
*/
|
||||
|
||||
.podman-plugin {
|
||||
--bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee;
|
||||
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
|
||||
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
|
||||
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
|
||||
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
|
||||
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05);
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.podman-plugin {
|
||||
--bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36;
|
||||
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
|
||||
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
|
||||
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
|
||||
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] .podman-plugin {
|
||||
--bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36;
|
||||
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
|
||||
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
|
||||
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
|
||||
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
|
||||
}
|
||||
:root[data-theme="light"] .podman-plugin {
|
||||
--bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee;
|
||||
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
|
||||
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
|
||||
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
|
||||
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
|
||||
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
|
||||
}
|
||||
|
||||
.podman-plugin * { box-sizing: border-box; }
|
||||
.podman-plugin h1, .podman-plugin h2, .podman-plugin h3 { text-wrap: balance; margin: 0; font-weight: 600; }
|
||||
.podman-plugin .tnum { font-variant-numeric: tabular-nums; }
|
||||
.podman-plugin .mono { font-family: var(--font-mono); }
|
||||
.podman-plugin :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
|
||||
|
||||
.podman-pagehead {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap; padding: 4px 0 0;
|
||||
}
|
||||
.podman-pagehead .titlewrap { display: flex; align-items: center; gap: 12px; }
|
||||
.podman-pagehead .icon {
|
||||
width: 38px; height: 38px; border-radius: 9px; background: var(--surface-3);
|
||||
display: grid; place-items: center; color: var(--accent); border: 1px solid var(--border);
|
||||
}
|
||||
.podman-pagehead h1 { font-size: 21px; }
|
||||
.podman-pagehead .meta { font-size: 12.5px; color: var(--text-dim); margin-top: 2px; }
|
||||
.podman-pagehead .meta .dot-good { color: var(--good); }
|
||||
.podman-pagehead .meta .dot-bad { color: var(--bad); }
|
||||
|
||||
.podman-btn {
|
||||
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text);
|
||||
padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.podman-btn:hover { border-color: var(--text-faint); }
|
||||
.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
|
||||
.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
||||
.podman-btn-danger { color: var(--bad); }
|
||||
.podman-btn-danger:hover { border-color: var(--bad); }
|
||||
.podman-btn-icon { padding: 6px 8px; }
|
||||
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.podman-subnav {
|
||||
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto; list-style: none;
|
||||
}
|
||||
.podman-subnav button {
|
||||
appearance: none; background: none; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim); padding: 10px 12px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
white-space: nowrap; display: flex; align-items: center; gap: 6px; font-family: var(--font-ui);
|
||||
}
|
||||
.podman-subnav button:hover { color: var(--text); }
|
||||
.podman-subnav button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
|
||||
.podman-subnav button .n { font-size: 10.5px; padding: 1px 5px; border-radius: 8px; background: var(--surface-3); color: var(--text-dim); }
|
||||
|
||||
.podman-main { padding: 20px 0 48px; }
|
||||
.podman-panel { display: none; }
|
||||
.podman-panel.active { display: block; }
|
||||
|
||||
.podman-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); }
|
||||
.podman-card-pad { padding: 16px 18px; }
|
||||
.podman-card-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
||||
.podman-card-head h2 { font-size: 14.5px; }
|
||||
.podman-card-head .sub { font-size: 12px; color: var(--text-faint); font-weight: 500; }
|
||||
|
||||
.podman-grid { display: grid; gap: 14px; }
|
||||
.podman-stat-grid { grid-template-columns: repeat(6, 1fr); }
|
||||
@media (max-width: 1080px) { .podman-stat-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 620px) { .podman-stat-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.podman-stat { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); }
|
||||
.podman-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
|
||||
.podman-stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; }
|
||||
.podman-stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; }
|
||||
.podman-stat .bar { height: 5px; border-radius: 3px; background: var(--surface-3); margin-top: 10px; overflow: hidden; }
|
||||
.podman-stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
|
||||
|
||||
.podman-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px;
|
||||
font-size: 11.5px; font-weight: 700; letter-spacing: .01em; white-space: nowrap;
|
||||
}
|
||||
.podman-chip .d { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.podman-chip-good { background: var(--good-bg); color: var(--good); }
|
||||
.podman-chip-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.podman-chip-bad { background: var(--bad-bg); color: var(--bad); }
|
||||
.podman-chip-neutral { background: var(--neutral-bg); color: var(--neutral); }
|
||||
|
||||
.podman-plugin table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.podman-plugin thead th {
|
||||
text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--text-faint); font-weight: 700; padding: 10px 18px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.podman-plugin tbody td { padding: 11px 18px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
.podman-plugin tbody tr:last-child td { border-bottom: none; }
|
||||
.podman-plugin tbody tr:hover { background: var(--surface-2); }
|
||||
.podman-table-wrap { overflow-x: auto; }
|
||||
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.podman-row-name .ico {
|
||||
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
||||
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
||||
}
|
||||
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
|
||||
.podman-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
|
||||
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
|
||||
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
||||
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
|
||||
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
|
||||
|
||||
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
|
||||
.podman-search::placeholder { color: var(--text-faint); }
|
||||
|
||||
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
|
||||
@media (max-width: 920px) { .podman-two-col { grid-template-columns: 1fr; } }
|
||||
|
||||
.podman-activity-list { list-style: none; margin: 0; padding: 6px 0; }
|
||||
.podman-activity-list li { display: flex; gap: 10px; padding: 9px 18px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
|
||||
.podman-activity-list li:last-child { border-bottom: none; }
|
||||
.podman-activity-list .dot { width: 7px; height: 7px; border-radius: 50%; margin-top: 5px; flex: none; }
|
||||
.podman-activity-list .when { color: var(--text-faint); font-size: 11px; margin-top: 1px; }
|
||||
|
||||
.podman-empty-note { padding: 28px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
|
||||
|
||||
.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
||||
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
|
||||
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
|
||||
|
||||
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
|
||||
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
|
||||
.podman-logs-side { border-right: 1px solid var(--border); }
|
||||
.podman-logs-side .item { padding: 10px 14px; font-size: 12.5px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
||||
.podman-logs-side .item.active { background: var(--surface-2); font-weight: 700; box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.podman-log-pane {
|
||||
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
|
||||
padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65;
|
||||
}
|
||||
.podman-log-pane .l { white-space: pre-wrap; word-break: break-word; }
|
||||
.podman-log-pane .ts { color: #6b7280; }
|
||||
.podman-log-pane .lvl-warn { color: #e0b23d; }
|
||||
.podman-log-pane .lvl-error { color: #ef6470; }
|
||||
|
||||
.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; }
|
||||
.podman-term .prompt { color: #4cc785; }
|
||||
.podman-term .path { color: #6fb2f5; }
|
||||
.podman-term-input {
|
||||
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
|
||||
}
|
||||
|
||||
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
|
||||
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
|
||||
.podman-compose-side { border-right: 1px solid var(--border); }
|
||||
.podman-compose-proj { padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
|
||||
.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; }
|
||||
|
||||
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
|
||||
.podman-field-row:last-child { border-bottom: none; }
|
||||
.podman-field-row label { font-weight: 600; font-size: 13px; }
|
||||
.podman-field-row .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; max-width: 46ch; }
|
||||
.podman-field-row input[type="text"], .podman-field-row input[type="number"], .podman-field-row select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui);
|
||||
}
|
||||
.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
|
||||
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
|
||||
.podman-danger-card .podman-card-head h2 { color: var(--bad); }
|
||||
|
||||
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
|
||||
|
||||
.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
|
||||
.podman-error { color: var(--bad); }
|
||||
Reference in New Issue
Block a user