···4848 .merge_router(&router);
49495050 // Ensure custom core schemas are registered for SSE documentation.
5151- crate::core::LuminaryStateList::to_schema(&mut openapi.components);
5151+ crate::core::LuminaryProjectList::to_schema(&mut openapi.components);
5252 crate::logging::LogMessage::to_schema(&mut openapi.components);
53535454 let location = concat!(env!("CARGO_MANIFEST_DIR"), "/../panel/static/openapi.json");
+34-17
packages/node/src/api/project.rs
···16161717/// Returns the router for compose related endpoints.
1818pub fn router() -> Router {
1919- return Router::new().get(get_project).put(put_compose);
1919+ return Router::new().get(get_project_endpoint).patch(put_compose);
2020}
21212222/// Retrieves the compose file for a given project.
2323#[endpoint]
2424-pub async fn get_project(
2424+pub async fn get_project_endpoint(
2525 project: PathParam<String>,
2626 depot: &mut Depot,
2727) -> LuminaryResponse<LuminaryProjectWithCompose> {
2828 let engine = obtain!(depot, LuminaryEngine);
29293030- let name = project.into_inner();
3131- let compose = engine.get_compose(&name).await?;
3232- let project = engine.get_project(&name).await?;
3030+ return get_project(engine, &project).await;
3131+}
3232+3333+/// Retrieves the compose file and information for a given project.
3434+async fn get_project(engine: &LuminaryEngine, name: &String) -> LuminaryResponse<LuminaryProjectWithCompose> {
3535+ let compose = engine.get_compose(name).await?;
3636+ let project = engine.get_project(name).await?;
33373438 return Ok(LuminaryProjectWithCompose { project, compose }.into());
3539}
36404141+/// A project with its compose file. Used for returning project information in a single request.
3742#[derive(Debug, Clone, Serialize, ToSchema)]
3843struct LuminaryProjectWithCompose {
3944 #[serde(flatten)]
···4348 compose: String,
4449}
45504646-/// Updates the compose file for a given project.
5151+/// Perform a configurable change to a project.
4752#[endpoint]
4853pub async fn put_compose(
4954 project: PathParam<String>,
5050- payload: JsonBody<ComposeWithName>,
5555+ payload: JsonBody<LuminaryProjectPatch>,
5156 depot: &mut Depot,
5252-) -> LuminaryResponse<()> {
5757+) -> LuminaryResponse<LuminaryProjectWithCompose> {
5358 let engine = obtain!(depot, LuminaryEngine);
5959+ let mut changed = false;
54605555- engine
5656- .put_compose(&project.into_inner(), &payload.compose)
5757- .await?;
6161+ if let Some(compose) = &payload.compose {
6262+ engine.put_compose(&project, &compose).await?;
6363+ changed = true;
6464+ }
6565+6666+ if let Some(from) = &payload.from {
6767+ engine.rename_project(from, &project).await?;
6868+ changed = true;
6969+ }
7070+7171+ if changed {
7272+ engine.refresh().await?;
7373+ }
58745959- return Ok(().into());
7575+ return get_project(engine, &project).await;
6076}
61777878+/// The payload for updating a project. Allows for multiple updates at once.
6279#[derive(Debug, Clone, Deserialize, ToSchema)]
6363-struct ComposeWithName {
6464- /// The new name for the project.
6565- name: String,
8080+struct LuminaryProjectPatch {
8181+ /// If [Some], the new compose file for this project. If [None], the compose file will not be updated.
8282+ compose: Option<String>,
66836767- /// The compose file for this project.
6868- compose: String,
8484+ /// If [Some], renames the project with the given name. If [None], no rename will take place.
8585+ from: Option<String>,
6986}
+8-3
packages/node/src/core/action.rs
···99 #[wrap_err("Failed to set action for service")]
1010 async fn set_action(&self, project: &str, service: Option<&str>, action: LuminaryAction) -> Result<()> {
1111 // Get list of targets to update
1212- let mut project_list = self.state.write().await;
1212+ let mut project_list = self.list.write().await;
1313 let targets = match project_list.0.get_mut(project) {
1414 None => bail!("Unknown project '{}'", project),
1515 Some(service_list) => match service {
···8282 /// Stops the given project and optionally, a specific service within that project.
8383 #[wrap_err("Failed to stop project/service")]
8484 pub async fn stop(&self, project: &str, service: Option<&str>) -> Result<()> {
8585- self.run(LuminaryAction::Stopping, project, service, vec!["down"])
8686- .await?;
8585+ self.run(
8686+ LuminaryAction::Stopping,
8787+ project,
8888+ service,
8989+ vec!["down", "--remove-orphans"],
9090+ )
9191+ .await?;
8792 Ok(())
8893 }
8994
-41
packages/node/src/core/compose.rs
···11-use std::path::{Path, PathBuf};
22-33-use eyre::{Context, Ok, Result};
44-use luminary_macros::wrap_err;
55-use tokio::fs::read_to_string;
66-77-use crate::core::{COMPOSE_FILENAME, LuminaryEngine};
88-99-impl LuminaryEngine {
1010- fn compose_path(&self, project: &str) -> Result<PathBuf> {
1111- let path = Path::new(&self.configuration.project_directory)
1212- .join(project)
1313- .join(COMPOSE_FILENAME);
1414-1515- if !path.exists() {
1616- eyre::bail!("Project '{}' does not exist", project);
1717- }
1818-1919- return Ok(path);
2020- }
2121-2222- /// Retrieves the docker compose file for a given project.
2323- #[wrap_err("Failed to retrieve compose file")]
2424- pub async fn get_compose(&self, project: &str) -> Result<String> {
2525- let path = self.compose_path(project)?;
2626-2727- return Ok(read_to_string(path).await.wrap_err("Failed to read file")?);
2828- }
2929-3030- /// Updates the docker compose file for a given project.
3131- #[wrap_err("Failed to update compose file")]
3232- pub async fn put_compose(&self, project: &str, compose: &str) -> Result<()> {
3333- let path = self.compose_path(project)?;
3434-3535- tokio::fs::write(path, compose)
3636- .await
3737- .wrap_err("Failed to write file")?;
3838-3939- return Ok(());
4040- }
4141-}
+8-8
packages/node/src/core/engine.rs
···16161717use crate::{
1818 configuration::LuminaryConfiguration,
1919- core::{LuminaryStateList, ProjectLogChannel},
1919+ core::{LuminaryProjectList, ProjectLogChannel},
2020};
21212222/// The core engine of the Luminary application, containing shared state and configuration.
2323#[derive(Debug, Clone)]
2424pub struct LuminaryEngine {
2525 /// The canonical list of services for this instance of [LuminaryEngine].
2626- pub(super) state: Arc<RwLock<LuminaryStateList>>,
2626+ pub(super) list: Arc<RwLock<LuminaryProjectList>>,
27272828 /// A channel for broadcasting state changes to listeners.
2929- pub(super) state_channel: broadcast::Sender<LuminaryStateList>,
2929+ pub(super) list_channel: broadcast::Sender<LuminaryProjectList>,
30303131 /// A map of log channels for each project, keyed by project name. This is lazily populated when clients subscribe to logs for a project.
3232 pub(super) log_channels: Arc<Mutex<HashMap<String, ProjectLogChannel>>>,
···4545 let docker = Docker::connect_with_defaults().wrap_err("Failed to connect to docker engine.")?;
46464747 let instance = Self {
4848- state: Arc::new(RwLock::new(LuminaryStateList::new())),
4848+ list: Arc::new(RwLock::new(LuminaryProjectList::new())),
4949 log_channels: Arc::new(Mutex::new(HashMap::new())),
5050- state_channel: broadcast::channel(64).0,
5050+ list_channel: broadcast::channel(64).0,
5151 configuration,
5252 docker,
5353 };
···5959 }
60606161 /// Broadcasts the given state change to all listeners.
6262- pub(super) async fn broadcast(&self, list: LuminaryStateList) {
6363- if self.state_channel.receiver_count() > 0 {
6262+ pub(super) async fn broadcast(&self, list: LuminaryProjectList) {
6363+ if self.list_channel.receiver_count() > 0 {
6464 // This will only error if there are no receivers, so we can safely ignore it.
6565- let _ = self.state_channel.send(list.clone());
6565+ let _ = self.list_channel.send(list.clone());
6666 }
6767 }
6868
+4-1
packages/node/src/core/mod.rs
···11//! The core library for Luminary, containing all logic related to managing projects and interacting with the Docker engine.
2233pub const COMPOSE_FILENAME: &str = "compose.yml";
44+pub const COMPOSE_PROJECT_DIR_LABEL: &str = "com.docker.compose.project.working_dir";
55+pub const COMPOSE_PROJECT_LABEL: &str = "com.docker.compose.project";
66+pub const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service";
4758mod action;
66-mod compose;
79mod engine;
810mod logs;
911mod model;
1212+mod project;
1013mod state;
11141215pub use engine::LuminaryEngine;
+7-1
packages/node/src/core/model.rs
···1313/// A collection of Luminary projects, keyed by project name.
1414#[hashmap_schema]
1515#[derive(Debug, Clone, PartialEq, Serialize)]
1616-pub struct LuminaryStateList<String, LuminaryProject>;
1616+pub struct LuminaryProjectList<String, LuminaryProject>;
17171818/// A collection of Luminary services, keyed by service name.
1919#[hashmap_schema]
···8686 /// The identifier of this service
8787 #[serde(flatten)]
8888 pub identifier: LuminaryIdentifier,
8989+8990 /// The current status of this service
9091 pub status: LuminaryStatus,
9292+9193 /// The current action being performed on this service
9294 pub action: LuminaryAction,
9595+9696+ /// Wether this service is no longer defined in its compose file. It will be removed when it goes down.
9797+ pub orphan: bool,
9898+9399 /// Wether this service is stale, meaning that it was removed in the recent refresh.
94100 #[serde(skip)]
95101 pub stale: bool,
+92
packages/node/src/core/project.rs
···11+use std::path::{self, Path, PathBuf};
22+33+use eyre::{Context, Ok, Result};
44+use futures_util::StreamExt;
55+use luminary_macros::wrap_err;
66+use tokio::fs::{self, read_to_string};
77+88+use crate::core::{COMPOSE_FILENAME, LuminaryEngine, LuminaryStatus};
99+1010+impl LuminaryEngine {
1111+ /// Retrieves the paths for the given project directory and its compose file.
1212+ // TODO: In future this should look up the project directory from the program state
1313+ fn get_path(&self, project: &str) -> (PathBuf, PathBuf) {
1414+ let project_path = Path::new(&self.configuration.project_directory).join(project);
1515+1616+ let compose_path = project_path.join(COMPOSE_FILENAME);
1717+1818+ return (project_path, compose_path);
1919+ }
2020+2121+ /// Retrieves the docker compose file for a given project.
2222+ #[wrap_err("Failed to retrieve compose file")]
2323+ pub async fn get_compose(&self, project: &str) -> Result<String> {
2424+ let (_, path) = self.get_path(project);
2525+2626+ if !path.exists() {
2727+ eyre::bail!("Project '{}' does not exist", project);
2828+ }
2929+3030+ return Ok(read_to_string(path).await.wrap_err("Failed to read file")?);
3131+ }
3232+3333+ /// Updates the docker compose file for a given project.
3434+ /// WARNING: Does not automatically call `refresh`, make sure to do this after calling this function.
3535+ #[wrap_err("Failed to update compose file")]
3636+ pub async fn put_compose(&self, project: &str, compose: &str) -> Result<()> {
3737+ let (project_path, compose_path) = self.get_path(project);
3838+3939+ // Create project directory if it doesn't exist
4040+ fs::create_dir_all(&project_path)
4141+ .await
4242+ .wrap_err("Failed to create project directory")?;
4343+4444+ // Write compose file
4545+ fs::write(compose_path, compose)
4646+ .await
4747+ .wrap_err("Failed to write file")?;
4848+4949+ return Ok(());
5050+ }
5151+5252+ /// Renames the project dirctory for a given project. Recreating the project if it was previously running.
5353+ /// WARNING: Does not automatically call `refresh`, make sure to do this after calling this function.
5454+ #[wrap_err("Failed to rename project")]
5555+ pub async fn rename_project(&self, from: &str, to: &str) -> Result<()> {
5656+ let src_path = path::absolute(self.get_path(from).0).wrap_err("Failed to get absolute path")?;
5757+ let to_path = src_path.parent().unwrap().join(to);
5858+5959+ if !src_path.exists() {
6060+ eyre::bail!("Project '{}' does not exist", from);
6161+ }
6262+6363+ if to_path.exists() {
6464+ eyre::bail!("There is already a project with the name '{}'", to);
6565+ }
6666+6767+ // Labels are immutable so recreate the project to update Docker Compose labels (if it was not down).
6868+ let recreate = self
6969+ .get_project(from)
7070+ .await?
7171+ .services
7272+ .0
7373+ .iter()
7474+ .any(|(_, s)| s.status != LuminaryStatus::Down);
7575+7676+ if recreate {
7777+ self.stop(&from, None).await?;
7878+ };
7979+8080+ fs::rename(&src_path, &to_path)
8181+ .await
8282+ .wrap_err("Failed to rename project directory")?;
8383+8484+ if recreate {
8585+ // Use manual command as the project wont be in the program state at this point
8686+ let mut stream = self.cli(&to, vec!["up", "-d"])?;
8787+ while let Some(_) = stream.next().await {}
8888+ }
8989+9090+ return Ok(());
9191+ }
9292+}
+27-19
packages/node/src/core/state.rs
···18181919use crate::{
2020 core::{
2121- COMPOSE_FILENAME, LuminaryAction, LuminaryEngine, LuminaryIdentifier, LuminaryServiceList,
2222- model::{LuminaryProject, LuminaryService, LuminaryStateList, LuminaryStatus},
2121+ COMPOSE_FILENAME, COMPOSE_PROJECT_DIR_LABEL, COMPOSE_PROJECT_LABEL, COMPOSE_SERVICE_LABEL,
2222+ LuminaryAction, LuminaryEngine, LuminaryIdentifier, LuminaryServiceList,
2323+ model::{LuminaryProject, LuminaryProjectList, LuminaryService, LuminaryStatus},
2324 },
2425 eyre_fmt,
2526};
26272727-const COMPOSE_PROJECT_DIR_LABEL: &str = "com.docker.compose.project.working_dir";
2828-const COMPOSE_PROJECT_LABEL: &str = "com.docker.compose.project";
2929-const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service";
3030-3128impl LuminaryEngine {
3229 /// Retrieves a [LuminaryProject] by its name from the current state.
3333- pub async fn get_project(&self, name: &String) -> Result<LuminaryProject> {
3030+ pub async fn get_project(&self, name: &str) -> Result<LuminaryProject> {
3431 return Ok(self
3535- .state
3232+ .list
3633 .read()
3734 .await
3835 .0
···4239 }
43404441 /// Returns a stream of state updates, initialising the stream with the current state.
4545- pub async fn state_subscribe<'a>(&'_ self) -> BoxStream<'a, LuminaryStateList> {
4646- let mut reciever = self.state_channel.subscribe();
4747- let initial = self.state.read().await.clone();
4242+ pub async fn state_subscribe<'a>(&'_ self) -> BoxStream<'a, LuminaryProjectList> {
4343+ let mut reciever = self.list_channel.subscribe();
4444+ let initial = self.list.read().await.clone();
48454946 return async_stream::stream! {
5047 yield initial;
···8380 && let Some(labels) = actor.attributes
8481 && let Some(status) = Self::parse_action(action.clone())
8582 {
8686- let mut list = this.state.write().await;
8383+ let mut list = this.list.write().await;
8784 this.merge_service(status, labels, &mut list);
8885 this.broadcast(list.clone()).await;
8986 }
···10097 /// Lists all Luminary projects by combining data from both the filesystem and Docker engine.
10198 #[wrap_err("Failed to list projects")]
10299 pub async fn refresh(&self) -> Result<()> {
103103- let mut list = self.state.write().await;
100100+ let mut list = self.list.write().await;
104101105102 for project in list.0.values_mut() {
106103 for service in project.services.0.values_mut() {
···122119123120 /// Loads all Luminary projects found in the configured projects directory into the given state list.
124121 #[wrap_err("Failed to load projects from filesystem")]
125125- async fn load_from_filesystem(&self, list: &mut LuminaryStateList) -> Result<()> {
122122+ async fn load_from_filesystem(&self, list: &mut LuminaryProjectList) -> Result<()> {
126123 let mut entries = fs::read_dir(&self.configuration.project_directory)
127124 .await
128125 .wrap_err("Failed to list project directory contents")?;
···137134 }
138135139136 /// Loads a single project from the filesystem if a compose file is found, merging it into the given state list.
140140- async fn load_project_dir(&self, mut path: PathBuf, list: &mut LuminaryStateList) -> Result<()> {
137137+ async fn load_project_dir(&self, mut path: PathBuf, list: &mut LuminaryProjectList) -> Result<()> {
141138 if path.is_dir()
142139 && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())
143140 {
···171168 service_name.clone(),
172169 LuminaryService {
173170 stale: false,
171171+ orphan: false,
174172 action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle),
175173 status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down),
176174 identifier: LuminaryIdentifier::new(project_name.clone(), service_name),
···185183186184 /// Lists all Luminary projects by querying the Docker engine for containers with specific labels.
187185 #[wrap_err("Failed to list projects from docker engine")]
188188- async fn load_from_docker(&self, list: &mut LuminaryStateList) -> Result<()> {
186186+ async fn load_from_docker(&self, list: &mut LuminaryProjectList) -> Result<()> {
189187 let options = ListContainersOptionsBuilder::default().all(true).build();
190188 let containers = self
191189 .docker
···202200 return Ok(());
203201 }
204202203203+ /// Updates the state of a service based on Docker API details, merging it into the given state list.
205204 fn merge_service(
206205 &self,
207206 status: LuminaryStatus,
208207 mut labels: HashMap<String, String>,
209209- list: &mut LuminaryStateList,
208208+ list: &mut LuminaryProjectList,
210209 ) {
211210 if let Some(service_name) = labels.remove(COMPOSE_SERVICE_LABEL)
212211 && let Some(project_name) = labels.remove(COMPOSE_PROJECT_LABEL)
···222221 });
223222224223 let existing = project.services.0.get(&service_name);
224224+ let orphan = existing.as_ref().map(|e| e.stale || e.orphan).unwrap_or(false);
225225+226226+ // If the service is down and the service is an orphan, remove it
227227+ if status == LuminaryStatus::Down && orphan {
228228+ project.services.0.remove(&service_name);
229229+ return;
230230+ }
231231+225232 project.services.0.insert(
226233 service_name.clone(),
227234 LuminaryService {
228235 action: existing
229236 .as_ref()
230230- .map(|s| s.action)
237237+ .map(|e| e.action)
231238 .unwrap_or(LuminaryAction::Idle),
232239 identifier: LuminaryIdentifier::new(project_name, service_name),
233233- stale: false,
240240+ stale: existing.is_none() && status != LuminaryStatus::Down,
234241 status,
242242+ orphan,
235243 },
236244 );
237245 }
+2-2
packages/panel/src/lib/api/realtime.svelte.ts
···55import { goto } from "$app/navigation";
66import { patch } from "ultrapatch";
7788-export type LuminaryStateList = components["schemas"]["luminary_node.core.model.LuminaryStateList"];
88+export type LuminaryProjectList = components["schemas"]["luminary_node.core.model.LuminaryProjectList"];
99export type LuminaryProject = components["schemas"]["luminary_node.core.model.LuminaryProject"];
1010type LogMessage = components["schemas"]["luminary_node.logging.LogMessage"];
11111212/**
1313 * The current internal list of projects and their states.
1414 */
1515-let list: LuminaryStateList = $state({});
1515+let list: LuminaryProjectList = $state({});
16161717/**
1818 * A getter for the current project list.
+1-2
packages/panel/src/lib/component/Tabs.svelte
···1414 import { fade, slide } from "svelte/transition";
1515 import { Accordion } from "melt/builders";
1616 import type { Snippet } from "svelte";
1717- import Fa from "svelte-fa";
1817 import { page } from "$app/state";
1919- import { goto } from "$app/navigation";
1818+ import Fa from "svelte-fa";
20192120 let { tabs }: { tabs: { label: string; icon: IconDefinition; content: Snippet<[]> }[] } = $props();
2221