feat: add auto update

This commit is contained in:
2026-05-19 15:53:30 +02:00
parent bd3121d688
commit ba4de62a34
22 changed files with 3323 additions and 110 deletions

24
agent/Cargo.lock generated
View File

@ -150,6 +150,7 @@ dependencies = [
"hyperlocal",
"log",
"pin-project-lite",
"rustls",
"serde",
"serde_derive",
"serde_json",
@ -1191,24 +1192,13 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@ -1585,16 +1575,6 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@ -1640,10 +1620,8 @@ dependencies = [
"percent-encoding",
"pin-project",
"prost",
"rustls-pemfile",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower 0.4.13",
"tower-layer",

View File

@ -10,9 +10,9 @@ path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
tonic = { version = "0.12", features = ["tls"] }
tonic = { version = "0.12" }
prost = "0.13"
bollard = "0.17"
bollard = { version = "0.17", default-features = false, features = ["rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"

View File

@ -3,34 +3,30 @@ FROM rust:slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
protobuf-compiler \
pkg-config \
libssl-dev \
musl-tools \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /src
# Copy proto first (referenced by build.rs as ../proto/...)
COPY proto/ ./proto/
# Cache dependencies: copy manifests, build with dummy main, then discard.
COPY agent/Cargo.toml agent/Cargo.lock ./agent/
COPY agent/build.rs ./agent/
WORKDIR /src/agent
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
cargo build --release && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
# Full build.
COPY agent/src ./src
RUN touch src/main.rs && cargo build --release
RUN touch src/main.rs && cargo build --release --target x86_64-unknown-linux-musl
# ── Runtime ───────────────────────────────────────────────────────────────────
FROM debian:bookworm-slim
FROM alpine:3.19
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache ca-certificates docker-cli
COPY --from=builder /src/agent/target/release/containarr-agent /usr/local/bin/containarr-agent
COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/containarr-agent /usr/local/bin/containarr-agent
ENTRYPOINT ["containarr-agent"]

View File

@ -43,7 +43,7 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static {
#[derive(Clone)]
pub struct DockerClient {
inner: Docker,
pub inner: Docker,
}
impl DockerClient {

View File

@ -5,15 +5,23 @@ pub mod proto {
}
use anyhow::{Context, Result};
use bollard::container::LogOutput;
use bollard::container::{
Config, CreateContainerOptions, LogOutput, StartContainerOptions,
StopContainerOptions, RemoveContainerOptions,
};
use bollard::network::ConnectNetworkOptions;
use bollard::models::EndpointSettings;
use bollard::image::CreateImageOptions;
use docker::{ContainerBackend, DockerClient};
use futures_util::StreamExt as _;
use proto::{
agent_gateway_client::AgentGatewayClient,
agent_message, server_message,
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
ImageInfo, VolumeInfo, NetworkInfo,
FileResult, ImageInfo, VolumeInfo, NetworkInfo,
UpdateCheckResult,
};
use serde::Serialize;
use std::{collections::HashMap, env, time::Duration};
use tokio::{sync::mpsc, task::JoinHandle, time};
use tonic::Request;
@ -170,6 +178,165 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
});
log_tasks.insert(cmd.container_id, handle);
}
Some(server_message::Payload::ListDir(cmd)) => {
let tx_clone = tx.clone();
tokio::spawn(async move {
let result = list_dir_handler(&cmd.path).await;
let fr = match result {
Ok(content) => FileResult {
command_id: cmd.command_id,
success: true,
error: String::new(),
content,
},
Err(e) => FileResult {
command_id: cmd.command_id,
success: false,
error: e.to_string(),
content: vec![],
},
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
}).await;
});
}
Some(server_message::Payload::ReadFile(cmd)) => {
let tx_clone = tx.clone();
tokio::spawn(async move {
let result = tokio::fs::read(&cmd.path).await;
let fr = match result {
Ok(content) => FileResult {
command_id: cmd.command_id,
success: true,
error: String::new(),
content,
},
Err(e) => FileResult {
command_id: cmd.command_id,
success: false,
error: e.to_string(),
content: vec![],
},
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
}).await;
});
}
Some(server_message::Payload::WriteFile(cmd)) => {
let tx_clone = tx.clone();
tokio::spawn(async move {
let result = write_file_handler(&cmd.path, &cmd.content).await;
let fr = match result {
Ok(()) => FileResult {
command_id: cmd.command_id,
success: true,
error: String::new(),
content: vec![],
},
Err(e) => FileResult {
command_id: cmd.command_id,
success: false,
error: e.to_string(),
content: vec![],
},
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
}).await;
});
}
Some(server_message::Payload::ExecCompose(cmd)) => {
let tx_clone = tx.clone();
tokio::spawn(async move {
let result = exec_compose_handler(&cmd.path, &cmd.action).await;
let fr = match result {
Ok(content) => FileResult {
command_id: cmd.command_id,
success: true,
error: String::new(),
content,
},
Err(e) => FileResult {
command_id: cmd.command_id,
success: false,
error: e.to_string(),
content: vec![],
},
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
}).await;
});
}
Some(server_message::Payload::CreateDir(cmd)) => {
let tx = tx.clone();
let path = cmd.path.clone();
let cmd_id = cmd.command_id.clone();
tokio::spawn(async move {
let (success, error) = match tokio::fs::create_dir_all(&path).await {
Ok(_) => (true, String::new()),
Err(e) => (false, e.to_string()),
};
let _ = tx.send(AgentMessage {
payload: Some(agent_message::Payload::FileResult(FileResult {
command_id: cmd_id,
success,
error,
content: vec![],
})),
}).await;
});
}
Some(server_message::Payload::CheckUpdate(cmd)) => {
let tx_clone = tx.clone();
let docker_clone = docker.clone();
tokio::spawn(async move {
let result = check_update_handler(&docker_clone.inner, &cmd.container_id).await;
let msg = match result {
Ok((update_available, current_digest, remote_digest)) => {
UpdateCheckResult {
command_id: cmd.command_id,
container_id: cmd.container_id,
update_available,
current_digest,
remote_digest,
error: String::new(),
}
}
Err(e) => UpdateCheckResult {
command_id: cmd.command_id,
container_id: cmd.container_id,
update_available: false,
current_digest: String::new(),
remote_digest: String::new(),
error: e.to_string(),
},
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::UpdateCheckResult(msg)),
}).await;
});
}
Some(server_message::Payload::UpdateContainer(cmd)) => {
let tx_clone = tx.clone();
let docker_clone = docker.clone();
tokio::spawn(async move {
let result = update_container_handler(&docker_clone.inner, &cmd.container_id).await;
let (success, error) = match result {
Ok(()) => (true, String::new()),
Err(e) => (false, e.to_string()),
};
let _ = tx_clone.send(AgentMessage {
payload: Some(agent_message::Payload::Result(proto::CommandResult {
command_id: cmd.command_id,
success,
error,
})),
}).await;
});
}
None => {}
}
}
@ -205,6 +372,281 @@ pub(crate) fn unix_now() -> i64 {
.as_secs() as i64
}
// ── FS / compose helpers ──────────────────────────────────────────────────────
#[derive(Serialize)]
pub(crate) struct DirEntry {
pub name: String,
pub is_dir: bool,
pub has_compose: bool,
}
pub(crate) async fn list_dir_handler(path: &str) -> Result<Vec<u8>> {
let mut read_dir = tokio::fs::read_dir(path).await
.with_context(|| format!("read_dir {path}"))?;
let mut entries: Vec<DirEntry> = Vec::new();
while let Some(entry) = read_dir.next_entry().await
.with_context(|| format!("next_entry in {path}"))? {
let file_type = entry.file_type().await
.with_context(|| format!("file_type for {:?}", entry.path()))?;
let is_dir = file_type.is_dir();
let has_compose = if is_dir {
let p = entry.path();
tokio::fs::try_exists(p.join("docker-compose.yaml")).await.unwrap_or(false)
|| tokio::fs::try_exists(p.join("docker-compose.yml")).await.unwrap_or(false)
} else {
false
};
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
is_dir,
has_compose,
});
}
serde_json::to_vec(&entries).context("serialize dir entries")
}
pub(crate) async fn write_file_handler(path: &str, content: &[u8]) -> Result<()> {
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await
.with_context(|| format!("create_dir_all {:?}", parent))?;
}
tokio::fs::write(path, content).await
.with_context(|| format!("write file {path}"))
}
pub(crate) async fn exec_compose_handler(path: &str, action: &str) -> Result<Vec<u8>> {
let mut cmd = tokio::process::Command::new("docker");
cmd.args(match action {
"up" => vec!["compose", "up", "-d"],
"down" => vec!["compose", "down"],
"pull" => vec!["compose", "pull"],
_ => vec!["compose", "up", "-d"],
});
cmd.current_dir(path);
let output = cmd.output().await
.with_context(|| format!("exec docker compose {action} in {path}"))?;
if output.status.success() {
Ok(output.stdout)
} else {
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr))
}
}
// ── Update helpers ────────────────────────────────────────────────────────────
/// Returns `(update_available, current_digest, remote_digest)`.
pub(crate) async fn check_update_handler(
docker: &bollard::Docker,
container_id: &str,
) -> Result<(bool, String, String)> {
// 1. Inspect the container to get the image name
let inspect = docker
.inspect_container(container_id, None)
.await
.with_context(|| format!("inspect container {container_id}"))?;
let image_name = inspect
.config
.as_ref()
.and_then(|c| c.image.as_deref())
.ok_or_else(|| anyhow::anyhow!("container {container_id} has no image config"))?
.to_string();
// 2. Get current digest before pull
let current_digest = get_image_digest(docker, &image_name).await;
// 3. Pull the image and detect if it was updated
let update_available = pull_image_and_detect_update(docker, &image_name).await?;
// 4. Get remote digest after pull
let remote_digest = get_image_digest(docker, &image_name).await;
Ok((update_available, current_digest, remote_digest))
}
/// Pull the image via bollard and detect whether a newer image was downloaded.
/// Returns `true` if "Downloaded newer image" was detected in the pull stream.
pub(crate) async fn pull_image_and_detect_update(
docker: &bollard::Docker,
image_name: &str,
) -> Result<bool> {
let mut stream = docker.create_image(
Some(CreateImageOptions {
from_image: image_name,
..Default::default()
}),
None,
None,
);
let mut update_available = false;
while let Some(item) = stream.next().await {
let info = item.with_context(|| format!("pull stream error for {image_name}"))?;
if let Some(status) = &info.status {
if parse_pull_status(status) == Some(true) {
update_available = true;
}
}
}
Ok(update_available)
}
/// Parse the pull status string to detect update availability.
/// Extracted as a pure function to enable unit testing.
pub(crate) fn parse_pull_status(status: &str) -> Option<bool> {
if status.contains("Downloaded newer image") {
Some(true)
} else if status.contains("Image is up to date") {
Some(false)
} else {
None
}
}
/// Get the first repo digest for an image, falling back to the image ID.
async fn get_image_digest(docker: &bollard::Docker, image_name: &str) -> String {
match docker.inspect_image(image_name).await {
Ok(info) => {
info.repo_digests
.and_then(|d| d.into_iter().next())
.or_else(|| info.id)
.unwrap_or_default()
}
Err(e) => {
warn!("inspect_image {} failed: {:#}", image_name, e);
String::new()
}
}
}
/// Perform a container update via bollard recreate (always).
pub(crate) async fn update_container_handler(
docker: &bollard::Docker,
container_id: &str,
) -> Result<()> {
let inspect = docker
.inspect_container(container_id, None)
.await
.with_context(|| format!("inspect container {container_id}"))?;
info!("updating container {container_id} via bollard recreate");
standalone_recreate(docker, container_id, &inspect).await
}
/// Recreate a standalone container with its original config.
async fn standalone_recreate(
docker: &bollard::Docker,
container_id: &str,
inspect: &bollard::models::ContainerInspectResponse,
) -> Result<()> {
let image_name = inspect
.config
.as_ref()
.and_then(|c| c.image.as_deref())
.ok_or_else(|| anyhow::anyhow!("container has no image config"))?
.to_string();
// Strip leading '/' from container name
let container_name = inspect
.name
.as_deref()
.unwrap_or(container_id)
.trim_start_matches('/')
.to_string();
// Collect extra networks before stopping (all networks except the primary one)
let extra_networks: Vec<String> = {
let primary = inspect
.host_config
.as_ref()
.and_then(|hc| hc.network_mode.as_deref())
.unwrap_or("bridge")
.to_string();
inspect
.network_settings
.as_ref()
.and_then(|ns| ns.networks.as_ref())
.map(|nets| {
nets.keys()
.filter(|n| **n != primary)
.cloned()
.collect()
})
.unwrap_or_default()
};
// 1. Pull the new image
pull_image_and_detect_update(docker, &image_name).await?;
// 2. Stop the container (ignore error if already stopped)
let _ = docker
.stop_container(container_id, Some(StopContainerOptions { t: 10 }))
.await;
// 3. Remove the container
docker
.remove_container(
container_id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.with_context(|| format!("remove container {container_id}"))?;
// 4. Recreate with the same config
let config = inspect.config.clone().unwrap_or_default();
let host_config = inspect.host_config.clone();
let create_config = Config {
image: Some(image_name.clone()),
cmd: config.cmd,
env: config.env,
labels: config.labels,
exposed_ports: config.exposed_ports,
volumes: config.volumes,
working_dir: config.working_dir,
entrypoint: config.entrypoint,
host_config,
..Default::default()
};
let created = docker
.create_container(
Some(CreateContainerOptions {
name: container_name.as_str(),
..Default::default()
}),
create_config,
)
.await
.with_context(|| format!("create container {container_name}"))?;
// 5. Start the new container
docker
.start_container(&created.id, None::<StartContainerOptions<String>>)
.await
.with_context(|| format!("start container {}", created.id))?;
// 6. Reconnect to extra networks
for net_name in &extra_networks {
let _ = docker
.connect_network(
net_name,
ConnectNetworkOptions {
container: created.id.clone(),
endpoint_config: EndpointSettings::default(),
},
)
.await;
}
info!("container {container_name} recreated with new image {image_name}");
Ok(())
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
@ -457,4 +899,258 @@ mod tests {
assert_eq!(chunk.stream, "stdout");
assert_eq!(chunk.data, b"hello");
}
// ── DirEntry JSON serialization ───────────────────────────────────────────
#[test]
fn dir_entry_serializes_file() {
let entry = DirEntry { name: "foo.txt".to_string(), is_dir: false, has_compose: false };
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains(r#""name":"foo.txt""#));
assert!(json.contains(r#""is_dir":false"#));
}
#[test]
fn dir_entry_serializes_directory() {
let entry = DirEntry { name: "subdir".to_string(), is_dir: true, has_compose: false };
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains(r#""name":"subdir""#));
assert!(json.contains(r#""is_dir":true"#));
}
#[test]
fn dir_entries_serialize_as_array() {
let entries = vec![
DirEntry { name: "a".to_string(), is_dir: false, has_compose: false },
DirEntry { name: "b".to_string(), is_dir: true, has_compose: false },
];
let bytes = serde_json::to_vec(&entries).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 2);
assert_eq!(parsed[0]["name"], "a");
assert_eq!(parsed[1]["is_dir"], true);
}
#[test]
fn dir_entry_empty_name_is_valid() {
let entry = DirEntry { name: String::new(), is_dir: false, has_compose: false };
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains(r#""name":"""#));
}
#[test]
fn dir_entry_roundtrips_via_json() {
let entries = vec![
DirEntry { name: "compose.yml".to_string(), is_dir: false, has_compose: false },
DirEntry { name: "data".to_string(), is_dir: true, has_compose: false },
];
let bytes = serde_json::to_vec(&entries).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed[0]["name"], "compose.yml");
assert_eq!(parsed[0]["is_dir"], false);
assert_eq!(parsed[1]["name"], "data");
assert_eq!(parsed[1]["is_dir"], true);
}
// ── FileResult proto construction ─────────────────────────────────────────
#[test]
fn file_result_success_fields() {
let fr = FileResult {
command_id: "cmd-fs-1".to_string(),
success: true,
error: String::new(),
content: b"hello world".to_vec(),
};
assert!(fr.success);
assert!(fr.error.is_empty());
assert_eq!(fr.content, b"hello world");
}
#[test]
fn file_result_error_fields() {
let fr = FileResult {
command_id: "cmd-fs-2".to_string(),
success: false,
error: "permission denied".to_string(),
content: vec![],
};
assert!(!fr.success);
assert_eq!(fr.error, "permission denied");
assert!(fr.content.is_empty());
}
#[test]
fn agent_message_wraps_file_result() {
let fr = FileResult {
command_id: "x".to_string(),
success: true,
error: String::new(),
content: vec![],
};
let msg = AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
};
assert!(matches!(
msg.payload,
Some(agent_message::Payload::FileResult(_))
));
}
// ── CreateDir message construction ────────────────────────────────────────
#[test]
fn create_dir_success_builds_correct_file_result() {
// Simulate the success branch of the CreateDir handler
let cmd_id = "create-dir-cmd-1".to_string();
let (success, error) = (true, String::new());
let fr = FileResult {
command_id: cmd_id.clone(),
success,
error,
content: vec![],
};
let msg = AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
};
// The message must wrap a FileResult with success=true and no content
if let Some(agent_message::Payload::FileResult(fr)) = msg.payload {
assert!(fr.success);
assert!(fr.error.is_empty());
assert!(fr.content.is_empty());
assert_eq!(fr.command_id, cmd_id);
} else {
panic!("expected FileResult payload");
}
}
#[test]
fn create_dir_error_builds_correct_file_result() {
// Simulate the error branch of the CreateDir handler
let cmd_id = "create-dir-cmd-2".to_string();
let err_msg = "permission denied (os error 13)".to_string();
let (success, error) = (false, err_msg.clone());
let fr = FileResult {
command_id: cmd_id.clone(),
success,
error,
content: vec![],
};
let msg = AgentMessage {
payload: Some(agent_message::Payload::FileResult(fr)),
};
if let Some(agent_message::Payload::FileResult(fr)) = msg.payload {
assert!(!fr.success);
assert_eq!(fr.error, err_msg);
assert!(fr.content.is_empty());
assert_eq!(fr.command_id, cmd_id);
} else {
panic!("expected FileResult payload");
}
}
// ── parse_pull_status — unit tests ────────────────────────────────────────
#[test]
fn parse_pull_status_detects_newer_image() {
let status = "Status: Downloaded newer image for nginx:latest";
assert_eq!(parse_pull_status(status), Some(true));
}
#[test]
fn parse_pull_status_detects_up_to_date() {
let status = "Status: Image is up to date for nginx:latest";
assert_eq!(parse_pull_status(status), Some(false));
}
#[test]
fn parse_pull_status_returns_none_for_progress() {
assert_eq!(parse_pull_status("Pulling from library/nginx"), None);
assert_eq!(parse_pull_status("Pull complete"), None);
assert_eq!(parse_pull_status("Digest: sha256:abc123"), None);
}
#[test]
fn parse_pull_status_returns_none_for_empty_string() {
assert_eq!(parse_pull_status(""), None);
}
#[test]
fn parse_pull_status_newer_image_takes_priority() {
// Edge case: both substrings in same string (shouldn't happen in practice)
let status = "Downloaded newer image is up to date";
assert_eq!(parse_pull_status(status), Some(true));
}
#[test]
fn parse_pull_status_case_sensitive() {
// Docker sends title-case; lowercase should not match
assert_eq!(parse_pull_status("downloaded newer image"), None);
assert_eq!(parse_pull_status("image is up to date"), None);
}
// ── UpdateCheckResult proto construction ──────────────────────────────────
#[test]
fn update_check_result_no_update_fields() {
let r = UpdateCheckResult {
command_id: "chk-1".to_string(),
container_id: "c1".to_string(),
update_available: false,
current_digest: "sha256:aaa".to_string(),
remote_digest: "sha256:aaa".to_string(),
error: String::new(),
};
assert!(!r.update_available);
assert_eq!(r.current_digest, r.remote_digest);
assert!(r.error.is_empty());
}
#[test]
fn update_check_result_update_available_fields() {
let r = UpdateCheckResult {
command_id: "chk-2".to_string(),
container_id: "c2".to_string(),
update_available: true,
current_digest: "sha256:old".to_string(),
remote_digest: "sha256:new".to_string(),
error: String::new(),
};
assert!(r.update_available);
assert_ne!(r.current_digest, r.remote_digest);
}
#[test]
fn update_check_result_error_fields() {
let r = UpdateCheckResult {
command_id: "chk-3".to_string(),
container_id: "c3".to_string(),
update_available: false,
current_digest: String::new(),
remote_digest: String::new(),
error: "container not found".to_string(),
};
assert!(!r.update_available);
assert!(!r.error.is_empty());
}
#[test]
fn agent_message_wraps_update_check_result() {
let r = UpdateCheckResult {
command_id: "chk-4".to_string(),
container_id: "c4".to_string(),
update_available: true,
current_digest: "sha256:old".to_string(),
remote_digest: "sha256:new".to_string(),
error: String::new(),
};
let msg = AgentMessage {
payload: Some(agent_message::Payload::UpdateCheckResult(r)),
};
assert!(matches!(
msg.payload,
Some(agent_message::Payload::UpdateCheckResult(_))
));
}
}

View File

@ -75,12 +75,30 @@ message LogChunk {
int64 timestamp = 4;
}
message FileResult {
string command_id = 1;
bool success = 2;
string error = 3;
bytes content = 4; // for ReadFile: file content; for ListDir: JSON-encoded entries
}
message UpdateCheckResult {
string command_id = 1;
string container_id = 2;
bool update_available = 3;
string current_digest = 4;
string remote_digest = 5;
string error = 6;
}
message AgentMessage {
oneof payload {
AgentHandshake handshake = 1;
ContainerSnapshot snapshot = 2;
CommandResult result = 3;
LogChunk log_chunk = 4;
FileResult file_result = 5;
UpdateCheckResult update_check_result = 6;
}
}
@ -107,10 +125,54 @@ message StreamLogsCommand {
int32 tail = 4;
}
message ListDirCommand {
string command_id = 1;
string path = 2;
}
message ReadFileCommand {
string command_id = 1;
string path = 2;
}
message WriteFileCommand {
string command_id = 1;
string path = 2;
bytes content = 3;
}
message ExecComposeCommand {
string command_id = 1;
string path = 2; // directory containing docker-compose.yaml
string action = 3; // "up", "down", "pull"
}
message CreateDirCommand {
string command_id = 1;
string path = 2;
}
message CheckUpdateCommand {
string command_id = 1;
string container_id = 2;
}
message UpdateContainerCommand {
string command_id = 1;
string container_id = 2;
}
message ServerMessage {
oneof payload {
ContainerCommand container_cmd = 1;
StreamLogsCommand stream_logs = 2;
ListDirCommand list_dir = 3;
ReadFileCommand read_file = 4;
WriteFileCommand write_file = 5;
ExecComposeCommand exec_compose = 6;
CreateDirCommand create_dir = 7;
CheckUpdateCommand check_update = 8;
UpdateContainerCommand update_container = 9;
}
}

View File

@ -18,6 +18,7 @@ import (
"github.com/containarr/server/internal/broker"
grpcgateway "github.com/containarr/server/internal/grpc"
agentv1 "github.com/containarr/server/internal/proto/agentv1"
"github.com/containarr/server/internal/scheduler"
"github.com/containarr/server/internal/store"
"google.golang.org/grpc"
)
@ -39,6 +40,14 @@ func main() {
reg := grpcgateway.NewRegistry()
brk := broker.New()
// Root context cancelled on shutdown signal.
rootCtx, rootCancel := context.WithCancel(context.Background())
defer rootCancel()
// Scheduler.
sched := scheduler.New(scheduler.NewStoreAdapter(db), reg)
go sched.Start(rootCtx)
// gRPC server.
gw := grpcgateway.NewGateway(db, reg, brk)
grpcServer := grpc.NewServer()
@ -76,6 +85,7 @@ func main() {
<-quit
slog.Info("shutting down")
rootCancel()
grpcServer.GracefulStop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

View File

@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -801,3 +802,246 @@ func TestContainerAction_Success(t *testing.T) {
t.Error("expected command_id in response")
}
}
// newCancelledRequest creates a request with an already-cancelled context.
func newCancelledRequest(method, target string, body *bytes.Reader) *http.Request {
ctx, cancel := context.WithCancel(context.Background())
cancel()
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, body)
} else {
req = httptest.NewRequest(method, target, nil)
}
return req.WithContext(ctx)
}
// ── FsList ────────────────────────────────────────────────────────────────────
func TestFsList_AgentNotFound(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/list?path=/tmp", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestFsList_Timeout(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
// Use cancelled context to force immediate timeout on the agent wait.
req := newCancelledRequest(http.MethodGet, "/api/v1/agents/a1/fs/list?path=/tmp", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Either 504 (timeout) or 404 (send failed because channel was full/cancelled).
if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound {
t.Errorf("expected 504 or 404, got %d", w.Code)
}
}
func TestFsList_MissingPath(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/list", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ── FsRead ────────────────────────────────────────────────────────────────────
func TestFsRead_AgentNotFound(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/read?path=/etc/hosts", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestFsRead_MissingPath(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead)
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/read", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ── FsWrite ───────────────────────────────────────────────────────────────────
func TestFsWrite_AgentNotFound(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite)
body, _ := json.Marshal(map[string]string{"path": "/tmp/test.txt", "content": "hello"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/write", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestFsWrite_MissingPath(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite)
body, _ := json.Marshal(map[string]string{"content": "hello"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/write", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ── FsMkdir ───────────────────────────────────────────────────────────────────
func TestFsMkdir_AgentNotFound(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir)
body, _ := json.Marshal(map[string]string{"path": "/opt/stacks/nouveau-dossier"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/mkdir", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestFsMkdir_InvalidBody(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir)
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/mkdir", bytes.NewReader([]byte("not-json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ── ComposeAction ─────────────────────────────────────────────────────────────
func TestComposeAction_AgentNotFound(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestComposeAction_InvalidAction(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "restart"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestComposeAction_MissingFields(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
body, _ := json.Marshal(map[string]string{"action": "up"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestComposeAction_Timeout(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
bodyBytes, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"})
req := newCancelledRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound {
t.Errorf("expected 504 or 404, got %d", w.Code)
}
}

View File

@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
@ -185,6 +186,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Alias string `json:"alias"`
IPAddress string `json:"ip_address"`
ID string `json:"id"`
Tags []string `json:"tags"`
Size int64 `json:"size"`
@ -197,8 +199,9 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
AgentID: agent.ID,
Hostname: agent.Hostname,
Alias: agent.Alias,
IPAddress: agent.IPAddress,
ID: img.GetId(),
Tags: img.GetTags(),
Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(),
Size: img.GetSize(),
CreatedAt: img.GetCreatedAt(),
})
@ -214,6 +217,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Alias string `json:"alias"`
IPAddress string `json:"ip_address"`
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
@ -225,6 +229,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
AgentID: agent.ID,
Hostname: agent.Hostname,
Alias: agent.Alias,
IPAddress: agent.IPAddress,
Name: vol.GetName(),
Driver: vol.GetDriver(),
Mountpoint: vol.GetMountpoint(),
@ -241,6 +246,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Alias string `json:"alias"`
IPAddress string `json:"ip_address"`
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
@ -253,6 +259,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
AgentID: agent.ID,
Hostname: agent.Hostname,
Alias: agent.Alias,
IPAddress: agent.IPAddress,
ID: net.GetId(),
Name: net.GetName(),
Driver: net.GetDriver(),
@ -390,6 +397,292 @@ func (h *Handler) EventsWS(w http.ResponseWriter, r *http.Request) {
}
}
// ── File system & Compose ─────────────────────────────────────────────────────
// sendFileCmd sends a file/compose command to an agent and waits for the response.
// It uses the request context with an added 30s deadline so the handler can be
// tested by cancelling the context.
func (h *Handler) sendFileCmd(r *http.Request, agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
return h.registry.SendAndWaitCtx(ctx, agentID, msg, cmdID)
}
// FsList handles GET /api/v1/agents/{agentID}/fs/list?path=/some/dir
func (h *Handler) FsList(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
path := r.URL.Query().Get("path")
if path == "" {
http.Error(w, "path required", http.StatusBadRequest)
return
}
cmdID := uuid.NewString()
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_ListDir{
ListDir: &agentv1.ListDirCommand{
CommandId: cmdID,
Path: path,
},
},
}, cmdID)
if err != nil {
if err.Error() == "agent not connected" {
http.Error(w, "agent not connected", http.StatusNotFound)
return
}
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
return
}
if !result.Success {
http.Error(w, result.Error, http.StatusInternalServerError)
return
}
// Content is JSON-encoded list of entries from the agent
var entries json.RawMessage = result.Content
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
// FsRead handles GET /api/v1/agents/{agentID}/fs/read?path=/some/file
func (h *Handler) FsRead(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
path := r.URL.Query().Get("path")
if path == "" {
http.Error(w, "path required", http.StatusBadRequest)
return
}
cmdID := uuid.NewString()
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_ReadFile{
ReadFile: &agentv1.ReadFileCommand{
CommandId: cmdID,
Path: path,
},
},
}, cmdID)
if err != nil {
if err.Error() == "agent not connected" {
http.Error(w, "agent not connected", http.StatusNotFound)
return
}
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
return
}
if !result.Success {
http.Error(w, result.Error, http.StatusInternalServerError)
return
}
jsonOK(w, map[string]string{"content": string(result.Content)})
}
// FsWrite handles POST /api/v1/agents/{agentID}/fs/write
func (h *Handler) FsWrite(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
var body struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" {
http.Error(w, "path and content required", http.StatusBadRequest)
return
}
cmdID := uuid.NewString()
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_WriteFile{
WriteFile: &agentv1.WriteFileCommand{
CommandId: cmdID,
Path: body.Path,
Content: []byte(body.Content),
},
},
}, cmdID)
if err != nil {
if err.Error() == "agent not connected" {
http.Error(w, "agent not connected", http.StatusNotFound)
return
}
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
return
}
if !result.Success {
http.Error(w, result.Error, http.StatusInternalServerError)
return
}
jsonOK(w, map[string]bool{"ok": true})
}
// FsMkdir handles POST /api/v1/agents/{agentID}/fs/mkdir
func (h *Handler) FsMkdir(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
var body struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" {
http.Error(w, "path required", http.StatusBadRequest)
return
}
cmdID := uuid.NewString()
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_CreateDir{
CreateDir: &agentv1.CreateDirCommand{
CommandId: cmdID,
Path: body.Path,
},
},
}, cmdID)
if err != nil {
if err.Error() == "agent not connected" {
http.Error(w, "agent not connected", http.StatusNotFound)
return
}
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
return
}
if !result.Success {
http.Error(w, result.Error, http.StatusInternalServerError)
return
}
jsonOK(w, map[string]bool{"ok": true})
}
// ComposeAction handles POST /api/v1/agents/{agentID}/compose
func (h *Handler) ComposeAction(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
var body struct {
Path string `json:"path"`
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" || body.Action == "" {
http.Error(w, "path and action required", http.StatusBadRequest)
return
}
validActions := map[string]bool{"up": true, "down": true, "pull": true}
if !validActions[body.Action] {
http.Error(w, "action must be one of: up, down, pull", http.StatusBadRequest)
return
}
cmdID := uuid.NewString()
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_ExecCompose{
ExecCompose: &agentv1.ExecComposeCommand{
CommandId: cmdID,
Path: body.Path,
Action: body.Action,
},
},
}, cmdID)
if err != nil {
if err.Error() == "agent not connected" {
http.Error(w, "agent not connected", http.StatusNotFound)
return
}
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
return
}
if !result.Success {
jsonErr, _ := json.Marshal(map[string]string{"error": result.Error, "output": string(result.Content)})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write(jsonErr)
return
}
jsonOK(w, map[string]any{"ok": true, "output": string(result.Content)})
}
// ── Auto-update policies ──────────────────────────────────────────────────────
// GetAutoUpdatePolicy handles GET /api/v1/agents/{agentID}/containers/{containerID}/auto-update
func (h *Handler) GetAutoUpdatePolicy(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
containerID := chi.URLParam(r, "containerID")
p, err := h.store.GetAutoUpdatePolicy(agentID, containerID)
if err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
if p == nil {
jsonOK(w, map[string]any{"enabled": false, "interval_minutes": 1440})
return
}
jsonOK(w, map[string]any{
"enabled": p.Enabled,
"interval_minutes": p.IntervalMinutes,
"last_checked_at": p.LastCheckedAt,
"last_updated_at": p.LastUpdatedAt,
})
}
// PutAutoUpdatePolicy handles PUT /api/v1/agents/{agentID}/containers/{containerID}/auto-update
func (h *Handler) PutAutoUpdatePolicy(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
containerID := chi.URLParam(r, "containerID")
var body struct {
Enabled bool `json:"enabled"`
IntervalMinutes int `json:"interval_minutes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.IntervalMinutes < 60 || body.IntervalMinutes > 43200 {
http.Error(w, "interval_minutes must be between 60 and 43200", http.StatusBadRequest)
return
}
p := &store.AutoUpdatePolicy{
AgentID: agentID,
ContainerID: containerID,
Enabled: body.Enabled,
IntervalMinutes: body.IntervalMinutes,
}
if err := h.store.UpsertAutoUpdatePolicy(p); err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
jsonOK(w, map[string]any{
"enabled": p.Enabled,
"interval_minutes": p.IntervalMinutes,
})
}
// UpdateNow handles POST /api/v1/agents/{agentID}/containers/{containerID}/update-now
func (h *Handler) UpdateNow(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
containerID := chi.URLParam(r, "containerID")
cmdID := uuid.NewString()
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_UpdateContainer{
UpdateContainer: &agentv1.UpdateContainerCommand{
CommandId: cmdID,
ContainerId: containerID,
},
},
})
if !sent {
http.Error(w, "agent not connected", http.StatusServiceUnavailable)
return
}
h.registry.RegisterPendingUpdate(agentID, cmdID, containerID)
jsonOK(w, map[string]string{"command_id": cmdID})
}
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)

View File

@ -52,6 +52,14 @@ func NewRouter(h *Handler) http.Handler {
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
r.Get("/events", h.EventsWS)
r.Get("/agents/{agentID}/fs/list", h.FsList)
r.Get("/agents/{agentID}/fs/read", h.FsRead)
r.Post("/agents/{agentID}/fs/write", h.FsWrite)
r.Post("/agents/{agentID}/fs/mkdir", h.FsMkdir)
r.Post("/agents/{agentID}/compose", h.ComposeAction)
r.Get("/agents/{agentID}/containers/{containerID}/auto-update", h.GetAutoUpdatePolicy)
r.Put("/agents/{agentID}/containers/{containerID}/auto-update", h.PutAutoUpdatePolicy)
r.Post("/agents/{agentID}/containers/{containerID}/update-now", h.UpdateNow)
})
})

View File

@ -4,6 +4,7 @@ import (
"io"
"log/slog"
"net"
"time"
"github.com/containarr/server/internal/broker"
agentv1 "github.com/containarr/server/internal/proto/agentv1"
@ -125,11 +126,21 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error {
})
case *agentv1.AgentMessage_Result:
res := p.Result
g.broker.Publish(broker.Event{
Type: "command.result",
AgentID: agentID,
Payload: p.Result,
Payload: res,
})
if containerID, found := g.registry.ResolvePendingUpdate(agentID, res.CommandId); found {
now := time.Now()
_ = g.store.UpdateAutoUpdateChecked(agentID, containerID, now)
if res.Success {
_ = g.store.UpdateAutoUpdateDone(agentID, containerID, now)
} else {
slog.Warn("update container failed", "agent_id", agentID, "container_id", containerID, "error", res.Error)
}
}
case *agentv1.AgentMessage_LogChunk:
g.broker.Publish(broker.Event{
@ -137,6 +148,29 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error {
AgentID: agentID,
Payload: p.LogChunk,
})
case *agentv1.AgentMessage_FileResult:
g.registry.ResolvePending(agentID, p.FileResult.CommandId, p.FileResult)
case *agentv1.AgentMessage_UpdateCheckResult:
res := p.UpdateCheckResult
if res.Error != "" {
slog.Warn("update check error", "agent_id", agentID, "container_id", res.ContainerId, "error", res.Error)
}
_ = g.store.UpdateAutoUpdateChecked(agentID, res.ContainerId, time.Now())
if res.UpdateAvailable {
cmdID := newCommandID()
slog.Info("update available, triggering UpdateContainerCommand", "agent_id", agentID, "container_id", res.ContainerId, "command_id", cmdID)
g.registry.Send(agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_UpdateContainer{
UpdateContainer: &agentv1.UpdateContainerCommand{
CommandId: cmdID,
ContainerId: res.ContainerId,
},
},
})
g.registry.RegisterPendingUpdate(agentID, cmdID, res.ContainerId)
}
}
}
}

View File

@ -1,6 +1,8 @@
package grpc
import (
"context"
"fmt"
"sync"
"time"
@ -21,6 +23,9 @@ type AgentState struct {
Networks []*agentv1.NetworkInfo
cmdCh chan *agentv1.ServerMessage
pendingFiles map[string]chan *agentv1.FileResult
pendingUpdates map[string]string // commandID → containerID
pendingMu sync.Mutex
}
type Registry struct {
@ -41,6 +46,8 @@ func (r *Registry) Register(id, hostname, alias, ipAddress, arch, os string) *Ag
Arch: arch,
OS: os,
cmdCh: make(chan *agentv1.ServerMessage, 16),
pendingFiles: make(map[string]chan *agentv1.FileResult),
pendingUpdates: make(map[string]string),
}
r.mu.Lock()
r.agents[id] = state
@ -118,3 +125,113 @@ func (r *Registry) Send(agentID string, msg *agentv1.ServerMessage) bool {
return false
}
}
// RegisterPending registers a channel waiting for a FileResult with the given cmdID.
func (r *Registry) RegisterPending(agentID, cmdID string) chan *agentv1.FileResult {
r.mu.RLock()
s, ok := r.agents[agentID]
r.mu.RUnlock()
if !ok {
return nil
}
ch := make(chan *agentv1.FileResult, 1)
s.pendingMu.Lock()
s.pendingFiles[cmdID] = ch
s.pendingMu.Unlock()
return ch
}
// ResolvePending sends the FileResult to the waiting channel identified by cmdID.
func (r *Registry) ResolvePending(agentID, cmdID string, result *agentv1.FileResult) {
r.mu.RLock()
s, ok := r.agents[agentID]
r.mu.RUnlock()
if !ok {
return
}
s.pendingMu.Lock()
ch, ok := s.pendingFiles[cmdID]
if ok {
delete(s.pendingFiles, cmdID)
}
s.pendingMu.Unlock()
if ok {
select {
case ch <- result:
default:
}
}
}
// CancelPending removes the pending channel for cmdID (cleanup on timeout).
func (r *Registry) CancelPending(agentID, cmdID string) {
r.mu.RLock()
s, ok := r.agents[agentID]
r.mu.RUnlock()
if !ok {
return
}
s.pendingMu.Lock()
delete(s.pendingFiles, cmdID)
s.pendingMu.Unlock()
}
// SendAndWait registers a pending channel, sends msg to the agent, and waits up
// to 30 seconds for the FileResult response identified by cmdID.
func (r *Registry) SendAndWait(agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return r.SendAndWaitCtx(ctx, agentID, msg, cmdID)
}
// RegisterPendingUpdate enregistre un commandID en attente de CommandResult pour un UpdateContainer.
func (r *Registry) RegisterPendingUpdate(agentID, cmdID, containerID string) {
r.mu.RLock()
s, ok := r.agents[agentID]
r.mu.RUnlock()
if !ok {
return
}
s.pendingMu.Lock()
s.pendingUpdates[cmdID] = containerID
s.pendingMu.Unlock()
}
// ResolvePendingUpdate retourne le containerID associé au commandID et le supprime de la map.
// Retourne ("", false) si le commandID n'est pas connu.
func (r *Registry) ResolvePendingUpdate(agentID, cmdID string) (string, bool) {
r.mu.RLock()
s, ok := r.agents[agentID]
r.mu.RUnlock()
if !ok {
return "", false
}
s.pendingMu.Lock()
containerID, found := s.pendingUpdates[cmdID]
if found {
delete(s.pendingUpdates, cmdID)
}
s.pendingMu.Unlock()
return containerID, found
}
// SendAndWaitCtx is like SendAndWait but uses the provided context for timeout control.
func (r *Registry) SendAndWaitCtx(ctx context.Context, agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) {
ch := r.RegisterPending(agentID, cmdID)
if ch == nil {
return nil, fmt.Errorf("agent not connected")
}
if !r.Send(agentID, msg) {
r.CancelPending(agentID, cmdID)
return nil, fmt.Errorf("agent not connected")
}
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
r.CancelPending(agentID, cmdID)
return nil, fmt.Errorf("timeout waiting for agent response")
}
}

View File

@ -1,6 +1,7 @@
package grpc
import (
"context"
"testing"
"time"
@ -153,3 +154,112 @@ func TestSend_FullChannel(t *testing.T) {
t.Error("Send should return false when channel is full")
}
}
// ── Pending file correlations ──────────────────────────────────────────────────
func TestRegisterPending_UnknownAgent(t *testing.T) {
r := NewRegistry()
ch := r.RegisterPending("ghost", "cmd1")
if ch != nil {
t.Error("expected nil channel for unknown agent")
}
}
func TestResolvePending_Success(t *testing.T) {
r := NewRegistry()
r.Register("id1", "h", "a", "ip", "arch", "os")
ch := r.RegisterPending("id1", "cmd1")
if ch == nil {
t.Fatal("expected non-nil channel")
}
result := &agentv1.FileResult{CommandId: "cmd1", Success: true, Content: []byte("data")}
r.ResolvePending("id1", "cmd1", result)
select {
case got := <-ch:
if got.CommandId != "cmd1" || !got.Success {
t.Errorf("unexpected result: %+v", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for resolve")
}
}
func TestResolvePending_UnknownAgent(t *testing.T) {
r := NewRegistry()
// must not panic
r.ResolvePending("ghost", "cmd1", &agentv1.FileResult{})
}
func TestResolvePending_UnknownCmd(t *testing.T) {
r := NewRegistry()
r.Register("id1", "h", "a", "ip", "arch", "os")
// must not panic
r.ResolvePending("id1", "nonexistent", &agentv1.FileResult{})
}
func TestCancelPending(t *testing.T) {
r := NewRegistry()
r.Register("id1", "h", "a", "ip", "arch", "os")
r.RegisterPending("id1", "cmd1")
r.CancelPending("id1", "cmd1")
// After cancel, resolving should be a no-op (not panic)
r.ResolvePending("id1", "cmd1", &agentv1.FileResult{})
}
func TestCancelPending_UnknownAgent(t *testing.T) {
r := NewRegistry()
// must not panic
r.CancelPending("ghost", "cmd1")
}
func TestSendAndWaitCtx_AgentNotConnected(t *testing.T) {
r := NewRegistry()
ctx := context.Background()
_, err := r.SendAndWaitCtx(ctx, "ghost", &agentv1.ServerMessage{}, "cmd1")
if err == nil || err.Error() != "agent not connected" {
t.Errorf("expected 'agent not connected', got %v", err)
}
}
func TestSendAndWaitCtx_Timeout(t *testing.T) {
r := NewRegistry()
r.Register("id1", "h", "a", "ip", "arch", "os")
// Use an already-cancelled context to force immediate timeout.
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
_, err := r.SendAndWaitCtx(ctx, "id1", &agentv1.ServerMessage{}, "cmd-timeout")
if err == nil {
t.Error("expected timeout or not-connected error")
}
}
func TestSendAndWaitCtx_Success(t *testing.T) {
r := NewRegistry()
r.Register("id1", "h", "a", "ip", "arch", "os")
cmdID := "cmd-success"
expected := &agentv1.FileResult{CommandId: cmdID, Success: true, Content: []byte("hello")}
// Simulate the agent responding after the send.
go func() {
// Wait briefly for RegisterPending + Send to happen.
time.Sleep(10 * time.Millisecond)
r.ResolvePending("id1", cmdID, expected)
}()
ctx := context.Background()
result, err := r.SendAndWaitCtx(ctx, "id1", &agentv1.ServerMessage{}, cmdID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.CommandId != cmdID || !result.Success {
t.Errorf("unexpected result: %+v", result)
}
}

View File

@ -0,0 +1,39 @@
package scheduler
import (
"time"
"github.com/containarr/server/internal/store"
)
// StoreAdapter wraps *store.Store so it satisfies StoreInterface.
type StoreAdapter struct {
s *store.Store
}
// NewStoreAdapter creates a StoreAdapter wrapping the given *store.Store.
func NewStoreAdapter(s *store.Store) *StoreAdapter {
return &StoreAdapter{s: s}
}
// ListDueAutoUpdatePolicies implements StoreInterface by converting
// *store.AutoUpdatePolicy to DuePolicy.
func (a *StoreAdapter) ListDueAutoUpdatePolicies(now time.Time) ([]DuePolicy, error) {
policies, err := a.s.ListDueAutoUpdatePolicies(now)
if err != nil {
return nil, err
}
out := make([]DuePolicy, 0, len(policies))
for _, p := range policies {
out = append(out, DuePolicy{
AgentID: p.AgentID,
ContainerID: p.ContainerID,
})
}
return out, nil
}
// UpdateAutoUpdateChecked implements StoreInterface.
func (a *StoreAdapter) UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error {
return a.s.UpdateAutoUpdateChecked(agentID, containerID, at)
}

View File

@ -0,0 +1,86 @@
package scheduler
import (
"context"
"log/slog"
"time"
agentv1 "github.com/containarr/server/internal/proto/agentv1"
"github.com/google/uuid"
)
// DuePolicy is a minimal view of an auto-update policy returned by the store.
type DuePolicy struct {
AgentID string
ContainerID string
}
// StoreInterface defines the minimal store methods used by the scheduler.
// Implementations must convert their internal policy type to DuePolicy when
// implementing ListDueAutoUpdatePolicies, or use StoreAdapter provided below.
type StoreInterface interface {
ListDueAutoUpdatePolicies(now time.Time) ([]DuePolicy, error)
UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error
}
// RegistryInterface defines the minimal registry methods used by the scheduler.
type RegistryInterface interface {
Send(agentID string, msg *agentv1.ServerMessage) bool
}
// Scheduler sends CheckUpdateCommand to agents every 60 seconds for containers
// with an active and due auto-update policy.
type Scheduler struct {
store StoreInterface
registry RegistryInterface
}
// New creates a new Scheduler.
func New(store StoreInterface, registry RegistryInterface) *Scheduler {
return &Scheduler{store: store, registry: registry}
}
// Start runs the scheduler loop until ctx is cancelled.
func (s *Scheduler) Start(ctx context.Context) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("scheduler stopped")
return
case t := <-ticker.C:
s.tick(t)
}
}
}
func (s *Scheduler) tick(now time.Time) {
policies, err := s.store.ListDueAutoUpdatePolicies(now)
if err != nil {
slog.Error("scheduler: list due policies", "err", err)
return
}
for _, p := range policies {
cmdID := uuid.NewString()
msg := &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_CheckUpdate{
CheckUpdate: &agentv1.CheckUpdateCommand{
CommandId: cmdID,
ContainerId: p.ContainerID,
},
},
}
sent := s.registry.Send(p.AgentID, msg)
if !sent {
slog.Debug("scheduler: agent not connected, skipping", "agent_id", p.AgentID, "container_id", p.ContainerID)
continue
}
if err := s.store.UpdateAutoUpdateChecked(p.AgentID, p.ContainerID, now); err != nil {
slog.Error("scheduler: update last_checked_at", "agent_id", p.AgentID, "container_id", p.ContainerID, "err", err)
}
slog.Info("scheduler: sent CheckUpdateCommand", "agent_id", p.AgentID, "container_id", p.ContainerID, "command_id", cmdID)
}
}

View File

@ -49,6 +49,16 @@ func (s *Store) migrate() error {
last_seen_at DATETIME,
online INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS auto_update_policies (
agent_id TEXT NOT NULL,
container_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
interval_minutes INTEGER NOT NULL DEFAULT 1440,
last_checked_at DATETIME,
last_updated_at DATETIME,
PRIMARY KEY (agent_id, container_id),
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
`)
if err != nil {
return err
@ -186,3 +196,106 @@ func boolToInt(b bool) int {
}
return 0
}
// ── AutoUpdatePolicies ────────────────────────────────────────────────────────
type AutoUpdatePolicy struct {
AgentID string
ContainerID string
Enabled bool
IntervalMinutes int
LastCheckedAt *time.Time
LastUpdatedAt *time.Time
}
func (s *Store) UpsertAutoUpdatePolicy(p *AutoUpdatePolicy) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO auto_update_policies
(agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, p.AgentID, p.ContainerID, boolToInt(p.Enabled), p.IntervalMinutes, p.LastCheckedAt, p.LastUpdatedAt)
return err
}
func (s *Store) GetAutoUpdatePolicy(agentID, containerID string) (*AutoUpdatePolicy, error) {
row := s.db.QueryRow(`
SELECT agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at
FROM auto_update_policies WHERE agent_id = ? AND container_id = ?
`, agentID, containerID)
p := &AutoUpdatePolicy{}
var enabled int
var lastChecked, lastUpdated sql.NullTime
err := row.Scan(&p.AgentID, &p.ContainerID, &enabled, &p.IntervalMinutes, &lastChecked, &lastUpdated)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
p.Enabled = enabled == 1
if lastChecked.Valid {
t := lastChecked.Time
p.LastCheckedAt = &t
}
if lastUpdated.Valid {
t := lastUpdated.Time
p.LastUpdatedAt = &t
}
return p, nil
}
func (s *Store) ListDueAutoUpdatePolicies(now time.Time) ([]*AutoUpdatePolicy, error) {
rows, err := s.db.Query(`
SELECT agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at
FROM auto_update_policies
WHERE enabled = 1
AND (last_checked_at IS NULL
OR (julianday(?) - julianday(last_checked_at)) * 1440 >= interval_minutes)
`, now)
if err != nil {
return nil, err
}
defer rows.Close()
var policies []*AutoUpdatePolicy
for rows.Next() {
p := &AutoUpdatePolicy{}
var enabled int
var lastChecked, lastUpdated sql.NullTime
if err := rows.Scan(&p.AgentID, &p.ContainerID, &enabled, &p.IntervalMinutes, &lastChecked, &lastUpdated); err != nil {
return nil, err
}
p.Enabled = enabled == 1
if lastChecked.Valid {
t := lastChecked.Time
p.LastCheckedAt = &t
}
if lastUpdated.Valid {
t := lastUpdated.Time
p.LastUpdatedAt = &t
}
policies = append(policies, p)
}
return policies, rows.Err()
}
func (s *Store) UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error {
_, err := s.db.Exec(`
UPDATE auto_update_policies SET last_checked_at = ? WHERE agent_id = ? AND container_id = ?
`, at, agentID, containerID)
return err
}
func (s *Store) UpdateAutoUpdateDone(agentID, containerID string, at time.Time) error {
_, err := s.db.Exec(`
UPDATE auto_update_policies SET last_updated_at = ? WHERE agent_id = ? AND container_id = ?
`, at, agentID, containerID)
return err
}
func (s *Store) DeleteAutoUpdatePolicy(agentID, containerID string) error {
_, err := s.db.Exec(`
DELETE FROM auto_update_policies WHERE agent_id = ? AND container_id = ?
`, agentID, containerID)
return err
}

View File

@ -2,6 +2,7 @@ package store
import (
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
@ -254,3 +255,199 @@ func TestCreateAgentToken_IdempotentIgnore(t *testing.T) {
t.Fatalf("second call (should be idempotent): %v", err)
}
}
// ── AutoUpdatePolicies ────────────────────────────────────────────────────────
// helper: create an agent prerequisite for FK constraints.
func createAgent(t *testing.T, s *Store, id, token, hostname string) {
t.Helper()
if err := s.CreateAgentToken(id, token, hostname); err != nil {
t.Fatalf("createAgent: %v", err)
}
}
func TestUpsertAndGetAutoUpdatePolicy(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
p := &AutoUpdatePolicy{
AgentID: "ag1",
ContainerID: "ctr1",
Enabled: true,
IntervalMinutes: 60,
}
if err := s.UpsertAutoUpdatePolicy(p); err != nil {
t.Fatalf("UpsertAutoUpdatePolicy: %v", err)
}
got, err := s.GetAutoUpdatePolicy("ag1", "ctr1")
if err != nil {
t.Fatalf("GetAutoUpdatePolicy: %v", err)
}
if got == nil {
t.Fatal("expected policy, got nil")
}
if !got.Enabled || got.IntervalMinutes != 60 {
t.Errorf("unexpected policy: %+v", got)
}
if got.LastCheckedAt != nil || got.LastUpdatedAt != nil {
t.Error("expected nil timestamps on fresh policy")
}
}
func TestGetAutoUpdatePolicy_NotFound(t *testing.T) {
s := newTestStore(t)
p, err := s.GetAutoUpdatePolicy("nobody", "ctr")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p != nil {
t.Errorf("expected nil, got %+v", p)
}
}
func TestUpsertAutoUpdatePolicy_Update(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: false, IntervalMinutes: 1440})
got, err := s.GetAutoUpdatePolicy("ag1", "ctr1")
if err != nil {
t.Fatalf("GetAutoUpdatePolicy: %v", err)
}
if got.Enabled || got.IntervalMinutes != 1440 {
t.Errorf("expected updated policy, got %+v", got)
}
}
func TestUpdateAutoUpdateChecked(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
now := time.Now().Truncate(time.Second)
if err := s.UpdateAutoUpdateChecked("ag1", "ctr1", now); err != nil {
t.Fatalf("UpdateAutoUpdateChecked: %v", err)
}
got, _ := s.GetAutoUpdatePolicy("ag1", "ctr1")
if got.LastCheckedAt == nil {
t.Fatal("expected LastCheckedAt to be set")
}
if got.LastCheckedAt.UTC().Truncate(time.Second) != now.UTC() {
t.Errorf("expected %v, got %v", now.UTC(), got.LastCheckedAt.UTC().Truncate(time.Second))
}
}
func TestUpdateAutoUpdateDone(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
now := time.Now().Truncate(time.Second)
if err := s.UpdateAutoUpdateDone("ag1", "ctr1", now); err != nil {
t.Fatalf("UpdateAutoUpdateDone: %v", err)
}
got, _ := s.GetAutoUpdatePolicy("ag1", "ctr1")
if got.LastUpdatedAt == nil {
t.Fatal("expected LastUpdatedAt to be set")
}
}
func TestListDueAutoUpdatePolicies_NullLastChecked(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
// last_checked_at IS NULL → should be due immediately.
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 1 {
t.Fatalf("expected 1 due policy, got %d", len(due))
}
if due[0].ContainerID != "ctr1" {
t.Errorf("unexpected container: %q", due[0].ContainerID)
}
}
func TestListDueAutoUpdatePolicies_NotDueYet(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 1440})
// Mark as just checked — not due yet.
_ = s.UpdateAutoUpdateChecked("ag1", "ctr1", time.Now())
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 0 {
t.Fatalf("expected 0 due policies (just checked), got %d", len(due))
}
}
func TestListDueAutoUpdatePolicies_Due(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
// Simulate last check 2 hours ago → should be due.
past := time.Now().Add(-2 * time.Hour)
_ = s.UpdateAutoUpdateChecked("ag1", "ctr1", past)
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 1 {
t.Fatalf("expected 1 due policy (overdue), got %d", len(due))
}
}
func TestListDueAutoUpdatePolicies_DisabledExcluded(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: false, IntervalMinutes: 60})
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 0 {
t.Fatalf("expected 0 due policies (disabled), got %d", len(due))
}
}
func TestDeleteAutoUpdatePolicy(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
if err := s.DeleteAutoUpdatePolicy("ag1", "ctr1"); err != nil {
t.Fatalf("DeleteAutoUpdatePolicy: %v", err)
}
got, err := s.GetAutoUpdatePolicy("ag1", "ctr1")
if err != nil {
t.Fatalf("GetAutoUpdatePolicy: %v", err)
}
if got != nil {
t.Error("expected nil after deletion")
}
}
func TestDeleteAutoUpdatePolicy_Idempotent(t *testing.T) {
s := newTestStore(t)
// Deleting a non-existent policy should not error.
if err := s.DeleteAutoUpdatePolicy("nobody", "ctr"); err != nil {
t.Fatalf("DeleteAutoUpdatePolicy on missing: %v", err)
}
}

153
web/package-lock.json generated
View File

@ -7,6 +7,14 @@
"": {
"name": "containarr-web",
"version": "0.1.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.16.0",
@ -1680,6 +1688,92 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@ -2351,6 +2445,47 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3956,6 +4091,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -7323,6 +7464,12 @@
"node": ">=8"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -8194,6 +8341,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View File

@ -29,5 +29,13 @@
"vite": "^6.0.7",
"vitest": "^4.1.6",
"workbox-window": "^7.3.0"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0"
}
}

View File

@ -122,6 +122,7 @@ export interface ImageEntry {
agent_id: string;
hostname: string;
alias: string;
ip_address: string;
id: string;
tags: string[];
size: number;
@ -132,6 +133,7 @@ export interface VolumeEntry {
agent_id: string;
hostname: string;
alias: string;
ip_address: string;
name: string;
driver: string;
mountpoint: string;
@ -141,6 +143,7 @@ export interface NetworkEntry {
agent_id: string;
hostname: string;
alias: string;
ip_address: string;
id: string;
name: string;
driver: string;
@ -201,6 +204,92 @@ export function connectLogs(
return () => ws.close();
}
export async function fsList(
agentId: string,
path: string
): Promise<{ name: string; is_dir: boolean; has_compose: boolean }[]> {
const r = await apiFetch(`${BASE}/agents/${agentId}/fs/list?path=${encodeURIComponent(path)}`);
if (!r.ok) throw new Error(`fsList: ${r.status}`);
return r.json();
}
export async function fsRead(agentId: string, path: string): Promise<string> {
const r = await apiFetch(`${BASE}/agents/${agentId}/fs/read?path=${encodeURIComponent(path)}`);
if (!r.ok) throw new Error(`fsRead: ${r.status}`);
const json = await r.json();
return json.content as string;
}
export async function fsWrite(agentId: string, path: string, content: string): Promise<void> {
const r = await apiFetch(`${BASE}/agents/${agentId}/fs/write`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
});
if (!r.ok) throw new Error(`fsWrite: ${r.status}`);
}
export async function fsMkdir(agentId: string, path: string): Promise<void> {
const r = await apiFetch(`/api/v1/agents/${agentId}/fs/mkdir`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!r.ok) throw new Error(`mkdir: ${r.status}`);
}
export async function composeAction(
agentId: string,
path: string,
action: "up" | "down" | "pull"
): Promise<{ ok: boolean; output: string }> {
const r = await apiFetch(`${BASE}/agents/${agentId}/compose`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, action }),
});
if (!r.ok) throw new Error(`composeAction: ${r.status}`);
return r.json();
}
export interface AutoUpdatePolicy {
enabled: boolean;
interval_minutes: number;
last_checked_at: string | null;
last_updated_at: string | null;
}
export async function getAutoUpdatePolicy(agentId: string, containerId: string): Promise<AutoUpdatePolicy> {
const r = await apiFetch(`${BASE}/agents/${agentId}/containers/${containerId}/auto-update`);
if (!r.ok) throw new Error(`getAutoUpdatePolicy: ${r.status}`);
return r.json();
}
export async function setAutoUpdatePolicy(
agentId: string,
containerId: string,
policy: Pick<AutoUpdatePolicy, "enabled" | "interval_minutes">
): Promise<AutoUpdatePolicy> {
const r = await apiFetch(`${BASE}/agents/${agentId}/containers/${containerId}/auto-update`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(policy),
});
if (!r.ok) throw new Error(`setAutoUpdatePolicy: ${r.status}`);
return r.json();
}
export async function updateNow(agentId: string, containerId: string): Promise<{ command_id: string }> {
const r = await apiFetch(`${BASE}/agents/${agentId}/containers/${containerId}/update-now`, {
method: "POST",
});
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(text || `updateNow: ${r.status}`);
}
return r.json();
}
export function connectEvents(
onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void
): () => void {

View File

@ -13,11 +13,15 @@
fetchNetworks,
containerAction,
connectEvents,
getAutoUpdatePolicy,
setAutoUpdatePolicy,
updateNow,
type ContainerEntry,
type ContainerPort,
type ImageEntry,
type VolumeEntry,
type NetworkEntry,
type AutoUpdatePolicy,
} from "$lib/api";
import { clearToken } from "$lib/auth";
import LogModal from "$lib/LogModal.svelte";
@ -144,6 +148,19 @@
loadError = null;
try {
entries = await fetchContainers() ?? [];
// Pré-chargement en arrière-plan des policies pour colorer les boutons auto-update
const toLoad = entries;
Promise.allSettled(
toLoad.map(e => getAutoUpdatePolicy(e.agent_id, e.container.id).then(policy => ({ key: autoUpdateKey(e.agent_id, e.container.id), policy })))
).then(results => {
const updates: Record<string, AutoUpdateState> = {};
for (const r of results) {
if (r.status === "fulfilled") {
updates[r.value.key] = { policy: r.value.policy, loading: false, saving: false };
}
}
autoUpdateStates = { ...autoUpdateStates, ...updates };
});
} catch (e: unknown) {
loadError = e instanceof Error ? e.message : String(e);
entries = [];
@ -213,11 +230,170 @@
setTimeout(() => (toast = null), 3000);
}
// ── Auto-update panel ────────────────────────────────────────────────────
interface AutoUpdateState {
policy: AutoUpdatePolicy | null;
loading: boolean;
saving: boolean;
}
let autoUpdateOpen = $state<string | null>(null); // containerKey = `${agentId}/${containerId}`
let updateNowPending = $state<string | null>(null); // containerKey en cours d'update
let autoUpdateStates = $state<Record<string, AutoUpdateState>>({});
let autoUpdateDebounce = $state<ReturnType<typeof setTimeout> | null>(null);
let autoUpdatePanelPos = $state<{ top: number; right: number } | null>(null);
const INTERVAL_OPTIONS = [
{ label: "1 heure", value: 60 },
{ label: "6 heures", value: 360 },
{ label: "12 heures", value: 720 },
{ label: "24 heures", value: 1440 },
{ label: "7 jours", value: 10080 },
];
function autoUpdateKey(agentId: string, containerId: string) {
return `${agentId}/${containerId}`;
}
async function openAutoUpdate(agentId: string, containerId: string, panelPos?: { top: number; right: number }) {
const key = autoUpdateKey(agentId, containerId);
if (autoUpdateOpen === key) {
autoUpdateOpen = null;
autoUpdatePanelPos = null;
return;
}
autoUpdateOpen = key;
if (panelPos) autoUpdatePanelPos = panelPos;
// Si la policy a déjà été pré-chargée, on ne la réinitialise pas
const existing = autoUpdateStates[key];
if (!existing?.policy) {
autoUpdateStates = {
...autoUpdateStates,
[key]: { policy: existing?.policy ?? null, loading: true, saving: false },
};
try {
const policy = await getAutoUpdatePolicy(agentId, containerId);
autoUpdateStates = {
...autoUpdateStates,
[key]: { policy, loading: false, saving: false },
};
} catch {
autoUpdateStates = {
...autoUpdateStates,
[key]: { policy: null, loading: false, saving: false },
};
}
}
}
function scheduleAutoUpdateSave(agentId: string, containerId: string) {
const key = autoUpdateKey(agentId, containerId);
if (autoUpdateDebounce) clearTimeout(autoUpdateDebounce);
autoUpdateDebounce = setTimeout(async () => {
const state = autoUpdateStates[key];
if (!state?.policy) return;
autoUpdateStates = {
...autoUpdateStates,
[key]: { ...state, saving: true },
};
try {
const updated = await setAutoUpdatePolicy(agentId, containerId, {
enabled: state.policy.enabled,
interval_minutes: state.policy.interval_minutes,
});
autoUpdateStates = {
...autoUpdateStates,
[key]: { policy: updated, loading: false, saving: false },
};
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
autoUpdateStates = {
...autoUpdateStates,
[key]: { ...state, saving: false },
};
}
}, 300);
}
function toggleAutoUpdateEnabled(agentId: string, containerId: string) {
const key = autoUpdateKey(agentId, containerId);
const state = autoUpdateStates[key];
if (!state?.policy) return;
autoUpdateStates = {
...autoUpdateStates,
[key]: { ...state, policy: { ...state.policy, enabled: !state.policy.enabled } },
};
scheduleAutoUpdateSave(agentId, containerId);
}
function changeAutoUpdateInterval(agentId: string, containerId: string, minutes: number) {
const key = autoUpdateKey(agentId, containerId);
const state = autoUpdateStates[key];
if (!state?.policy) return;
autoUpdateStates = {
...autoUpdateStates,
[key]: { ...state, policy: { ...state.policy, interval_minutes: minutes } },
};
scheduleAutoUpdateSave(agentId, containerId);
}
async function doUpdateNow(agentId: string, containerId: string) {
const key = autoUpdateKey(agentId, containerId);
updateNowPending = key;
try {
await updateNow(agentId, containerId);
showToast("Mise à jour lancée", true);
// Refresh panel après un délai pour montrer last_checked_at mis à jour
if (autoUpdateOpen !== null) {
const currentKey = autoUpdateOpen;
const parts = currentKey.split('/');
const panelAgentId = parts[0];
const panelContainerId = parts.slice(1).join('/');
setTimeout(async () => {
if (autoUpdateOpen !== currentKey) return; // panel fermé entretemps
try {
const policy = await getAutoUpdatePolicy(panelAgentId, panelContainerId);
autoUpdateStates = {
...autoUpdateStates,
[currentKey]: { policy, loading: false, saving: false },
};
} catch {}
}, 3000);
}
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
updateNowPending = null;
}
}
function closeAutoUpdateOnClickOutside(e: MouseEvent) {
if (autoUpdateOpen === null) return;
const target = e.target as HTMLElement;
if (!target.closest("[data-autoupdate-panel]") && !target.closest("[data-autoupdate-btn]")) {
autoUpdateOpen = null;
autoUpdatePanelPos = null;
}
}
function formatRelativeTime(iso: string | null): string {
if (!iso) return "Jamais";
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return "Il y a quelques secondes";
const m = Math.floor(s / 60);
if (m < 60) return `Il y a ${m} min`;
const h = Math.floor(m / 60);
if (h < 24) return `Il y a ${h}h`;
const d = Math.floor(h / 24);
return `Il y a ${d}j`;
}
// ── Toggle helpers ────────────────────────────────────────────────────────
function toggleSection(agentId: string) { collapsed[agentId] = !(collapsed[agentId] ?? true); }
function toggleImages(agentId: string) { collapsedImages[agentId] = !(collapsedImages[agentId] ?? true); }
function toggleVolumes(agentId: string) { collapsedVolumes[agentId] = !(collapsedVolumes[agentId] ?? true); }
function toggleNetworks(agentId: string) { collapsedNetworks[agentId] = !(collapsedNetworks[agentId] ?? true); }
function toggleSection(agentId: string) { collapsed = { ...collapsed, [agentId]: !(collapsed[agentId] ?? true) }; }
function toggleImages(agentId: string) { collapsedImages = { ...collapsedImages, [agentId]: !(collapsedImages[agentId] ?? true) }; }
function toggleVolumes(agentId: string) { collapsedVolumes = { ...collapsedVolumes, [agentId]: !(collapsedVolumes[agentId] ?? true) }; }
function toggleNetworks(agentId: string) { collapsedNetworks = { ...collapsedNetworks, [agentId]: !(collapsedNetworks[agentId] ?? true) }; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
onMount(() => {
@ -225,6 +401,18 @@
disconnect = connectEvents((evt) => {
if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") {
if (activeTab === "containers") load();
// Refresh open auto-update panel
if (autoUpdateOpen !== null) {
const parts = autoUpdateOpen.split('/');
const panelAgentId = parts[0];
const panelContainerId = parts.slice(1).join('/');
getAutoUpdatePolicy(panelAgentId, panelContainerId).then(policy => {
autoUpdateStates = {
...autoUpdateStates,
[autoUpdateOpen!]: { policy, loading: false, saving: false },
};
}).catch(() => {});
}
}
if (evt.type === "resources.updated") {
if (activeTab === "images") loadImages();
@ -255,7 +443,7 @@
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}).sort((a, b) => a.host_port - b.host_port);
}
function stateDotClass(state: string) {
@ -320,7 +508,7 @@
</div>
{/if}
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200" role="presentation" onclick={closeAutoUpdateOnClickOutside}>
<!-- Header -->
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
@ -334,19 +522,25 @@
<span class="text-xs text-slate-500 mr-3 tabular-nums">
{entries.length} containers · {Object.keys(byAgent).length} hosts
</span>
{:else if activeTab === "images" && images !== null}
{:else if activeTab === "images"}
{#if images !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
{images.length} images · {Object.keys(byAgentImages).length} hosts
</span>
{:else if activeTab === "volumes" && volumes !== null}
{/if}
{:else if activeTab === "volumes"}
{#if volumes !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
</span>
{:else if activeTab === "networks" && networks !== null}
{/if}
{:else if activeTab === "networks"}
{#if networks !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
</span>
{/if}
{/if}
{#if installPrompt}
<button onclick={installPWA} class="nav-btn" title="Installer l'application">
@ -357,6 +551,13 @@
</button>
{/if}
<a href="/compose" class="nav-btn" title="Éditeur Compose">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
<a href="/admin" class="nav-btn" title="Administration">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
@ -405,7 +606,6 @@
<main class="p-4 md:p-6 max-w-7xl mx-auto">
{#key activeTab}
<!-- ═══════════════════════════════════════════════════
CONTAINERS TAB
════════════════════════════════════════════════════ -->
@ -434,7 +634,6 @@
{#each sortedAgents as [agentId, containers]}
{#if containers.length > 0}
{@const first = containers[0]}
{@const isCollapsed = collapsed[agentId] ?? true}
<section class="mb-8">
<button
@ -444,7 +643,7 @@
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
{collapsed[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
@ -465,7 +664,7 @@
</span>
</button>
{#if !isCollapsed}
{#if collapsed[agentId] === false}
<!-- Desktop table -->
<div class="hidden md:block card overflow-hidden">
<table class="w-full text-sm">
@ -480,7 +679,7 @@
</tr>
</thead>
<tbody>
{#each containers as { agent_id, container } (container.id)}
{#each containers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) as { agent_id, container } (container.id)}
<tr class="border-b border-white/[0.04] last:border-0
hover:bg-white/[0.025] transition-colors group">
<td class="px-4 py-3">
@ -508,7 +707,7 @@
{container.compose_project || "—"}
</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-1.5">
<div class="flex justify-end gap-1.5 relative">
{@render ActionBtn({ label: "Logs", variant: "cyan",
loading: false,
onclick: () => openLogs(agent_id, container.id, container.name) })}
@ -524,6 +723,7 @@
loading: actionPending === container.id,
onclick: () => doAction(agent_id, container.id, "restart") })}
{/if}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</td>
</tr>
@ -534,7 +734,7 @@
<!-- Mobile cards -->
<div class="md:hidden space-y-2">
{#each containers as { agent_id, container } (container.id)}
{#each containers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) as { agent_id, container } (container.id)}
<div class="card p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2 min-w-0">
@ -554,7 +754,7 @@
{/each}
</div>
{/if}
<div class="flex gap-2 flex-wrap">
<div class="flex gap-2 flex-wrap relative">
{@render ActionBtn({ label: "Logs", variant: "cyan",
loading: false,
onclick: () => openLogs(agent_id, container.id, container.name) })}
@ -570,6 +770,7 @@
loading: actionPending === container.id,
onclick: () => doAction(agent_id, container.id, "restart") })}
{/if}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</div>
{/each}
@ -609,7 +810,6 @@
{:else}
{#each sortedAgentImages as [agentId, agentImages]}
{@const first = agentImages[0]}
{@const isCollapsed = collapsedImages[agentId] ?? true}
<section class="mb-8">
<button
@ -619,7 +819,7 @@
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
{collapsedImages[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
@ -631,12 +831,16 @@
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
</span>
</button>
{#if !isCollapsed}
{#if collapsedImages[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
@ -648,10 +852,10 @@
</tr>
</thead>
<tbody>
{#each agentImages as img (img.id)}
{#each agentImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-3">
{#if img.tags.length > 0}
{#if img.tags?.length > 0}
<div class="flex flex-wrap gap-1">
{#each img.tags as tag}
<span class="font-mono text-xs px-1.5 py-0.5 rounded
@ -706,7 +910,6 @@
{:else}
{#each sortedAgentVolumes as [agentId, agentVolumes]}
{@const first = agentVolumes[0]}
{@const isCollapsed = collapsedVolumes[agentId] ?? true}
<section class="mb-8">
<button
@ -716,7 +919,7 @@
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
{collapsedVolumes[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
@ -728,12 +931,16 @@
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
</span>
</button>
{#if !isCollapsed}
{#if collapsedVolumes[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
@ -744,7 +951,7 @@
</tr>
</thead>
<tbody>
{#each agentVolumes as vol (vol.name)}
{#each agentVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-3 font-mono text-xs text-slate-200 font-medium">{vol.name}</td>
<td class="px-4 py-3 text-xs text-slate-400">{vol.driver}</td>
@ -789,7 +996,6 @@
{:else}
{#each sortedAgentNetworks as [agentId, agentNetworks]}
{@const first = agentNetworks[0]}
{@const isCollapsed = collapsedNetworks[agentId] ?? true}
<section class="mb-8">
<button
@ -799,7 +1005,7 @@
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
{collapsedNetworks[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
@ -811,12 +1017,16 @@
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
</span>
</button>
{#if !isCollapsed}
{#if collapsedNetworks[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
@ -828,7 +1038,7 @@
</tr>
</thead>
<tbody>
{#each agentNetworks as net (net.id)}
{#each agentNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-3 font-mono text-xs text-slate-200 font-medium">{net.name}</td>
<td class="px-4 py-3 text-xs text-slate-400">{net.driver}</td>
@ -852,9 +1062,100 @@
{/each}
{/if}
{/if}
{/key}
</main>
<!-- Auto-update panel (position: fixed, hors du flux des tableaux) -->
{#if autoUpdateOpen !== null && autoUpdatePanelPos !== null}
{@const state = autoUpdateStates[autoUpdateOpen]}
{@const parts = autoUpdateOpen.split('/')}
{@const panelAgentId = parts[0]}
{@const panelContainerId = parts.slice(1).join('/')}
<div
data-autoupdate-panel
role="presentation"
onclick={(e) => e.stopPropagation()}
style="position: fixed; top: {autoUpdatePanelPos.top}px; right: {autoUpdatePanelPos.right}px;"
class="z-50 w-64 bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-3"
>
{#if state?.loading}
<div class="flex items-center justify-center py-4 text-slate-500 text-xs gap-2">
<div class="w-4 h-4 border border-slate-600 border-t-violet-400 rounded-full animate-spin"></div>
Chargement…
</div>
{:else if !state?.policy}
<p class="text-xs text-signal-red text-center py-2">Erreur de chargement</p>
{:else}
<!-- Toggle -->
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-slate-300">Auto-update</span>
<button
type="button"
aria-pressed={state.policy.enabled}
aria-label="Activer/désactiver l'auto-update"
onclick={() => toggleAutoUpdateEnabled(panelAgentId, panelContainerId)}
class="relative w-9 h-5 rounded-full cursor-pointer transition-colors focus:outline-none
{state.policy.enabled ? 'bg-violet-500' : 'bg-gray-600'}
{state.saving ? 'opacity-60' : ''}"
>
<span
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow
transition-transform duration-200
{state.policy.enabled ? 'translate-x-4' : 'translate-x-0'}"
></span>
</button>
</div>
<!-- Interval -->
<div class="mb-3">
<label for="au-interval-{autoUpdateOpen}" class="text-xs text-slate-500 block mb-1">Intervalle</label>
<select
id="au-interval-{autoUpdateOpen}"
disabled={!state.policy.enabled || state.saving}
value={state.policy.interval_minutes}
onchange={(e) => changeAutoUpdateInterval(panelAgentId, panelContainerId, Number((e.target as HTMLSelectElement).value))}
class="w-full bg-gray-900 border border-gray-600 text-gray-300 text-xs
rounded-md px-2 py-1.5 focus:outline-none focus:border-violet-500
disabled:opacity-40 disabled:cursor-not-allowed"
>
{#each INTERVAL_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Bouton update now -->
<div class="mb-3">
<button
type="button"
disabled={updateNowPending === autoUpdateOpen || state.saving}
onclick={() => doUpdateNow(panelAgentId, panelContainerId)}
class="w-full px-3 py-1.5 rounded-md text-xs font-medium transition-all border
bg-violet-500/15 hover:bg-violet-500/25 text-violet-300 border-violet-500/30
disabled:opacity-40 disabled:cursor-not-allowed"
>
{updateNowPending === autoUpdateOpen ? "Mise à jour en cours…" : "Mettre à jour maintenant"}
</button>
</div>
<!-- Info lines -->
<div class="space-y-1 border-t border-gray-700 pt-2">
<p class="text-gray-500 text-xs">
Vérification : <span class="text-slate-400">{formatRelativeTime(state.policy.last_checked_at)}</span>
</p>
<p class="text-gray-500 text-xs">
Mise à jour : <span class="text-slate-400">{formatRelativeTime(state.policy.last_updated_at)}</span>
</p>
</div>
{#if state.saving}
<p class="text-xs text-violet-400 mt-2 text-center">Enregistrement…</p>
{/if}
{/if}
</div>
{/if}
</div>
{#snippet ActionBtn({ label, variant, loading, onclick }: {
@ -876,3 +1177,33 @@
{loading ? "…" : label}
</button>
{/snippet}
{#snippet AutoUpdateBtn(agentId: string, containerId: string)}
{@const key = autoUpdateKey(agentId, containerId)}
{@const isOpen = autoUpdateOpen === key}
{@const state = autoUpdateStates[key]}
<div class="relative">
<button
data-autoupdate-btn
onclick={(e) => {
e.stopPropagation();
if (autoUpdateOpen !== key) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
openAutoUpdate(agentId, containerId, { top: rect.bottom + 6, right: window.innerWidth - rect.right });
} else {
openAutoUpdate(agentId, containerId);
}
}}
title="Auto-update"
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
{isOpen || state?.policy?.enabled
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{/snippet}

View File

@ -0,0 +1,649 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
import { defaultKeymap, indentWithTab } from '@codemirror/commands';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import { indentUnit } from '@codemirror/language';
import { bracketMatching } from '@codemirror/language';
import { fetchAgents, fsList, fsRead, fsWrite, composeAction, fsMkdir, type Agent } from '$lib/api';
import { clearToken } from '$lib/auth';
// ── Auth ──────────────────────────────────────────────────────────────────
function logout() {
clearToken();
goto('/login');
}
// ── Toast ─────────────────────────────────────────────────────────────────
let toast = $state<{ msg: string; ok: boolean } | null>(null);
function showToast(msg: string, ok: boolean) {
toast = { msg, ok };
setTimeout(() => (toast = null), 3500);
}
// ── Agents ────────────────────────────────────────────────────────────────
let agents = $state<Agent[]>([]);
let selectedAgentId = $state<string>('');
const selectedAgent = $derived(agents.find(a => a.id === selectedAgentId) ?? null);
// ── File browser ──────────────────────────────────────────────────────────
let currentPath = $state('/opt');
let dirEntries = $state<{ name: string; is_dir: boolean; has_compose: boolean }[]>([]);
let filePath = $state('');
let browseError = $state<string | null>(null);
let browseLoading = $state(false);
const breadcrumbs = $derived(() => {
const parts = currentPath.split('/').filter(Boolean);
const crumbs: { label: string; path: string }[] = [{ label: '/', path: '/' }];
let built = '';
for (const p of parts) {
built += '/' + p;
crumbs.push({ label: p, path: built });
}
return crumbs;
});
function entryFullPath(name: string): string {
return currentPath === '/' ? '/' + name : currentPath + '/' + name;
}
function isComposeName(name: string): boolean {
return name === 'docker-compose.yaml' || name === 'docker-compose.yml'
|| name === 'compose.yaml' || name === 'compose.yml';
}
async function browse(path: string) {
if (!selectedAgentId) return;
browseLoading = true;
browseError = null;
try {
const entries = await fsList(selectedAgentId, path);
// dirs first (α), then files (α)
dirEntries = entries.sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
});
currentPath = path;
// auto-suggest compose file if present in this directory
const compose = entries.find(e => !e.is_dir && isComposeName(e.name));
if (compose) {
filePath = (path === '/' ? '' : path) + '/' + compose.name;
}
} catch (e: unknown) {
browseError = e instanceof Error ? e.message : String(e);
dirEntries = [];
} finally {
browseLoading = false;
}
}
async function selectFile(name: string) {
filePath = entryFullPath(name);
await openFile();
}
function navigateUp() {
if (currentPath === '/') return;
const parent = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
browse(parent);
}
// ── Nouveau dossier ───────────────────────────────────────────────────────
let mkdirInputVisible = $state(false);
let mkdirName = $state('');
let mkdirPending = $state(false);
function showMkdirInput() {
mkdirName = '';
mkdirInputVisible = true;
}
function cancelMkdir() {
mkdirInputVisible = false;
mkdirName = '';
}
async function confirmMkdir() {
const name = mkdirName.trim();
if (!name || !selectedAgentId) return;
const fullPath = currentPath === '/' ? '/' + name : currentPath + '/' + name;
mkdirPending = true;
try {
await fsMkdir(selectedAgentId, fullPath);
mkdirInputVisible = false;
mkdirName = '';
await browse(currentPath);
showToast('Dossier créé', true);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
mkdirPending = false;
}
}
function onMkdirKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') confirmMkdir();
if (e.key === 'Escape') cancelMkdir();
}
async function openFile() {
if (!selectedAgentId || !filePath) return;
isLoading = true;
try {
// Try exact path first, then fallback alternatives
let content: string;
try {
content = await fsRead(selectedAgentId, filePath);
} catch {
// Try yml variant
const altPath = filePath.replace(/\.yaml$/, '.yml').replace(/\.yml$/, '.yaml');
try {
content = await fsRead(selectedAgentId, altPath);
filePath = altPath;
} catch {
throw new Error(`Fichier non trouvé : ${filePath}`);
}
}
setContent(content);
composeOutput = '';
showToast('Fichier chargé', true);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
isLoading = false;
}
}
// ── CodeMirror ────────────────────────────────────────────────────────────
let editorEl = $state<HTMLDivElement | null>(null);
let view: EditorView | null = null;
let isLoading = $state(false);
let actionPending = $state<string | null>(null);
let composeOutput = $state('');
onMount(async () => {
// Load agents
try {
const all = await fetchAgents();
agents = all.filter(a => a.online);
if (agents.length > 0) {
selectedAgentId = agents[0].id;
browse(currentPath);
}
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
}
// Init CodeMirror
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
lineNumbers(),
yaml(),
oneDark,
indentUnit.of(' '),
bracketMatching(),
keymap.of([...defaultKeymap, indentWithTab]),
EditorView.lineWrapping,
EditorView.theme({
'&': { height: '100%', minHeight: '500px' },
'.cm-scroller': { overflow: 'auto', fontFamily: '"JetBrains Mono", "Fira Code", ui-monospace, monospace', fontSize: '13px' },
}),
],
}),
parent: editorEl!,
});
});
onDestroy(() => view?.destroy());
function getContent(): string {
return view?.state.doc.toString() ?? '';
}
function setContent(text: string) {
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
}
// ── Agent change ──────────────────────────────────────────────────────────
function onAgentChange() {
dirEntries = [];
currentPath = '/opt';
filePath = '';
composeOutput = '';
if (selectedAgentId) browse('/opt');
}
// ── Actions ───────────────────────────────────────────────────────────────
async function save() {
if (!selectedAgentId || !filePath) {
showToast('Sélectionnez un agent et un chemin de fichier', false);
return;
}
actionPending = 'save';
try {
await fsWrite(selectedAgentId, filePath, getContent());
showToast('Fichier sauvegardé', true);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
actionPending = null;
}
}
async function runCompose(action: 'up' | 'down' | 'pull') {
if (!selectedAgentId || !filePath) {
showToast('Sélectionnez un agent et un chemin de fichier', false);
return;
}
const dir = filePath.substring(0, filePath.lastIndexOf('/')) || '/';
actionPending = action;
composeOutput = '';
try {
const result = await composeAction(selectedAgentId, dir, action);
composeOutput = result.output;
showToast(result.ok ? `compose ${action} terminé` : `compose ${action} a échoué`, result.ok);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
composeOutput = msg;
showToast(msg, false);
} finally {
actionPending = null;
}
}
</script>
<svelte:head>
<title>Compose — Containarr</title>
</svelte:head>
<!-- Toast -->
{#if toast}
<div class="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm
font-medium shadow-2xl border transition-all
{toast.ok
? 'bg-abyss-800 border-signal-green/30 text-signal-green'
: 'bg-abyss-800 border-signal-red/30 text-signal-red'}">
<span class="w-1.5 h-1.5 rounded-full {toast.ok ? 'bg-signal-green' : 'bg-signal-red'}"></span>
{toast.msg}
</div>
{/if}
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200 flex flex-col">
<!-- Header -->
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3 shrink-0">
<div class="flex items-center gap-2.5">
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
</div>
<div class="ml-auto flex items-center gap-1">
<a href="/" class="nav-btn" title="Dashboard">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</a>
<a href="/admin" class="nav-btn" title="Administration">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
<button onclick={logout} class="nav-btn" title="Déconnexion">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
</svg>
</button>
</div>
</header>
<!-- Page title bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6 py-3 shrink-0">
<div class="max-w-screen-2xl mx-auto flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-sm font-medium text-slate-300">Éditeur Compose</span>
{#if selectedAgent}
<span class="text-xs text-slate-600 ml-1">{selectedAgent.alias || selectedAgent.hostname}</span>
{/if}
</div>
</div>
<!-- Main content: sidebar + editor -->
<div class="flex-1 flex flex-col md:flex-row overflow-hidden max-w-screen-2xl w-full mx-auto p-4 md:p-6 gap-4">
<!-- ── Sidebar ─────────────────────────────────────────────────────────── -->
<aside class="w-full md:w-72 shrink-0 flex flex-col gap-3">
<!-- Agent selector -->
<div class="card p-3 flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider" for="agent-select">Agent</label>
{#if agents.length === 0}
<p class="text-xs text-slate-600 italic">Aucun agent en ligne</p>
{:else}
<select
id="agent-select"
bind:value={selectedAgentId}
onchange={onAgentChange}
class="w-full bg-abyss-800 border border-white/[0.08] rounded-lg px-3 py-2
text-sm text-slate-200 focus:outline-none focus:border-cyan-400/40
focus:ring-1 focus:ring-cyan-400/20 transition-colors"
>
{#each agents as agent (agent.id)}
<option value={agent.id}>{agent.alias || agent.hostname}</option>
{/each}
</select>
{/if}
</div>
<!-- Directory browser -->
<div class="card p-3 flex flex-col gap-2 flex-1 min-h-0">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wider">Navigateur</span>
{#if browseLoading}
<div class="w-3.5 h-3.5 border border-cyan-400/40 border-t-cyan-400 rounded-full animate-spin"></div>
{/if}
</div>
<!-- Breadcrumb -->
<div class="flex flex-wrap items-center gap-0.5 text-xs font-mono">
{#each breadcrumbs() as crumb, i}
{#if i > 1}
<span class="text-slate-700">/</span>
{/if}
<button
onclick={() => browse(crumb.path)}
class="text-cyan-400/70 hover:text-cyan-400 transition-colors px-0.5 rounded
{i === breadcrumbs().length - 1 ? 'text-cyan-400 font-semibold' : ''}"
>
{crumb.label}
</button>
{/each}
</div>
<!-- Up button -->
<button
onclick={navigateUp}
disabled={currentPath === '/'}
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium
bg-white/[0.04] hover:bg-white/[0.08] text-slate-400 border border-white/[0.06]
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
Remonter
</button>
<!-- Nouveau dossier -->
{#if mkdirInputVisible}
<div class="flex items-center gap-1.5">
<input
type="text"
bind:value={mkdirName}
onkeydown={onMkdirKeydown}
placeholder="nom-du-dossier"
disabled={mkdirPending}
class="flex-1 min-w-0 bg-abyss-800 border border-cyan-400/30 rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/60 focus:ring-1 focus:ring-cyan-400/20
disabled:opacity-50 transition-colors"
/>
<button
onclick={confirmMkdir}
disabled={mkdirPending || !mkdirName.trim()}
title="Valider"
class="flex items-center justify-center w-6 h-6 rounded text-signal-green
hover:bg-signal-green/10 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors shrink-0"
>
{#if mkdirPending}
<div class="w-3 h-3 border border-signal-green/40 border-t-signal-green rounded-full animate-spin"></div>
{:else}
{/if}
</button>
<button
onclick={cancelMkdir}
title="Annuler"
class="flex items-center justify-center w-6 h-6 rounded text-slate-500
hover:text-slate-300 hover:bg-white/[0.06] transition-colors shrink-0"
>
</button>
</div>
{:else}
<button
onclick={showMkdirInput}
disabled={!selectedAgentId}
title="Nouveau dossier"
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs
text-slate-400 hover:text-slate-200 hover:bg-white/[0.05]
disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-start"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
Nouveau dossier
</button>
{/if}
<!-- Error -->
{#if browseError}
<p class="text-xs text-signal-red/80 font-mono break-all">{browseError}</p>
{/if}
<!-- File browser list -->
<div class="flex-1 overflow-y-auto min-h-[120px] max-h-72 space-y-0.5">
{#if dirEntries.length === 0 && !browseLoading && !browseError}
<p class="text-xs text-slate-600 italic py-2 px-1">Dossier vide</p>
{/if}
{#each dirEntries as entry (entry.name)}
{#if entry.is_dir}
<!-- Directory: navigate into -->
<button
onclick={() => browse(entryFullPath(entry.name))}
class="flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md text-xs
text-slate-300 hover:text-slate-100 hover:bg-white/[0.06] transition-colors font-mono"
>
<svg class="w-3.5 h-3.5 shrink-0 {entry.has_compose ? 'text-cyan-400' : 'text-cyan-400/50'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<span class="truncate flex-1">{entry.name}</span>
{#if entry.has_compose}
<span class="shrink-0 text-[10px] px-1 py-0.5 rounded bg-cyan-400/15
text-cyan-400 border border-cyan-400/25 leading-none">compose</span>
{/if}
</button>
{:else}
<!-- File: select and open -->
<button
onclick={() => selectFile(entry.name)}
disabled={isLoading}
class="flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md text-xs
transition-colors font-mono disabled:opacity-40
{isComposeName(entry.name)
? 'text-cyan-300 hover:text-cyan-100 hover:bg-cyan-400/[0.06]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.06]'}"
>
<svg class="w-3.5 h-3.5 shrink-0 {isComposeName(entry.name) ? 'text-cyan-400' : 'text-slate-600'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate flex-1">{entry.name}</span>
</button>
{/if}
{/each}
</div>
<!-- Open button -->
<button
onclick={openFile}
disabled={!selectedAgentId || !filePath || isLoading}
class="flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium
bg-cyan-400/10 hover:bg-cyan-400/20 text-cyan-400 border border-cyan-400/25
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
{#if isLoading}
<div class="w-3 h-3 border border-cyan-400/40 border-t-cyan-400 rounded-full animate-spin"></div>
Chargement…
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Ouvrir
{/if}
</button>
</div>
<!-- File path input -->
<div class="card p-3 flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider" for="file-path">Chemin du fichier</label>
<input
id="file-path"
type="text"
bind:value={filePath}
placeholder="/opt/stacks/nginx/docker-compose.yaml"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-lg px-3 py-2
text-xs font-mono text-slate-200 placeholder-slate-700
focus:outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20
transition-colors"
/>
</div>
</aside>
<!-- ── Editor area ─────────────────────────────────────────────────────── -->
<div class="flex-1 flex flex-col gap-3 min-w-0">
<!-- CodeMirror editor -->
<div class="card flex-1 overflow-hidden flex flex-col min-h-[500px]">
<div
bind:this={editorEl}
class="flex-1 overflow-hidden [&_.cm-editor]:h-full [&_.cm-editor]:outline-none
[&_.cm-focused]:outline-none"
></div>
</div>
<!-- Action bar -->
<div class="flex flex-wrap items-center gap-2">
<!-- Save -->
<button
onclick={save}
disabled={actionPending !== null}
class="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
bg-signal-cyan/10 hover:bg-signal-cyan/20 text-signal-cyan border border-signal-cyan/25
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{#if actionPending === 'save'}
<div class="w-3.5 h-3.5 border border-signal-cyan/40 border-t-signal-cyan rounded-full animate-spin"></div>
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
{/if}
Sauvegarder
</button>
<!-- Up -->
<button
onclick={() => runCompose('up')}
disabled={actionPending !== null}
class="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
bg-signal-green/10 hover:bg-signal-green/20 text-signal-green border border-signal-green/25
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{#if actionPending === 'up'}
<div class="w-3.5 h-3.5 border border-signal-green/40 border-t-signal-green rounded-full animate-spin"></div>
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
{/if}
Up
</button>
<!-- Down -->
<button
onclick={() => runCompose('down')}
disabled={actionPending !== null}
class="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
bg-white/[0.05] hover:bg-signal-red/10 text-signal-red border border-signal-red/20
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{#if actionPending === 'down'}
<div class="w-3.5 h-3.5 border border-signal-red/40 border-t-signal-red rounded-full animate-spin"></div>
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{/if}
Down
</button>
<!-- Pull -->
<button
onclick={() => runCompose('pull')}
disabled={actionPending !== null}
class="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
bg-white/[0.05] hover:bg-white/[0.09] text-slate-400 border border-white/[0.08]
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{#if actionPending === 'pull'}
<div class="w-3.5 h-3.5 border border-slate-500/40 border-t-slate-400 rounded-full animate-spin"></div>
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{/if}
Pull
</button>
{#if actionPending && actionPending !== 'save'}
<span class="text-xs text-slate-500 ml-1">Exécution de compose {actionPending}</span>
{/if}
</div>
<!-- Output area -->
{#if composeOutput}
<div class="card p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wider">Sortie</span>
<button
onclick={() => (composeOutput = '')}
class="text-xs text-slate-600 hover:text-slate-400 transition-colors"
>
Effacer
</button>
</div>
<pre class="text-xs font-mono text-slate-300 overflow-y-auto max-h-48 leading-relaxed
bg-abyss-950 rounded-lg p-3 border border-white/[0.04] whitespace-pre-wrap break-all">{composeOutput}</pre>
</div>
{/if}
</div>
</div>
</div>