feat: add auto update
This commit is contained in:
24
agent/Cargo.lock
generated
24
agent/Cargo.lock
generated
@ -150,6 +150,7 @@ dependencies = [
|
|||||||
"hyperlocal",
|
"hyperlocal",
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -1191,24 +1192,13 @@ version = "0.23.40"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
@ -1585,16 +1575,6 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@ -1640,10 +1620,8 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"prost",
|
"prost",
|
||||||
"rustls-pemfile",
|
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
|
|||||||
@ -10,9 +10,9 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tonic = { version = "0.12", features = ["tls"] }
|
tonic = { version = "0.12" }
|
||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
bollard = "0.17"
|
bollard = { version = "0.17", default-features = false, features = ["rustls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@ -3,34 +3,30 @@ FROM rust:slim AS builder
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
protobuf-compiler \
|
protobuf-compiler \
|
||||||
pkg-config \
|
musl-tools \
|
||||||
libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN rustup target add x86_64-unknown-linux-musl
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy proto first (referenced by build.rs as ../proto/...)
|
|
||||||
COPY proto/ ./proto/
|
COPY proto/ ./proto/
|
||||||
|
|
||||||
# Cache dependencies: copy manifests, build with dummy main, then discard.
|
|
||||||
COPY agent/Cargo.toml agent/Cargo.lock ./agent/
|
COPY agent/Cargo.toml agent/Cargo.lock ./agent/
|
||||||
COPY agent/build.rs ./agent/
|
COPY agent/build.rs ./agent/
|
||||||
WORKDIR /src/agent
|
WORKDIR /src/agent
|
||||||
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||||
cargo build --release && \
|
cargo build --release --target x86_64-unknown-linux-musl && \
|
||||||
rm -rf src
|
rm -rf src
|
||||||
|
|
||||||
# Full build.
|
|
||||||
COPY agent/src ./src
|
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 ───────────────────────────────────────────────────────────────────
|
# ── Runtime ───────────────────────────────────────────────────────────────────
|
||||||
FROM debian:bookworm-slim
|
FROM alpine:3.19
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apk add --no-cache ca-certificates docker-cli
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
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"]
|
ENTRYPOINT ["containarr-agent"]
|
||||||
|
|||||||
@ -43,7 +43,7 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DockerClient {
|
pub struct DockerClient {
|
||||||
inner: Docker,
|
pub inner: Docker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DockerClient {
|
impl DockerClient {
|
||||||
|
|||||||
@ -5,15 +5,23 @@ pub mod proto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
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 docker::{ContainerBackend, DockerClient};
|
||||||
use futures_util::StreamExt as _;
|
use futures_util::StreamExt as _;
|
||||||
use proto::{
|
use proto::{
|
||||||
agent_gateway_client::AgentGatewayClient,
|
agent_gateway_client::AgentGatewayClient,
|
||||||
agent_message, server_message,
|
agent_message, server_message,
|
||||||
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
|
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
|
||||||
ImageInfo, VolumeInfo, NetworkInfo,
|
FileResult, ImageInfo, VolumeInfo, NetworkInfo,
|
||||||
|
UpdateCheckResult,
|
||||||
};
|
};
|
||||||
|
use serde::Serialize;
|
||||||
use std::{collections::HashMap, env, time::Duration};
|
use std::{collections::HashMap, env, time::Duration};
|
||||||
use tokio::{sync::mpsc, task::JoinHandle, time};
|
use tokio::{sync::mpsc, task::JoinHandle, time};
|
||||||
use tonic::Request;
|
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);
|
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 => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,6 +372,281 @@ pub(crate) fn unix_now() -> i64 {
|
|||||||
.as_secs() as 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 ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -457,4 +899,258 @@ mod tests {
|
|||||||
assert_eq!(chunk.stream, "stdout");
|
assert_eq!(chunk.stream, "stdout");
|
||||||
assert_eq!(chunk.data, b"hello");
|
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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,12 +75,30 @@ message LogChunk {
|
|||||||
int64 timestamp = 4;
|
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 {
|
message AgentMessage {
|
||||||
oneof payload {
|
oneof payload {
|
||||||
AgentHandshake handshake = 1;
|
AgentHandshake handshake = 1;
|
||||||
ContainerSnapshot snapshot = 2;
|
ContainerSnapshot snapshot = 2;
|
||||||
CommandResult result = 3;
|
CommandResult result = 3;
|
||||||
LogChunk log_chunk = 4;
|
LogChunk log_chunk = 4;
|
||||||
|
FileResult file_result = 5;
|
||||||
|
UpdateCheckResult update_check_result = 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,10 +125,54 @@ message StreamLogsCommand {
|
|||||||
int32 tail = 4;
|
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 {
|
message ServerMessage {
|
||||||
oneof payload {
|
oneof payload {
|
||||||
ContainerCommand container_cmd = 1;
|
ContainerCommand container_cmd = 1;
|
||||||
StreamLogsCommand stream_logs = 2;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/containarr/server/internal/broker"
|
"github.com/containarr/server/internal/broker"
|
||||||
grpcgateway "github.com/containarr/server/internal/grpc"
|
grpcgateway "github.com/containarr/server/internal/grpc"
|
||||||
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
"github.com/containarr/server/internal/scheduler"
|
||||||
"github.com/containarr/server/internal/store"
|
"github.com/containarr/server/internal/store"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
@ -39,6 +40,14 @@ func main() {
|
|||||||
reg := grpcgateway.NewRegistry()
|
reg := grpcgateway.NewRegistry()
|
||||||
brk := broker.New()
|
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.
|
// gRPC server.
|
||||||
gw := grpcgateway.NewGateway(db, reg, brk)
|
gw := grpcgateway.NewGateway(db, reg, brk)
|
||||||
grpcServer := grpc.NewServer()
|
grpcServer := grpc.NewServer()
|
||||||
@ -76,6 +85,7 @@ func main() {
|
|||||||
<-quit
|
<-quit
|
||||||
|
|
||||||
slog.Info("shutting down")
|
slog.Info("shutting down")
|
||||||
|
rootCancel()
|
||||||
grpcServer.GracefulStop()
|
grpcServer.GracefulStop()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -801,3 +802,246 @@ func TestContainerAction_Success(t *testing.T) {
|
|||||||
t.Error("expected command_id in response")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -185,6 +186,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Alias string `json:"alias"`
|
Alias string `json:"alias"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@ -197,8 +199,9 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
AgentID: agent.ID,
|
AgentID: agent.ID,
|
||||||
Hostname: agent.Hostname,
|
Hostname: agent.Hostname,
|
||||||
Alias: agent.Alias,
|
Alias: agent.Alias,
|
||||||
|
IPAddress: agent.IPAddress,
|
||||||
ID: img.GetId(),
|
ID: img.GetId(),
|
||||||
Tags: img.GetTags(),
|
Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(),
|
||||||
Size: img.GetSize(),
|
Size: img.GetSize(),
|
||||||
CreatedAt: img.GetCreatedAt(),
|
CreatedAt: img.GetCreatedAt(),
|
||||||
})
|
})
|
||||||
@ -214,6 +217,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
|||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Alias string `json:"alias"`
|
Alias string `json:"alias"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Driver string `json:"driver"`
|
Driver string `json:"driver"`
|
||||||
Mountpoint string `json:"mountpoint"`
|
Mountpoint string `json:"mountpoint"`
|
||||||
@ -225,6 +229,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
|||||||
AgentID: agent.ID,
|
AgentID: agent.ID,
|
||||||
Hostname: agent.Hostname,
|
Hostname: agent.Hostname,
|
||||||
Alias: agent.Alias,
|
Alias: agent.Alias,
|
||||||
|
IPAddress: agent.IPAddress,
|
||||||
Name: vol.GetName(),
|
Name: vol.GetName(),
|
||||||
Driver: vol.GetDriver(),
|
Driver: vol.GetDriver(),
|
||||||
Mountpoint: vol.GetMountpoint(),
|
Mountpoint: vol.GetMountpoint(),
|
||||||
@ -238,25 +243,27 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||||
type networkDTO struct {
|
type networkDTO struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Alias string `json:"alias"`
|
Alias string `json:"alias"`
|
||||||
ID string `json:"id"`
|
IPAddress string `json:"ip_address"`
|
||||||
Name string `json:"name"`
|
ID string `json:"id"`
|
||||||
Driver string `json:"driver"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope"`
|
Driver string `json:"driver"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
}
|
}
|
||||||
var out []networkDTO
|
var out []networkDTO
|
||||||
for _, agent := range h.registry.List() {
|
for _, agent := range h.registry.List() {
|
||||||
for _, net := range agent.Networks {
|
for _, net := range agent.Networks {
|
||||||
out = append(out, networkDTO{
|
out = append(out, networkDTO{
|
||||||
AgentID: agent.ID,
|
AgentID: agent.ID,
|
||||||
Hostname: agent.Hostname,
|
Hostname: agent.Hostname,
|
||||||
Alias: agent.Alias,
|
Alias: agent.Alias,
|
||||||
ID: net.GetId(),
|
IPAddress: agent.IPAddress,
|
||||||
Name: net.GetName(),
|
ID: net.GetId(),
|
||||||
Driver: net.GetDriver(),
|
Name: net.GetName(),
|
||||||
Scope: net.GetScope(),
|
Driver: net.GetDriver(),
|
||||||
|
Scope: net.GetScope(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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) {
|
func jsonOK(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|||||||
@ -52,6 +52,14 @@ func NewRouter(h *Handler) http.Handler {
|
|||||||
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||||
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
|
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
|
||||||
r.Get("/events", h.EventsWS)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/containarr/server/internal/broker"
|
"github.com/containarr/server/internal/broker"
|
||||||
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
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:
|
case *agentv1.AgentMessage_Result:
|
||||||
|
res := p.Result
|
||||||
g.broker.Publish(broker.Event{
|
g.broker.Publish(broker.Event{
|
||||||
Type: "command.result",
|
Type: "command.result",
|
||||||
AgentID: agentID,
|
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:
|
case *agentv1.AgentMessage_LogChunk:
|
||||||
g.broker.Publish(broker.Event{
|
g.broker.Publish(broker.Event{
|
||||||
@ -137,6 +148,29 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error {
|
|||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
Payload: p.LogChunk,
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package grpc
|
package grpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -20,7 +22,10 @@ type AgentState struct {
|
|||||||
Volumes []*agentv1.VolumeInfo
|
Volumes []*agentv1.VolumeInfo
|
||||||
Networks []*agentv1.NetworkInfo
|
Networks []*agentv1.NetworkInfo
|
||||||
|
|
||||||
cmdCh chan *agentv1.ServerMessage
|
cmdCh chan *agentv1.ServerMessage
|
||||||
|
pendingFiles map[string]chan *agentv1.FileResult
|
||||||
|
pendingUpdates map[string]string // commandID → containerID
|
||||||
|
pendingMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
@ -34,13 +39,15 @@ func NewRegistry() *Registry {
|
|||||||
|
|
||||||
func (r *Registry) Register(id, hostname, alias, ipAddress, arch, os string) *AgentState {
|
func (r *Registry) Register(id, hostname, alias, ipAddress, arch, os string) *AgentState {
|
||||||
state := &AgentState{
|
state := &AgentState{
|
||||||
ID: id,
|
ID: id,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Alias: alias,
|
Alias: alias,
|
||||||
IPAddress: ipAddress,
|
IPAddress: ipAddress,
|
||||||
Arch: arch,
|
Arch: arch,
|
||||||
OS: os,
|
OS: os,
|
||||||
cmdCh: make(chan *agentv1.ServerMessage, 16),
|
cmdCh: make(chan *agentv1.ServerMessage, 16),
|
||||||
|
pendingFiles: make(map[string]chan *agentv1.FileResult),
|
||||||
|
pendingUpdates: make(map[string]string),
|
||||||
}
|
}
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.agents[id] = state
|
r.agents[id] = state
|
||||||
@ -118,3 +125,113 @@ func (r *Registry) Send(agentID string, msg *agentv1.ServerMessage) bool {
|
|||||||
return false
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package grpc
|
package grpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -153,3 +154,112 @@ func TestSend_FullChannel(t *testing.T) {
|
|||||||
t.Error("Send should return false when channel is full")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
39
server/internal/scheduler/adapter.go
Normal file
39
server/internal/scheduler/adapter.go
Normal 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)
|
||||||
|
}
|
||||||
86
server/internal/scheduler/scheduler.go
Normal file
86
server/internal/scheduler/scheduler.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,16 @@ func (s *Store) migrate() error {
|
|||||||
last_seen_at DATETIME,
|
last_seen_at DATETIME,
|
||||||
online INTEGER NOT NULL DEFAULT 0
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -186,3 +196,106 @@ func boolToInt(b bool) int {
|
|||||||
}
|
}
|
||||||
return 0
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestStore(t *testing.T) *Store {
|
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)
|
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
153
web/package-lock.json
generated
@ -7,6 +7,14 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "containarr-web",
|
"name": "containarr-web",
|
||||||
"version": "0.1.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
@ -1680,6 +1688,92 @@
|
|||||||
"specificity": "bin/cli.js"
|
"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": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
@ -2351,6 +2445,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -3956,6 +4091,12 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -7323,6 +7464,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"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": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
@ -29,5 +29,13 @@
|
|||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
"workbox-window": "^7.3.0"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export interface ImageEntry {
|
|||||||
agent_id: string;
|
agent_id: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
|
ip_address: string;
|
||||||
id: string;
|
id: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
size: number;
|
size: number;
|
||||||
@ -132,6 +133,7 @@ export interface VolumeEntry {
|
|||||||
agent_id: string;
|
agent_id: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
|
ip_address: string;
|
||||||
name: string;
|
name: string;
|
||||||
driver: string;
|
driver: string;
|
||||||
mountpoint: string;
|
mountpoint: string;
|
||||||
@ -141,6 +143,7 @@ export interface NetworkEntry {
|
|||||||
agent_id: string;
|
agent_id: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
|
ip_address: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
driver: string;
|
driver: string;
|
||||||
@ -201,6 +204,92 @@ export function connectLogs(
|
|||||||
return () => ws.close();
|
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(
|
export function connectEvents(
|
||||||
onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void
|
onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void
|
||||||
): () => void {
|
): () => void {
|
||||||
|
|||||||
@ -13,11 +13,15 @@
|
|||||||
fetchNetworks,
|
fetchNetworks,
|
||||||
containerAction,
|
containerAction,
|
||||||
connectEvents,
|
connectEvents,
|
||||||
|
getAutoUpdatePolicy,
|
||||||
|
setAutoUpdatePolicy,
|
||||||
|
updateNow,
|
||||||
type ContainerEntry,
|
type ContainerEntry,
|
||||||
type ContainerPort,
|
type ContainerPort,
|
||||||
type ImageEntry,
|
type ImageEntry,
|
||||||
type VolumeEntry,
|
type VolumeEntry,
|
||||||
type NetworkEntry,
|
type NetworkEntry,
|
||||||
|
type AutoUpdatePolicy,
|
||||||
} from "$lib/api";
|
} from "$lib/api";
|
||||||
import { clearToken } from "$lib/auth";
|
import { clearToken } from "$lib/auth";
|
||||||
import LogModal from "$lib/LogModal.svelte";
|
import LogModal from "$lib/LogModal.svelte";
|
||||||
@ -144,6 +148,19 @@
|
|||||||
loadError = null;
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
entries = await fetchContainers() ?? [];
|
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) {
|
} catch (e: unknown) {
|
||||||
loadError = e instanceof Error ? e.message : String(e);
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
entries = [];
|
entries = [];
|
||||||
@ -213,11 +230,170 @@
|
|||||||
setTimeout(() => (toast = null), 3000);
|
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 ────────────────────────────────────────────────────────
|
// ── Toggle helpers ────────────────────────────────────────────────────────
|
||||||
function toggleSection(agentId: string) { collapsed[agentId] = !(collapsed[agentId] ?? true); }
|
function toggleSection(agentId: string) { collapsed = { ...collapsed, [agentId]: !(collapsed[agentId] ?? true) }; }
|
||||||
function toggleImages(agentId: string) { collapsedImages[agentId] = !(collapsedImages[agentId] ?? true); }
|
function toggleImages(agentId: string) { collapsedImages = { ...collapsedImages, [agentId]: !(collapsedImages[agentId] ?? true) }; }
|
||||||
function toggleVolumes(agentId: string) { collapsedVolumes[agentId] = !(collapsedVolumes[agentId] ?? true); }
|
function toggleVolumes(agentId: string) { collapsedVolumes = { ...collapsedVolumes, [agentId]: !(collapsedVolumes[agentId] ?? true) }; }
|
||||||
function toggleNetworks(agentId: string) { collapsedNetworks[agentId] = !(collapsedNetworks[agentId] ?? true); }
|
function toggleNetworks(agentId: string) { collapsedNetworks = { ...collapsedNetworks, [agentId]: !(collapsedNetworks[agentId] ?? true) }; }
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@ -225,6 +401,18 @@
|
|||||||
disconnect = connectEvents((evt) => {
|
disconnect = connectEvents((evt) => {
|
||||||
if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") {
|
if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") {
|
||||||
if (activeTab === "containers") load();
|
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 (evt.type === "resources.updated") {
|
||||||
if (activeTab === "images") loadImages();
|
if (activeTab === "images") loadImages();
|
||||||
@ -255,7 +443,7 @@
|
|||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
}).sort((a, b) => a.host_port - b.host_port);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateDotClass(state: string) {
|
function stateDotClass(state: string) {
|
||||||
@ -320,7 +508,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
||||||
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
|
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
|
||||||
@ -334,18 +522,24 @@
|
|||||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
{entries.length} containers · {Object.keys(byAgent).length} hosts
|
{entries.length} containers · {Object.keys(byAgent).length} hosts
|
||||||
</span>
|
</span>
|
||||||
{:else if activeTab === "images" && images !== null}
|
{:else if activeTab === "images"}
|
||||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
{#if images !== null}
|
||||||
{images.length} images · {Object.keys(byAgentImages).length} hosts
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
</span>
|
{images.length} images · {Object.keys(byAgentImages).length} hosts
|
||||||
{:else if activeTab === "volumes" && volumes !== null}
|
</span>
|
||||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
{/if}
|
||||||
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
|
{:else if activeTab === "volumes"}
|
||||||
</span>
|
{#if volumes !== null}
|
||||||
{:else if activeTab === "networks" && networks !== null}
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
|
||||||
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
|
</span>
|
||||||
</span>
|
{/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}
|
||||||
|
|
||||||
{#if installPrompt}
|
{#if installPrompt}
|
||||||
@ -357,6 +551,13 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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">
|
<a href="/admin" class="nav-btn" title="Administration">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"
|
<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">
|
<main class="p-4 md:p-6 max-w-7xl mx-auto">
|
||||||
|
|
||||||
{#key activeTab}
|
|
||||||
<!-- ═══════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════
|
||||||
CONTAINERS TAB
|
CONTAINERS TAB
|
||||||
════════════════════════════════════════════════════ -->
|
════════════════════════════════════════════════════ -->
|
||||||
@ -434,7 +634,6 @@
|
|||||||
{#each sortedAgents as [agentId, containers]}
|
{#each sortedAgents as [agentId, containers]}
|
||||||
{#if containers.length > 0}
|
{#if containers.length > 0}
|
||||||
{@const first = containers[0]}
|
{@const first = containers[0]}
|
||||||
{@const isCollapsed = collapsed[agentId] ?? true}
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -444,7 +643,7 @@
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
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"
|
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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
@ -465,7 +664,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !isCollapsed}
|
{#if collapsed[agentId] === false}
|
||||||
<!-- Desktop table -->
|
<!-- Desktop table -->
|
||||||
<div class="hidden md:block card overflow-hidden">
|
<div class="hidden md:block card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
@ -480,7 +679,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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
|
<tr class="border-b border-white/[0.04] last:border-0
|
||||||
hover:bg-white/[0.025] transition-colors group">
|
hover:bg-white/[0.025] transition-colors group">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@ -508,7 +707,7 @@
|
|||||||
{container.compose_project || "—"}
|
{container.compose_project || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<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",
|
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||||
loading: false,
|
loading: false,
|
||||||
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||||
@ -524,6 +723,7 @@
|
|||||||
loading: actionPending === container.id,
|
loading: actionPending === container.id,
|
||||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||||
{/if}
|
{/if}
|
||||||
|
{@render AutoUpdateBtn(agent_id, container.id)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -534,7 +734,7 @@
|
|||||||
|
|
||||||
<!-- Mobile cards -->
|
<!-- Mobile cards -->
|
||||||
<div class="md:hidden space-y-2">
|
<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="card p-4">
|
||||||
<div class="flex items-start justify-between gap-2 mb-2">
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
@ -554,7 +754,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap relative">
|
||||||
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||||
loading: false,
|
loading: false,
|
||||||
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||||
@ -570,6 +770,7 @@
|
|||||||
loading: actionPending === container.id,
|
loading: actionPending === container.id,
|
||||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||||
{/if}
|
{/if}
|
||||||
|
{@render AutoUpdateBtn(agent_id, container.id)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@ -609,7 +810,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each sortedAgentImages as [agentId, agentImages]}
|
{#each sortedAgentImages as [agentId, agentImages]}
|
||||||
{@const first = agentImages[0]}
|
{@const first = agentImages[0]}
|
||||||
{@const isCollapsed = collapsedImages[agentId] ?? true}
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -619,7 +819,7 @@
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
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"
|
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" />
|
<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}
|
{#if first.alias}
|
||||||
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
{/if}
|
{/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">
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
|
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !isCollapsed}
|
{#if collapsedImages[agentId] === false}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@ -648,10 +852,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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">
|
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{#if img.tags.length > 0}
|
{#if img.tags?.length > 0}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each img.tags as tag}
|
{#each img.tags as tag}
|
||||||
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||||
@ -706,7 +910,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each sortedAgentVolumes as [agentId, agentVolumes]}
|
{#each sortedAgentVolumes as [agentId, agentVolumes]}
|
||||||
{@const first = agentVolumes[0]}
|
{@const first = agentVolumes[0]}
|
||||||
{@const isCollapsed = collapsedVolumes[agentId] ?? true}
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -716,7 +919,7 @@
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
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"
|
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" />
|
<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}
|
{#if first.alias}
|
||||||
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
{/if}
|
{/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">
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
|
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !isCollapsed}
|
{#if collapsedVolumes[agentId] === false}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@ -744,7 +951,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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">
|
<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 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>
|
<td class="px-4 py-3 text-xs text-slate-400">{vol.driver}</td>
|
||||||
@ -789,7 +996,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each sortedAgentNetworks as [agentId, agentNetworks]}
|
{#each sortedAgentNetworks as [agentId, agentNetworks]}
|
||||||
{@const first = agentNetworks[0]}
|
{@const first = agentNetworks[0]}
|
||||||
{@const isCollapsed = collapsedNetworks[agentId] ?? true}
|
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -799,7 +1005,7 @@
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
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"
|
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" />
|
<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}
|
{#if first.alias}
|
||||||
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
{/if}
|
{/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">
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
|
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if !isCollapsed}
|
{#if collapsedNetworks[agentId] === false}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@ -828,7 +1038,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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">
|
<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 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>
|
<td class="px-4 py-3 text-xs text-slate-400">{net.driver}</td>
|
||||||
@ -852,9 +1062,100 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
|
||||||
|
|
||||||
</main>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#snippet ActionBtn({ label, variant, loading, onclick }: {
|
{#snippet ActionBtn({ label, variant, loading, onclick }: {
|
||||||
@ -876,3 +1177,33 @@
|
|||||||
{loading ? "…" : label}
|
{loading ? "…" : label}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/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}
|
||||||
|
|||||||
649
web/src/routes/compose/+page.svelte
Normal file
649
web/src/routes/compose/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user