A focused Docker Compose management web application.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: project renaming

Brooke c9f5ec28 bf2b872b

+380 -244
+12 -24
packages/node/src/api/action.rs
··· 11 11 pub async fn restart_project(project: PathParam<String>, depot: &mut Depot) -> LuminaryResponse<()> { 12 12 let engine = obtain!(depot, LuminaryEngine); 13 13 14 - engine.restart(&project.into_inner(), None).await?; 14 + engine.restart(&project, None).await?; 15 15 return Ok(().into()); 16 16 } 17 17 ··· 24 24 ) -> LuminaryResponse<()> { 25 25 let engine = obtain!(depot, LuminaryEngine); 26 26 27 - engine 28 - .restart(&project.into_inner(), Some(&service.into_inner())) 29 - .await?; 27 + engine.restart(&project, Some(&service)).await?; 30 28 return Ok(().into()); 31 29 } 32 30 ··· 35 33 pub async fn start_project(project: PathParam<String>, depot: &mut Depot) -> LuminaryResponse<()> { 36 34 let engine = obtain!(depot, LuminaryEngine); 37 35 38 - engine.start(&project.into_inner(), None).await?; 36 + engine.start(&project, None).await?; 39 37 return Ok(().into()); 40 38 } 41 39 ··· 48 46 ) -> LuminaryResponse<()> { 49 47 let engine = obtain!(depot, LuminaryEngine); 50 48 51 - engine 52 - .start(&project.into_inner(), Some(&service.into_inner())) 53 - .await?; 49 + engine.start(&project, Some(&service)).await?; 54 50 return Ok(().into()); 55 51 } 56 52 ··· 59 55 pub async fn stop_project(project: PathParam<String>, depot: &mut Depot) -> LuminaryResponse<()> { 60 56 let engine = obtain!(depot, LuminaryEngine); 61 57 62 - engine.stop(&project.into_inner(), None).await?; 58 + engine.stop(&project, None).await?; 63 59 return Ok(().into()); 64 60 } 65 61 ··· 72 68 ) -> LuminaryResponse<()> { 73 69 let engine = obtain!(depot, LuminaryEngine); 74 70 75 - engine 76 - .stop(&project.into_inner(), Some(&service.into_inner())) 77 - .await?; 71 + engine.stop(&project, Some(&service)).await?; 78 72 return Ok(().into()); 79 73 } 80 74 ··· 83 77 pub async fn recreate_project(project: PathParam<String>, depot: &mut Depot) -> LuminaryResponse<()> { 84 78 let engine = obtain!(depot, LuminaryEngine); 85 79 86 - engine.recreate(&project.into_inner(), None).await?; 80 + engine.recreate(&project, None).await?; 87 81 return Ok(().into()); 88 82 } 89 83 ··· 96 90 ) -> LuminaryResponse<()> { 97 91 let engine = obtain!(depot, LuminaryEngine); 98 92 99 - engine 100 - .recreate(&project.into_inner(), Some(&service.into_inner())) 101 - .await?; 93 + engine.recreate(&project, Some(&service)).await?; 102 94 return Ok(().into()); 103 95 } 104 96 ··· 107 99 pub async fn pull_project(project: PathParam<String>, depot: &mut Depot) -> LuminaryResponse<()> { 108 100 let engine = obtain!(depot, LuminaryEngine); 109 101 110 - engine.pull(&project.into_inner(), None).await?; 102 + engine.pull(&project, None).await?; 111 103 return Ok(().into()); 112 104 } 113 105 ··· 120 112 ) -> LuminaryResponse<()> { 121 113 let engine = obtain!(depot, LuminaryEngine); 122 114 123 - engine 124 - .pull(&project.into_inner(), Some(&service.into_inner())) 125 - .await?; 115 + engine.pull(&project, Some(&service)).await?; 126 116 return Ok(().into()); 127 117 } 128 118 ··· 131 121 pub async fn build_project(project: PathParam<String>, depot: &mut Depot) -> LuminaryResponse<()> { 132 122 let engine = obtain!(depot, LuminaryEngine); 133 123 134 - engine.build(&project.into_inner(), None).await?; 124 + engine.build(&project, None).await?; 135 125 return Ok(().into()); 136 126 } 137 127 ··· 144 134 ) -> LuminaryResponse<()> { 145 135 let engine = obtain!(depot, LuminaryEngine); 146 136 147 - engine 148 - .build(&project.into_inner(), Some(&service.into_inner())) 149 - .await?; 137 + engine.build(&project, Some(&service)).await?; 150 138 return Ok(().into()); 151 139 }
+1 -1
packages/node/src/api/mod.rs
··· 48 48 .merge_router(&router); 49 49 50 50 // Ensure custom core schemas are registered for SSE documentation. 51 - crate::core::LuminaryStateList::to_schema(&mut openapi.components); 51 + crate::core::LuminaryProjectList::to_schema(&mut openapi.components); 52 52 crate::logging::LogMessage::to_schema(&mut openapi.components); 53 53 54 54 let location = concat!(env!("CARGO_MANIFEST_DIR"), "/../panel/static/openapi.json");
+34 -17
packages/node/src/api/project.rs
··· 16 16 17 17 /// Returns the router for compose related endpoints. 18 18 pub fn router() -> Router { 19 - return Router::new().get(get_project).put(put_compose); 19 + return Router::new().get(get_project_endpoint).patch(put_compose); 20 20 } 21 21 22 22 /// Retrieves the compose file for a given project. 23 23 #[endpoint] 24 - pub async fn get_project( 24 + pub async fn get_project_endpoint( 25 25 project: PathParam<String>, 26 26 depot: &mut Depot, 27 27 ) -> LuminaryResponse<LuminaryProjectWithCompose> { 28 28 let engine = obtain!(depot, LuminaryEngine); 29 29 30 - let name = project.into_inner(); 31 - let compose = engine.get_compose(&name).await?; 32 - let project = engine.get_project(&name).await?; 30 + return get_project(engine, &project).await; 31 + } 32 + 33 + /// Retrieves the compose file and information for a given project. 34 + async fn get_project(engine: &LuminaryEngine, name: &String) -> LuminaryResponse<LuminaryProjectWithCompose> { 35 + let compose = engine.get_compose(name).await?; 36 + let project = engine.get_project(name).await?; 33 37 34 38 return Ok(LuminaryProjectWithCompose { project, compose }.into()); 35 39 } 36 40 41 + /// A project with its compose file. Used for returning project information in a single request. 37 42 #[derive(Debug, Clone, Serialize, ToSchema)] 38 43 struct LuminaryProjectWithCompose { 39 44 #[serde(flatten)] ··· 43 48 compose: String, 44 49 } 45 50 46 - /// Updates the compose file for a given project. 51 + /// Perform a configurable change to a project. 47 52 #[endpoint] 48 53 pub async fn put_compose( 49 54 project: PathParam<String>, 50 - payload: JsonBody<ComposeWithName>, 55 + payload: JsonBody<LuminaryProjectPatch>, 51 56 depot: &mut Depot, 52 - ) -> LuminaryResponse<()> { 57 + ) -> LuminaryResponse<LuminaryProjectWithCompose> { 53 58 let engine = obtain!(depot, LuminaryEngine); 59 + let mut changed = false; 54 60 55 - engine 56 - .put_compose(&project.into_inner(), &payload.compose) 57 - .await?; 61 + if let Some(compose) = &payload.compose { 62 + engine.put_compose(&project, &compose).await?; 63 + changed = true; 64 + } 65 + 66 + if let Some(from) = &payload.from { 67 + engine.rename_project(from, &project).await?; 68 + changed = true; 69 + } 70 + 71 + if changed { 72 + engine.refresh().await?; 73 + } 58 74 59 - return Ok(().into()); 75 + return get_project(engine, &project).await; 60 76 } 61 77 78 + /// The payload for updating a project. Allows for multiple updates at once. 62 79 #[derive(Debug, Clone, Deserialize, ToSchema)] 63 - struct ComposeWithName { 64 - /// The new name for the project. 65 - name: String, 80 + struct LuminaryProjectPatch { 81 + /// If [Some], the new compose file for this project. If [None], the compose file will not be updated. 82 + compose: Option<String>, 66 83 67 - /// The compose file for this project. 68 - compose: String, 84 + /// If [Some], renames the project with the given name. If [None], no rename will take place. 85 + from: Option<String>, 69 86 }
+8 -3
packages/node/src/core/action.rs
··· 9 9 #[wrap_err("Failed to set action for service")] 10 10 async fn set_action(&self, project: &str, service: Option<&str>, action: LuminaryAction) -> Result<()> { 11 11 // Get list of targets to update 12 - let mut project_list = self.state.write().await; 12 + let mut project_list = self.list.write().await; 13 13 let targets = match project_list.0.get_mut(project) { 14 14 None => bail!("Unknown project '{}'", project), 15 15 Some(service_list) => match service { ··· 82 82 /// Stops the given project and optionally, a specific service within that project. 83 83 #[wrap_err("Failed to stop project/service")] 84 84 pub async fn stop(&self, project: &str, service: Option<&str>) -> Result<()> { 85 - self.run(LuminaryAction::Stopping, project, service, vec!["down"]) 86 - .await?; 85 + self.run( 86 + LuminaryAction::Stopping, 87 + project, 88 + service, 89 + vec!["down", "--remove-orphans"], 90 + ) 91 + .await?; 87 92 Ok(()) 88 93 } 89 94
-41
packages/node/src/core/compose.rs
··· 1 - use std::path::{Path, PathBuf}; 2 - 3 - use eyre::{Context, Ok, Result}; 4 - use luminary_macros::wrap_err; 5 - use tokio::fs::read_to_string; 6 - 7 - use crate::core::{COMPOSE_FILENAME, LuminaryEngine}; 8 - 9 - impl LuminaryEngine { 10 - fn compose_path(&self, project: &str) -> Result<PathBuf> { 11 - let path = Path::new(&self.configuration.project_directory) 12 - .join(project) 13 - .join(COMPOSE_FILENAME); 14 - 15 - if !path.exists() { 16 - eyre::bail!("Project '{}' does not exist", project); 17 - } 18 - 19 - return Ok(path); 20 - } 21 - 22 - /// Retrieves the docker compose file for a given project. 23 - #[wrap_err("Failed to retrieve compose file")] 24 - pub async fn get_compose(&self, project: &str) -> Result<String> { 25 - let path = self.compose_path(project)?; 26 - 27 - return Ok(read_to_string(path).await.wrap_err("Failed to read file")?); 28 - } 29 - 30 - /// Updates the docker compose file for a given project. 31 - #[wrap_err("Failed to update compose file")] 32 - pub async fn put_compose(&self, project: &str, compose: &str) -> Result<()> { 33 - let path = self.compose_path(project)?; 34 - 35 - tokio::fs::write(path, compose) 36 - .await 37 - .wrap_err("Failed to write file")?; 38 - 39 - return Ok(()); 40 - } 41 - }
+8 -8
packages/node/src/core/engine.rs
··· 16 16 17 17 use crate::{ 18 18 configuration::LuminaryConfiguration, 19 - core::{LuminaryStateList, ProjectLogChannel}, 19 + core::{LuminaryProjectList, ProjectLogChannel}, 20 20 }; 21 21 22 22 /// The core engine of the Luminary application, containing shared state and configuration. 23 23 #[derive(Debug, Clone)] 24 24 pub struct LuminaryEngine { 25 25 /// The canonical list of services for this instance of [LuminaryEngine]. 26 - pub(super) state: Arc<RwLock<LuminaryStateList>>, 26 + pub(super) list: Arc<RwLock<LuminaryProjectList>>, 27 27 28 28 /// A channel for broadcasting state changes to listeners. 29 - pub(super) state_channel: broadcast::Sender<LuminaryStateList>, 29 + pub(super) list_channel: broadcast::Sender<LuminaryProjectList>, 30 30 31 31 /// A map of log channels for each project, keyed by project name. This is lazily populated when clients subscribe to logs for a project. 32 32 pub(super) log_channels: Arc<Mutex<HashMap<String, ProjectLogChannel>>>, ··· 45 45 let docker = Docker::connect_with_defaults().wrap_err("Failed to connect to docker engine.")?; 46 46 47 47 let instance = Self { 48 - state: Arc::new(RwLock::new(LuminaryStateList::new())), 48 + list: Arc::new(RwLock::new(LuminaryProjectList::new())), 49 49 log_channels: Arc::new(Mutex::new(HashMap::new())), 50 - state_channel: broadcast::channel(64).0, 50 + list_channel: broadcast::channel(64).0, 51 51 configuration, 52 52 docker, 53 53 }; ··· 59 59 } 60 60 61 61 /// Broadcasts the given state change to all listeners. 62 - pub(super) async fn broadcast(&self, list: LuminaryStateList) { 63 - if self.state_channel.receiver_count() > 0 { 62 + pub(super) async fn broadcast(&self, list: LuminaryProjectList) { 63 + if self.list_channel.receiver_count() > 0 { 64 64 // This will only error if there are no receivers, so we can safely ignore it. 65 - let _ = self.state_channel.send(list.clone()); 65 + let _ = self.list_channel.send(list.clone()); 66 66 } 67 67 } 68 68
+4 -1
packages/node/src/core/mod.rs
··· 1 1 //! The core library for Luminary, containing all logic related to managing projects and interacting with the Docker engine. 2 2 3 3 pub const COMPOSE_FILENAME: &str = "compose.yml"; 4 + pub const COMPOSE_PROJECT_DIR_LABEL: &str = "com.docker.compose.project.working_dir"; 5 + pub const COMPOSE_PROJECT_LABEL: &str = "com.docker.compose.project"; 6 + pub const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service"; 4 7 5 8 mod action; 6 - mod compose; 7 9 mod engine; 8 10 mod logs; 9 11 mod model; 12 + mod project; 10 13 mod state; 11 14 12 15 pub use engine::LuminaryEngine;
+7 -1
packages/node/src/core/model.rs
··· 13 13 /// A collection of Luminary projects, keyed by project name. 14 14 #[hashmap_schema] 15 15 #[derive(Debug, Clone, PartialEq, Serialize)] 16 - pub struct LuminaryStateList<String, LuminaryProject>; 16 + pub struct LuminaryProjectList<String, LuminaryProject>; 17 17 18 18 /// A collection of Luminary services, keyed by service name. 19 19 #[hashmap_schema] ··· 86 86 /// The identifier of this service 87 87 #[serde(flatten)] 88 88 pub identifier: LuminaryIdentifier, 89 + 89 90 /// The current status of this service 90 91 pub status: LuminaryStatus, 92 + 91 93 /// The current action being performed on this service 92 94 pub action: LuminaryAction, 95 + 96 + /// Wether this service is no longer defined in its compose file. It will be removed when it goes down. 97 + pub orphan: bool, 98 + 93 99 /// Wether this service is stale, meaning that it was removed in the recent refresh. 94 100 #[serde(skip)] 95 101 pub stale: bool,
+92
packages/node/src/core/project.rs
··· 1 + use std::path::{self, Path, PathBuf}; 2 + 3 + use eyre::{Context, Ok, Result}; 4 + use futures_util::StreamExt; 5 + use luminary_macros::wrap_err; 6 + use tokio::fs::{self, read_to_string}; 7 + 8 + use crate::core::{COMPOSE_FILENAME, LuminaryEngine, LuminaryStatus}; 9 + 10 + impl LuminaryEngine { 11 + /// Retrieves the paths for the given project directory and its compose file. 12 + // TODO: In future this should look up the project directory from the program state 13 + fn get_path(&self, project: &str) -> (PathBuf, PathBuf) { 14 + let project_path = Path::new(&self.configuration.project_directory).join(project); 15 + 16 + let compose_path = project_path.join(COMPOSE_FILENAME); 17 + 18 + return (project_path, compose_path); 19 + } 20 + 21 + /// Retrieves the docker compose file for a given project. 22 + #[wrap_err("Failed to retrieve compose file")] 23 + pub async fn get_compose(&self, project: &str) -> Result<String> { 24 + let (_, path) = self.get_path(project); 25 + 26 + if !path.exists() { 27 + eyre::bail!("Project '{}' does not exist", project); 28 + } 29 + 30 + return Ok(read_to_string(path).await.wrap_err("Failed to read file")?); 31 + } 32 + 33 + /// Updates the docker compose file for a given project. 34 + /// WARNING: Does not automatically call `refresh`, make sure to do this after calling this function. 35 + #[wrap_err("Failed to update compose file")] 36 + pub async fn put_compose(&self, project: &str, compose: &str) -> Result<()> { 37 + let (project_path, compose_path) = self.get_path(project); 38 + 39 + // Create project directory if it doesn't exist 40 + fs::create_dir_all(&project_path) 41 + .await 42 + .wrap_err("Failed to create project directory")?; 43 + 44 + // Write compose file 45 + fs::write(compose_path, compose) 46 + .await 47 + .wrap_err("Failed to write file")?; 48 + 49 + return Ok(()); 50 + } 51 + 52 + /// Renames the project dirctory for a given project. Recreating the project if it was previously running. 53 + /// WARNING: Does not automatically call `refresh`, make sure to do this after calling this function. 54 + #[wrap_err("Failed to rename project")] 55 + pub async fn rename_project(&self, from: &str, to: &str) -> Result<()> { 56 + let src_path = path::absolute(self.get_path(from).0).wrap_err("Failed to get absolute path")?; 57 + let to_path = src_path.parent().unwrap().join(to); 58 + 59 + if !src_path.exists() { 60 + eyre::bail!("Project '{}' does not exist", from); 61 + } 62 + 63 + if to_path.exists() { 64 + eyre::bail!("There is already a project with the name '{}'", to); 65 + } 66 + 67 + // Labels are immutable so recreate the project to update Docker Compose labels (if it was not down). 68 + let recreate = self 69 + .get_project(from) 70 + .await? 71 + .services 72 + .0 73 + .iter() 74 + .any(|(_, s)| s.status != LuminaryStatus::Down); 75 + 76 + if recreate { 77 + self.stop(&from, None).await?; 78 + }; 79 + 80 + fs::rename(&src_path, &to_path) 81 + .await 82 + .wrap_err("Failed to rename project directory")?; 83 + 84 + if recreate { 85 + // Use manual command as the project wont be in the program state at this point 86 + let mut stream = self.cli(&to, vec!["up", "-d"])?; 87 + while let Some(_) = stream.next().await {} 88 + } 89 + 90 + return Ok(()); 91 + } 92 + }
+27 -19
packages/node/src/core/state.rs
··· 18 18 19 19 use crate::{ 20 20 core::{ 21 - COMPOSE_FILENAME, LuminaryAction, LuminaryEngine, LuminaryIdentifier, LuminaryServiceList, 22 - model::{LuminaryProject, LuminaryService, LuminaryStateList, LuminaryStatus}, 21 + COMPOSE_FILENAME, COMPOSE_PROJECT_DIR_LABEL, COMPOSE_PROJECT_LABEL, COMPOSE_SERVICE_LABEL, 22 + LuminaryAction, LuminaryEngine, LuminaryIdentifier, LuminaryServiceList, 23 + model::{LuminaryProject, LuminaryProjectList, LuminaryService, LuminaryStatus}, 23 24 }, 24 25 eyre_fmt, 25 26 }; 26 27 27 - const COMPOSE_PROJECT_DIR_LABEL: &str = "com.docker.compose.project.working_dir"; 28 - const COMPOSE_PROJECT_LABEL: &str = "com.docker.compose.project"; 29 - const COMPOSE_SERVICE_LABEL: &str = "com.docker.compose.service"; 30 - 31 28 impl LuminaryEngine { 32 29 /// Retrieves a [LuminaryProject] by its name from the current state. 33 - pub async fn get_project(&self, name: &String) -> Result<LuminaryProject> { 30 + pub async fn get_project(&self, name: &str) -> Result<LuminaryProject> { 34 31 return Ok(self 35 - .state 32 + .list 36 33 .read() 37 34 .await 38 35 .0 ··· 42 39 } 43 40 44 41 /// Returns a stream of state updates, initialising the stream with the current state. 45 - pub async fn state_subscribe<'a>(&'_ self) -> BoxStream<'a, LuminaryStateList> { 46 - let mut reciever = self.state_channel.subscribe(); 47 - let initial = self.state.read().await.clone(); 42 + pub async fn state_subscribe<'a>(&'_ self) -> BoxStream<'a, LuminaryProjectList> { 43 + let mut reciever = self.list_channel.subscribe(); 44 + let initial = self.list.read().await.clone(); 48 45 49 46 return async_stream::stream! { 50 47 yield initial; ··· 83 80 && let Some(labels) = actor.attributes 84 81 && let Some(status) = Self::parse_action(action.clone()) 85 82 { 86 - let mut list = this.state.write().await; 83 + let mut list = this.list.write().await; 87 84 this.merge_service(status, labels, &mut list); 88 85 this.broadcast(list.clone()).await; 89 86 } ··· 100 97 /// Lists all Luminary projects by combining data from both the filesystem and Docker engine. 101 98 #[wrap_err("Failed to list projects")] 102 99 pub async fn refresh(&self) -> Result<()> { 103 - let mut list = self.state.write().await; 100 + let mut list = self.list.write().await; 104 101 105 102 for project in list.0.values_mut() { 106 103 for service in project.services.0.values_mut() { ··· 122 119 123 120 /// Loads all Luminary projects found in the configured projects directory into the given state list. 124 121 #[wrap_err("Failed to load projects from filesystem")] 125 - async fn load_from_filesystem(&self, list: &mut LuminaryStateList) -> Result<()> { 122 + async fn load_from_filesystem(&self, list: &mut LuminaryProjectList) -> Result<()> { 126 123 let mut entries = fs::read_dir(&self.configuration.project_directory) 127 124 .await 128 125 .wrap_err("Failed to list project directory contents")?; ··· 137 134 } 138 135 139 136 /// Loads a single project from the filesystem if a compose file is found, merging it into the given state list. 140 - async fn load_project_dir(&self, mut path: PathBuf, list: &mut LuminaryStateList) -> Result<()> { 137 + async fn load_project_dir(&self, mut path: PathBuf, list: &mut LuminaryProjectList) -> Result<()> { 141 138 if path.is_dir() 142 139 && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()) 143 140 { ··· 171 168 service_name.clone(), 172 169 LuminaryService { 173 170 stale: false, 171 + orphan: false, 174 172 action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle), 175 173 status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down), 176 174 identifier: LuminaryIdentifier::new(project_name.clone(), service_name), ··· 185 183 186 184 /// Lists all Luminary projects by querying the Docker engine for containers with specific labels. 187 185 #[wrap_err("Failed to list projects from docker engine")] 188 - async fn load_from_docker(&self, list: &mut LuminaryStateList) -> Result<()> { 186 + async fn load_from_docker(&self, list: &mut LuminaryProjectList) -> Result<()> { 189 187 let options = ListContainersOptionsBuilder::default().all(true).build(); 190 188 let containers = self 191 189 .docker ··· 202 200 return Ok(()); 203 201 } 204 202 203 + /// Updates the state of a service based on Docker API details, merging it into the given state list. 205 204 fn merge_service( 206 205 &self, 207 206 status: LuminaryStatus, 208 207 mut labels: HashMap<String, String>, 209 - list: &mut LuminaryStateList, 208 + list: &mut LuminaryProjectList, 210 209 ) { 211 210 if let Some(service_name) = labels.remove(COMPOSE_SERVICE_LABEL) 212 211 && let Some(project_name) = labels.remove(COMPOSE_PROJECT_LABEL) ··· 222 221 }); 223 222 224 223 let existing = project.services.0.get(&service_name); 224 + let orphan = existing.as_ref().map(|e| e.stale || e.orphan).unwrap_or(false); 225 + 226 + // If the service is down and the service is an orphan, remove it 227 + if status == LuminaryStatus::Down && orphan { 228 + project.services.0.remove(&service_name); 229 + return; 230 + } 231 + 225 232 project.services.0.insert( 226 233 service_name.clone(), 227 234 LuminaryService { 228 235 action: existing 229 236 .as_ref() 230 - .map(|s| s.action) 237 + .map(|e| e.action) 231 238 .unwrap_or(LuminaryAction::Idle), 232 239 identifier: LuminaryIdentifier::new(project_name, service_name), 233 - stale: false, 240 + stale: existing.is_none() && status != LuminaryStatus::Down, 234 241 status, 242 + orphan, 235 243 }, 236 244 ); 237 245 }
+2 -2
packages/panel/src/lib/api/realtime.svelte.ts
··· 5 5 import { goto } from "$app/navigation"; 6 6 import { patch } from "ultrapatch"; 7 7 8 - export type LuminaryStateList = components["schemas"]["luminary_node.core.model.LuminaryStateList"]; 8 + export type LuminaryProjectList = components["schemas"]["luminary_node.core.model.LuminaryProjectList"]; 9 9 export type LuminaryProject = components["schemas"]["luminary_node.core.model.LuminaryProject"]; 10 10 type LogMessage = components["schemas"]["luminary_node.logging.LogMessage"]; 11 11 12 12 /** 13 13 * The current internal list of projects and their states. 14 14 */ 15 - let list: LuminaryStateList = $state({}); 15 + let list: LuminaryProjectList = $state({}); 16 16 17 17 /** 18 18 * A getter for the current project list.
+1 -2
packages/panel/src/lib/component/Tabs.svelte
··· 14 14 import { fade, slide } from "svelte/transition"; 15 15 import { Accordion } from "melt/builders"; 16 16 import type { Snippet } from "svelte"; 17 - import Fa from "svelte-fa"; 18 17 import { page } from "$app/state"; 19 - import { goto } from "$app/navigation"; 18 + import Fa from "svelte-fa"; 20 19 21 20 let { tabs }: { tabs: { label: string; icon: IconDefinition; content: Snippet<[]> }[] } = $props(); 22 21
+5 -1
packages/panel/src/lib/component/Tooltip.svelte
··· 29 29 30 30 <style lang="scss"> 31 31 .trigger { 32 + display: inline-block; 32 33 width: fit-content; 33 34 cursor: help; 34 35 } ··· 37 38 position: relative; 38 39 background-color: var(--overlay0); 39 40 box-shadow: 0 -2px 10px #00000080; 40 - color: inherit; 41 + color: var(--text); 42 + 43 + max-width: 20vw; 44 + text-align: center; 41 45 42 46 border-radius: 5px; 43 47 border: none;
+35
packages/panel/src/routes/(authenticated)/projects/EditTabs.svelte
··· 1 + <script lang="ts"> 2 + import ComposeEditor from "$lib/component/ComposeEditor.svelte"; 3 + import { faPencil } from "@fortawesome/free-solid-svg-icons"; 4 + import Tabs from "$lib/component/Tabs.svelte"; 5 + import type { ComponentProps } from "svelte"; 6 + 7 + let { 8 + tabs, 9 + data = $bindable(), 10 + }: { data: { name: string; compose: string }; tabs: ComponentProps<typeof Tabs>["tabs"] } = $props(); 11 + </script> 12 + 13 + <Tabs tabs={[...tabs, { label: "compose", icon: faPencil, content: compose }]} /> 14 + 15 + {#snippet compose()} 16 + <div> 17 + <label for="name">Name</label> 18 + <input required id="name" type="text" bind:value={data.name} /> 19 + </div> 20 + 21 + <h2>Compose</h2> 22 + <ComposeEditor bind:content={data.compose} /> 23 + {/snippet} 24 + 25 + <style lang="scss"> 26 + // Modify h2 of all child components 27 + * :global(h2) { 28 + margin-bottom: 5px; 29 + font-size: 16px; 30 + 31 + &:not(:first-child) { 32 + margin-top: 15px; 33 + } 34 + } 35 + </style>
+65 -58
packages/panel/src/routes/(authenticated)/projects/[project]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { faCircleInfo, faClockRotateLeft, faLayerGroup, faPencil } from "@fortawesome/free-solid-svg-icons"; 3 - import ComposeEditor from "$lib/component/ComposeEditor.svelte"; 2 + import { faCircleInfo, faClockRotateLeft, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; 3 + import PromiseButton from "$lib/component/PromiseButton.svelte"; 4 4 import { faSave } from "@fortawesome/free-regular-svg-icons"; 5 5 import LogTerminal from "$lib/component/LogTerminal.svelte"; 6 6 import StatusIcon from "$lib/component/StatusIcon.svelte"; 7 7 import StatusTab from "./ProjectStatus.svelte"; 8 - import Tabs from "$lib/component/Tabs.svelte"; 8 + import EditTabs from "../EditTabs.svelte"; 9 9 import { getProjects } from "$lib/api"; 10 - import { page } from "$app/state"; 10 + import { goto } from "$app/navigation"; 11 11 import { api, isMobile } from "$lib"; 12 + import { page } from "$app/state"; 12 13 import Fa from "svelte-fa"; 13 - 14 - type Payload = api.components["schemas"]["luminary_node.api.project.ComposeWithName"]; 15 14 16 15 let project = $derived(getProjects()[page.params.project!]); 17 16 let { data } = $props(); 18 17 19 18 // svelte-ignore state_referenced_locally 20 - let payload: Payload = $state({ 19 + let copied = $state({ 21 20 name: project.name, 22 21 compose: data.compose, 23 22 }); ··· 25 24 // Watch for changes to set unsaved state 26 25 let unsaved = $state(false); 27 26 $effect(() => { 28 - unsaved = payload.name !== project.name || payload.compose !== data.compose; 27 + if (!project) return; 28 + 29 + unsaved = copied.name !== project.name || copied.compose !== data.compose; 29 30 }); 30 31 31 32 function revert() { 32 - payload.compose = data.compose; 33 - payload.name = project.name; 33 + copied.compose = data.compose; 34 + copied.name = project.name; 34 35 } 35 36 36 - function save() { 37 - api.client.PUT(`/api/project/{project}`, { 38 - params: { path: { project: project.name } }, 39 - body: payload, 37 + async function save() { 38 + const rename = copied.name !== project.name; 39 + 40 + await api.client.PATCH(`/api/project/{project}`, { 41 + params: { path: { project: rename ? copied.name : project.name } }, 42 + body: { 43 + compose: copied.compose === data.compose ? null : copied.compose, 44 + from: rename ? project.name : null, 45 + }, 40 46 }); 47 + 48 + if (rename) { 49 + await goto(`/projects/${copied.name}${location.hash}`); 50 + return; 51 + } 41 52 42 53 unsaved = false; 43 - data.compose = payload.compose; 54 + data.compose = copied.compose; 44 55 } 45 56 </script> 46 57 47 - <div class="flexc gap-10"> 48 - <!-- Title Bar --> 49 - <h1 class="flexr gap-10 center fit"> 50 - <Fa icon={faLayerGroup} size="lg" /> 51 - <div style="display: inline-block;"> 52 - <div style="font-size: 22px;">{project.name}</div> 53 - <div class="subtext flexr gap-5"> 54 - <StatusIcon status={project.status} /> 55 - {project.status} 58 + {#if project} 59 + <div class="flexc gap-10"> 60 + <!-- Title Bar --> 61 + <h1 class="flexr gap-10 center fit"> 62 + <Fa icon={faLayerGroup} size="lg" /> 63 + <div style="display: inline-block;"> 64 + <div style="font-size: 22px;">{project.name}</div> 65 + <div class="subtext flexr gap-5"> 66 + <StatusIcon status={project.status} /> 67 + {project.status} 68 + </div> 56 69 </div> 57 - </div> 58 - </h1> 70 + </h1> 59 71 60 - <Tabs 61 - tabs={[ 62 - { label: "status", icon: faCircleInfo, content: status }, 63 - { label: "compose", icon: faPencil, content: compose }, 64 - ]} 65 - /> 66 - </div> 67 - 68 - {#snippet status()} 69 - <StatusTab {project} /> 70 - {#if !isMobile()} 71 - <h2>Logs</h2> 72 - <LogTerminal project={project.name} /> 73 - {/if} 74 - {/snippet} 75 - 76 - {#snippet compose()} 77 - <div> 78 - <label for="name">Name</label> 79 - <input required id="name" type="text" bind:value={payload.name} /> 72 + <EditTabs bind:data={copied} tabs={[{ label: "status", icon: faCircleInfo, content: status }]} /> 80 73 </div> 81 74 82 - <h2>Compose</h2> 83 - <ComposeEditor bind:content={payload.compose} /> 84 - {/snippet} 75 + {#snippet status()} 76 + <StatusTab {project} /> 77 + {#if !isMobile()} 78 + <h2>Logs</h2> 79 + <LogTerminal project={project.name} /> 80 + {/if} 81 + {/snippet} 85 82 86 - {#if unsaved} 87 - <div style="color: var(--peach); margin-bottom: 10px;">* Unsaved changes</div> 88 - <div class="flexr gap-10"> 89 - <button class="flexr gap-5 center" onclick={save}> 90 - <Fa icon={faSave} /> Save 91 - </button> 92 - <button class="flexr gap-5 center" onclick={revert}> 93 - <Fa icon={faClockRotateLeft} /> Revert 94 - </button> 83 + {#if unsaved} 84 + <div style="color: var(--peach); margin-bottom: 10px;">* Unsaved changes</div> 85 + <div class="flexr gap-10"> 86 + <div> 87 + <PromiseButton onclick={save}> 88 + <div class="flexr gap-5 center"> 89 + <Fa icon={faSave} /> Save 90 + </div> 91 + </PromiseButton> 92 + </div> 93 + <button class="flexr gap-5 center" onclick={revert}> 94 + <Fa icon={faClockRotateLeft} /> Revert 95 + </button> 96 + </div> 97 + {/if} 98 + {:else} 99 + <div class="flexc gap-10 center"> 100 + <Fa icon={faCircleInfo} size="lg" /> 101 + <div style="font-size: 22px;">Project no longer exists</div> 95 102 </div> 96 103 {/if} 97 104
+79 -66
packages/panel/src/routes/(authenticated)/projects/[project]/ProjectStatus.svelte
··· 94 94 </div> 95 95 <div class="grow"> 96 96 <h3>{service.serviceName}</h3> 97 - <div class="subtext">{service.status}</div> 97 + <div class="subtext"> 98 + {#if service.orphan} 99 + <Tooltip 100 + content={`This service no longer exists in the compose file. 101 + It will be removed when it is recreated`} 102 + > 103 + <span style="color: var(--peach)">orphaned</span>, 104 + </Tooltip> 105 + {/if} 106 + 107 + {service.status} 108 + </div> 98 109 </div> 99 110 100 - <div class="flexr center gap-5"> 101 - <Tooltip placement="left" content="Start Service"> 102 - <PromiseButton 103 - style="a" 104 - aria-label="Start Service" 105 - disabled={service.action !== "idle"} 106 - loading={service.action === "starting"} 107 - onclick={() => 108 - api.client.POST("/api/project/{project}/service/{service}/start", { 109 - params: { path: { project: project.name, service: service.serviceName } }, 110 - })} 111 - > 112 - {#snippet children(loading)} 113 - {#if !loading}<Fa icon={faPlay} />{/if} 114 - {/snippet} 115 - </PromiseButton> 116 - </Tooltip> 111 + {#if !service.orphan} 112 + <div class="flexr center gap-5"> 113 + <Tooltip placement="left" content="Start Service"> 114 + <PromiseButton 115 + style="a" 116 + aria-label="Start Service" 117 + disabled={service.action !== "idle"} 118 + loading={service.action === "starting"} 119 + onclick={() => 120 + api.client.POST("/api/project/{project}/service/{service}/start", { 121 + params: { path: { project: project.name, service: service.serviceName } }, 122 + })} 123 + > 124 + {#snippet children(loading)} 125 + {#if !loading}<Fa icon={faPlay} />{/if} 126 + {/snippet} 127 + </PromiseButton> 128 + </Tooltip> 117 129 118 - <Tooltip placement="left" content="Restart Service"> 119 - <PromiseButton 120 - style="a" 121 - aria-label="Restart Service" 122 - disabled={service.action !== "idle"} 123 - loading={service.action === "restarting"} 124 - onclick={() => 125 - api.client.POST("/api/project/{project}/service/{service}/restart", { 126 - params: { path: { project: project.name, service: service.serviceName } }, 127 - })} 128 - > 129 - {#snippet children(loading)} 130 - {#if !loading}<Fa icon={faArrowsRotate} />{/if} 131 - {/snippet} 132 - </PromiseButton> 133 - </Tooltip> 130 + <Tooltip placement="left" content="Restart Service"> 131 + <PromiseButton 132 + style="a" 133 + aria-label="Restart Service" 134 + disabled={service.action !== "idle"} 135 + loading={service.action === "restarting"} 136 + onclick={() => 137 + api.client.POST("/api/project/{project}/service/{service}/restart", { 138 + params: { path: { project: project.name, service: service.serviceName } }, 139 + })} 140 + > 141 + {#snippet children(loading)} 142 + {#if !loading}<Fa icon={faArrowsRotate} />{/if} 143 + {/snippet} 144 + </PromiseButton> 145 + </Tooltip> 134 146 135 - <Tooltip placement="left" content="Stop Service"> 136 - <PromiseButton 137 - style="a" 138 - aria-label="Stop Service" 139 - disabled={service.action !== "idle"} 140 - loading={service.action === "stopping"} 141 - onclick={() => 142 - api.client.POST("/api/project/{project}/service/{service}/stop", { 143 - params: { path: { project: project.name, service: service.serviceName } }, 144 - })} 145 - > 146 - {#snippet children(loading)} 147 - {#if !loading}<Fa icon={faStop} />{/if} 148 - {/snippet} 149 - </PromiseButton> 150 - </Tooltip> 147 + <Tooltip placement="left" content="Stop Service"> 148 + <PromiseButton 149 + style="a" 150 + aria-label="Stop Service" 151 + disabled={service.action !== "idle"} 152 + loading={service.action === "stopping"} 153 + onclick={() => 154 + api.client.POST("/api/project/{project}/service/{service}/stop", { 155 + params: { path: { project: project.name, service: service.serviceName } }, 156 + })} 157 + > 158 + {#snippet children(loading)} 159 + {#if !loading}<Fa icon={faStop} />{/if} 160 + {/snippet} 161 + </PromiseButton> 162 + </Tooltip> 151 163 152 - <Tooltip placement="left" content="Recreate Service"> 153 - <PromiseButton 154 - style="a" 155 - aria-label="Recreate Service" 156 - disabled={service.action !== "idle"} 157 - onclick={() => 158 - api.client.POST("/api/project/{project}/service/{service}/recreate", { 159 - params: { path: { project: project.name, service: service.serviceName } }, 160 - })} 161 - > 162 - {#snippet children(loading)} 163 - {#if !loading}<Fa icon={faRocket} />{/if} 164 - {/snippet} 165 - </PromiseButton> 166 - </Tooltip> 167 - </div> 164 + <Tooltip placement="left" content="Recreate Service"> 165 + <PromiseButton 166 + style="a" 167 + aria-label="Recreate Service" 168 + disabled={service.action !== "idle"} 169 + onclick={() => 170 + api.client.POST("/api/project/{project}/service/{service}/recreate", { 171 + params: { path: { project: project.name, service: service.serviceName } }, 172 + })} 173 + > 174 + {#snippet children(loading)} 175 + {#if !loading}<Fa icon={faRocket} />{/if} 176 + {/snippet} 177 + </PromiseButton> 178 + </Tooltip> 179 + </div> 180 + {/if} 168 181 </div> 169 182 {/each} 170 183 </div>