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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ document.addEventListener('click', async (event: MouseEvent) => {
const renameShellButton = target.closest<HTMLButtonElement>('[data-rename-shell]');
const resetShellLabelButton = target.closest<HTMLButtonElement>('[data-reset-shell-label]');
const renameSensorButton = target.closest<HTMLButtonElement>('[data-rename-sensor]');
const containerActionButton = target.closest<HTMLButtonElement>('[data-container-action]');
const shellinButton = target.closest<HTMLButtonElement>('[data-shellin]');
const resumeButton = target.closest<HTMLButtonElement>('[data-resume]');
const removeImageButton = target.closest<HTMLButtonElement>('[data-remove-image]');
Expand Down Expand Up @@ -49,6 +50,10 @@ document.addEventListener('click', async (event: MouseEvent) => {
return;
}
if (renameSensorButton) return renameSensorLabel(renameSensorButton.dataset.renameSensor || '');
if (containerActionButton) {
const d = containerActionButton.dataset;
return containerAction(d.chost || '', d.cengine || '', d.cname || '', d.containerAction || '');
}
if (shellinButton) {
openTerminal(shellinButton.dataset.shellin || '');
return;
Expand Down
45 changes: 42 additions & 3 deletions frontend/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,32 @@ function containerStatsText(c: ContainerInfo): string {
return bits.join(' · ');
}

// Compact age from a docker status string, e.g. "Up 7 days (healthy)" → "7d", "Up About an hour"
// → "~1h", "Exited (0) 3 minutes ago" → "" (stopped, no age badge).
function containerUptime(status: string): string {
if (!/^up\b/i.test(status)) return '';
if (/about an hour/i.test(status)) return '~1h';
if (/less than a (second|minute)/i.test(status)) return '<1m';
const m = /(\d+)\s*(second|minute|hour|day|week|month|year)/i.exec(status);
if (!m) return '';
const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[m[2].toLowerCase()] || '';
return `${m[1]}${unit}`;
}

// Restart + Pull-latest buttons. Hidden via CSS unless shells are unlocked; the click handler
// confirms and the server re-checks login + unlock + action header. host = remote host id ('' local).
function containerActionsHtml(c: ContainerInfo, host: string): string {
const attrs = `data-cname="${escapeHtml(c.name)}" data-cengine="${escapeHtml(c.engine)}" data-chost="${escapeHtml(host)}"`;
return `<div class="container-actions"><button type="button" class="container-action" data-container-action="restart" ${attrs} title="Restart ${escapeHtml(c.name)}">Restart</button><button type="button" class="container-action" data-container-action="pull" ${attrs} title="Pull latest image and recreate ${escapeHtml(c.name)}">Pull</button></div>`;
}

// Shared row for local + remote container lists. Stopped/unhealthy get a state class for greying.
function containerRowHtml(c: ContainerInfo, extraClass = ''): string {
function containerRowHtml(c: ContainerInfo, extraClass = '', host = ''): string {
const stats = containerStatsText(c);
const statsHtml = stats ? `<small class="container-stats">${escapeHtml(stats)}</small>` : '';
return `<div class="container-item ${extraClass} state-${containerState(c.status)}"><div><b>${escapeHtml(c.name)}</b><span>${escapeHtml(c.image)}</span></div><small>${escapeHtml(c.engine)}</small><em>${escapeHtml(c.status)}</em>${statsHtml}</div>`;
const age = containerUptime(c.status);
const ageHtml = age ? `<span class="container-age" title="${escapeHtml(c.status)}">${escapeHtml(age)}</span>` : '';
return `<div class="container-item ${extraClass} state-${containerState(c.status)}"><div><b>${escapeHtml(c.name)}</b><span>${escapeHtml(c.image)}</span></div><small>${escapeHtml(c.engine)}</small><em>${escapeHtml(c.status)}</em>${ageHtml}${statsHtml}${containerActionsHtml(c, host)}</div>`;
}

const SENSOR_LABEL_ALIASES_KEY = 'sdSensorLabelAliases';
Expand Down Expand Up @@ -272,7 +293,7 @@ function renderRemoteHosts(hosts: RemoteHostStatus[]): void {
const total = typeof host.container_total === 'number' ? host.container_total : containers.length;
const shownNote = total > containers.length ? ` · showing ${containers.length} of ${total}` : '';
const containerHtml = containers.length
? `<div class="remote-count">${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}</div><div class="remote-containers">${containers.map((c) => containerRowHtml(c, 'remote-container')).join('')}</div>`
? `<div class="remote-count">${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}</div><div class="remote-containers">${containers.map((c) => containerRowHtml(c, 'remote-container', host.id)).join('')}</div>`
: `<div class="muted remote-empty">${host.online ? 'No containers' : escapeHtml(host.error || 'Remote host is offline')}</div>`;
const error = host.error && host.online ? `<div class="remote-error">${escapeHtml(host.error)}</div>` : '';
const metricsHtml = host.metrics ? remoteMetricsHtml(host.metrics) : '';
Expand Down Expand Up @@ -304,6 +325,24 @@ async function loadRemoteHosts(): Promise<void> {
renderRemoteHosts(payload.hosts || []);
}

// Restart / pull-latest a container. Confirms first; server re-checks login + unlock + action header.
async function containerAction(host: string, engine: string, name: string, action: string): Promise<void> {
if (!shellUnlocked) { toast('Unlock shells first to manage containers'); return; }
if (!name || !engine) return;
const verb = action === 'pull' ? 'Pull latest image for' : 'Restart';
const where = host ? ` on ${host}` : '';
if (!window.confirm(`${verb} "${name}"${where}?`)) return;
toast(`${action === 'pull' ? 'Pulling' : 'Restarting'} ${name}…`);
try {
const payload = await postJson('/api/container-action', { host, engine, name, action }) as { message?: string };
toast(payload.message || `${name}: done`);
} catch (error) {
toast((error as Error).message || 'Action failed');
}
await Promise.allSettled([loadContainers(), loadRemoteHosts()]);
}

(window as any).containerAction = containerAction;
(window as any).loadMetrics = loadMetrics;
(window as any).loadContainers = loadContainers;
(window as any).loadRemoteHosts = loadRemoteHosts;
7 changes: 7 additions & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,13 @@ body.preview-fullscreen-open{overflow:hidden}
.container-item.state-unhealthy{border-color:rgba(255,106,122,.45)}
.container-item.state-unhealthy em{color:#ffb3bd}
.container-health{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;margin-bottom:2px}
.container-item .container-age{align-self:start;justify-self:end;font-size:10px;color:#9fe9dd;font-variant-numeric:tabular-nums;border:1px solid rgba(139,246,255,.18);border-radius:999px;padding:1px 6px}
.container-actions{grid-column:1 / -1;display:flex;gap:6px;justify-content:flex-end;margin-top:2px}
.container-action{font-size:10.5px;padding:2px 9px;border-radius:6px;border:1px solid rgba(139,246,255,.22);background:rgba(139,246,255,.06);color:#c9fff3;cursor:pointer}
.container-action:hover{background:rgba(139,246,255,.16)}
.container-action[data-container-action="restart"]{border-color:rgba(255,191,73,.3);color:var(--amber)}
/* Container actions are mutating — only available once shells are unlocked. */
body.shells-locked .container-actions{display:none}
/* Remote host monitor widget */
.remote-list{display:flex;flex-direction:column;gap:8px}
.remote-empty{font-size:12px}
Expand Down
5 changes: 5 additions & 0 deletions public/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ document.addEventListener('click', async (event) => {
const renameShellButton = target.closest('[data-rename-shell]');
const resetShellLabelButton = target.closest('[data-reset-shell-label]');
const renameSensorButton = target.closest('[data-rename-sensor]');
const containerActionButton = target.closest('[data-container-action]');
const shellinButton = target.closest('[data-shellin]');
const resumeButton = target.closest('[data-resume]');
const removeImageButton = target.closest('[data-remove-image]');
Expand Down Expand Up @@ -58,6 +59,10 @@ document.addEventListener('click', async (event) => {
}
if (renameSensorButton)
return renameSensorLabel(renameSensorButton.dataset.renameSensor || '');
if (containerActionButton) {
const d = containerActionButton.dataset;
return containerAction(d.chost || '', d.cengine || '', d.cname || '', d.containerAction || '');
}
if (shellinButton) {
openTerminal(shellinButton.dataset.shellin || '');
return;
Expand Down
52 changes: 49 additions & 3 deletions public/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,34 @@ function containerStatsText(c) {
bits.push(c.mem);
return bits.join(' · ');
}
// Compact age from a docker status string, e.g. "Up 7 days (healthy)" → "7d", "Up About an hour"
// → "~1h", "Exited (0) 3 minutes ago" → "" (stopped, no age badge).
function containerUptime(status) {
if (!/^up\b/i.test(status))
return '';
if (/about an hour/i.test(status))
return '~1h';
if (/less than a (second|minute)/i.test(status))
return '<1m';
const m = /(\d+)\s*(second|minute|hour|day|week|month|year)/i.exec(status);
if (!m)
return '';
const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[m[2].toLowerCase()] || '';
return `${m[1]}${unit}`;
}
// Restart + Pull-latest buttons. Hidden via CSS unless shells are unlocked; the click handler
// confirms and the server re-checks login + unlock + action header. host = remote host id ('' local).
function containerActionsHtml(c, host) {
const attrs = `data-cname="${escapeHtml(c.name)}" data-cengine="${escapeHtml(c.engine)}" data-chost="${escapeHtml(host)}"`;
return `<div class="container-actions"><button type="button" class="container-action" data-container-action="restart" ${attrs} title="Restart ${escapeHtml(c.name)}">Restart</button><button type="button" class="container-action" data-container-action="pull" ${attrs} title="Pull latest image and recreate ${escapeHtml(c.name)}">Pull</button></div>`;
}
// Shared row for local + remote container lists. Stopped/unhealthy get a state class for greying.
function containerRowHtml(c, extraClass = '') {
function containerRowHtml(c, extraClass = '', host = '') {
const stats = containerStatsText(c);
const statsHtml = stats ? `<small class="container-stats">${escapeHtml(stats)}</small>` : '';
return `<div class="container-item ${extraClass} state-${containerState(c.status)}"><div><b>${escapeHtml(c.name)}</b><span>${escapeHtml(c.image)}</span></div><small>${escapeHtml(c.engine)}</small><em>${escapeHtml(c.status)}</em>${statsHtml}</div>`;
const age = containerUptime(c.status);
const ageHtml = age ? `<span class="container-age" title="${escapeHtml(c.status)}">${escapeHtml(age)}</span>` : '';
return `<div class="container-item ${extraClass} state-${containerState(c.status)}"><div><b>${escapeHtml(c.name)}</b><span>${escapeHtml(c.image)}</span></div><small>${escapeHtml(c.engine)}</small><em>${escapeHtml(c.status)}</em>${ageHtml}${statsHtml}${containerActionsHtml(c, host)}</div>`;
}
const SENSOR_LABEL_ALIASES_KEY = 'sdSensorLabelAliases';
let latestMachineMetrics = null;
Expand Down Expand Up @@ -226,7 +249,7 @@ function renderRemoteHosts(hosts) {
const total = typeof host.container_total === 'number' ? host.container_total : containers.length;
const shownNote = total > containers.length ? ` · showing ${containers.length} of ${total}` : '';
const containerHtml = containers.length
? `<div class="remote-count">${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}</div><div class="remote-containers">${containers.map((c) => containerRowHtml(c, 'remote-container')).join('')}</div>`
? `<div class="remote-count">${escapeHtml(containerHealth(containers))}${escapeHtml(shownNote)}</div><div class="remote-containers">${containers.map((c) => containerRowHtml(c, 'remote-container', host.id)).join('')}</div>`
: `<div class="muted remote-empty">${host.online ? 'No containers' : escapeHtml(host.error || 'Remote host is offline')}</div>`;
const error = host.error && host.online ? `<div class="remote-error">${escapeHtml(host.error)}</div>` : '';
const metricsHtml = host.metrics ? remoteMetricsHtml(host.metrics) : '';
Expand Down Expand Up @@ -260,6 +283,29 @@ async function loadRemoteHosts() {
const payload = await response.json();
renderRemoteHosts(payload.hosts || []);
}
// Restart / pull-latest a container. Confirms first; server re-checks login + unlock + action header.
async function containerAction(host, engine, name, action) {
if (!shellUnlocked) {
toast('Unlock shells first to manage containers');
return;
}
if (!name || !engine)
return;
const verb = action === 'pull' ? 'Pull latest image for' : 'Restart';
const where = host ? ` on ${host}` : '';
if (!window.confirm(`${verb} "${name}"${where}?`))
return;
toast(`${action === 'pull' ? 'Pulling' : 'Restarting'} ${name}…`);
try {
const payload = await postJson('/api/container-action', { host, engine, name, action });
toast(payload.message || `${name}: done`);
}
catch (error) {
toast(error.message || 'Action failed');
}
await Promise.allSettled([loadContainers(), loadRemoteHosts()]);
}
window.containerAction = containerAction;
window.loadMetrics = loadMetrics;
window.loadContainers = loadContainers;
window.loadRemoteHosts = loadRemoteHosts;
185 changes: 185 additions & 0 deletions src/container_actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Mutating container actions (restart / pull-latest) for the dashboard, run locally via `sh -c`
// or on a remote host via SSH stdin (`sh -s`). Names and engines are strictly validated before
// they ever reach a shell, so there is nothing to inject. These endpoints are login + shell-unlock
// + action-header gated in routes.rs.

use std::time::Duration;
use tokio::{io::AsyncWriteExt, process::Command, time::timeout};

// docker/podman names: first char alphanumeric, then [A-Za-z0-9_.-]. This charset has no shell
// metacharacters, so a validated name is safe to splice into a shell command.
pub fn clean_name(raw: &str) -> Option<String> {
let name: String = raw
.trim()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
.take(64)
.collect();
let starts_ok = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric());
(starts_ok && name.len() == raw.trim().len()).then_some(name)
}

pub fn engine_ok(engine: &str) -> bool {
matches!(engine, "docker" | "podman")
}

// Pull-latest: if the container is compose-managed (has project labels) pull + recreate that
// service; otherwise pull the image and restart (a plain `docker run` container can't be recreated
// without its original args, so we at least fetch the new image and bounce it).
const PULL_TEMPLATE: &str = r#"set -e
ENG=__ENGINE__
NAME=__NAME__
proj=$($ENG inspect -f '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' "$NAME" 2>/dev/null || true)
svc=$($ENG inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$NAME" 2>/dev/null || true)
dir=$($ENG inspect -f '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' "$NAME" 2>/dev/null || true)
img=$($ENG inspect -f '{{ .Config.Image }}' "$NAME" 2>/dev/null || true)
if [ -n "$proj" ] && [ -n "$svc" ]; then
[ -n "$dir" ] && cd "$dir" 2>/dev/null || true
$ENG compose -f "$proj" pull "$svc" && $ENG compose -f "$proj" up -d "$svc" && echo "pulled + recreated $NAME ($svc)"
else
$ENG pull "$img" && $ENG restart "$NAME" && echo "pulled $img + restarted $NAME (not compose-managed)"
fi
"#;
Comment on lines +32 to +45

// Returns (script, timeout_ms) for the action, or None for an unknown action.
fn action_script(engine: &str, name: &str, action: &str) -> Option<(String, u64)> {
match action {
"restart" => Some((format!("{engine} restart {name} && echo \"restarted {name}\"\n"), 30_000)),
"pull" => Some((
PULL_TEMPLATE
.replace("__ENGINE__", engine)
.replace("__NAME__", name),
// image pulls can be slow.
240_000,
)),
_ => None,
}
}

fn validate(engine: &str, name: &str, action: &str) -> Result<(String, u64), String> {
if !engine_ok(engine) {
return Err("Unknown container engine".to_string());
}
let name = clean_name(name).ok_or_else(|| "Invalid container name".to_string())?;
action_script(engine, &name, action).ok_or_else(|| "Unknown action".to_string())
}

fn finish(stdout: &[u8], stderr: &[u8], ok: bool, fallback: &str) -> Result<String, String> {
let pick = |bytes: &[u8]| {
String::from_utf8_lossy(bytes)
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.chars()
.filter(|c| !c.is_control())
.take(200)
.collect::<String>()
};
if ok {
let msg = pick(stdout);
Ok(if msg.is_empty() {
fallback.to_string()
} else {
msg
})
} else {
let err = pick(stderr);
Err(if err.is_empty() {
"Command failed".to_string()
} else {
err
})
}
}

// Run an action against a container on the ShellDeck host itself.
pub async fn local(engine: &str, name: &str, action: &str) -> Result<String, String> {
let (script, budget) = validate(engine, name, action)?;
let mut command = Command::new("sh");
command.args(["-c", script.as_str()]).kill_on_drop(true);
let output = timeout(Duration::from_millis(budget), command.output())
.await
.map_err(|_| "Action timed out".to_string())?
.map_err(|_| "Could not run the action".to_string())?;
finish(
&output.stdout,
&output.stderr,
output.status.success(),
"done",
)
}

// Run an action against a container on a remote host, feeding the script over SSH stdin.
pub async fn remote(
target: &str,
engine: &str,
name: &str,
action: &str,
) -> Result<String, String> {
let (script, budget) = validate(engine, name, action)?;
let mut command = Command::new("ssh");
command
.args([
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=3",
"-o",
"NumberOfPasswordPrompts=0",
target,
"sh",
"-s",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = command
.spawn()
.map_err(|_| "Could not start SSH".to_string())?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(script.as_bytes()).await;
}
Comment on lines +141 to +146
let output = timeout(Duration::from_millis(budget), child.wait_with_output())
.await
.map_err(|_| "Action timed out".to_string())?
.map_err(|_| "SSH failed".to_string())?;
finish(
&output.stdout,
&output.stderr,
output.status.success(),
"done",
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn rejects_bad_names_and_engines() {
assert!(clean_name("memoh-server").is_some());
assert!(clean_name("ig-yente-api").is_some());
assert!(clean_name("a b").is_none()); // space
assert!(clean_name("$(rm -rf /)").is_none()); // injection
assert!(clean_name("-bad").is_none()); // leading dash
assert!(clean_name("").is_none());
assert!(engine_ok("docker"));
assert!(!engine_ok("sh"));
}

#[test]
fn builds_scripts_and_rejects_unknown_action() {
let (restart, _) = validate("docker", "glances", "restart").unwrap();
assert!(restart.contains("docker restart glances"));
let (pull, ms) = validate("docker", "glances", "pull").unwrap();
assert!(pull.contains("compose -f") && pull.contains("glances"));
assert!(ms > 60_000);
assert!(validate("docker", "glances", "delete").is_err());
assert!(validate("evil", "glances", "restart").is_err());
}
}
Loading