A focused Docker Compose management web application.
0
fork

Configure Feed

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

feat: compose saving

Brooke bf2b872b c82f8857

+90 -52
+17 -8
packages/node/src/api/project.rs
··· 1 1 //! Manages retrieving and updating project compose files. 2 2 3 - use std::str::from_utf8; 4 - 5 - use eyre::Context; 6 - use salvo::Request; 7 3 use salvo::Router; 8 4 use salvo::Writer; 9 5 use salvo::oapi::ToSchema; 6 + use salvo::oapi::extract::JsonBody; 10 7 use salvo::{ 11 8 Depot, 12 9 oapi::{endpoint, extract::PathParam}, 13 10 }; 11 + use serde::Deserialize; 14 12 use serde::Serialize; 15 13 16 14 use crate::core::LuminaryProject; ··· 49 47 #[endpoint] 50 48 pub async fn put_compose( 51 49 project: PathParam<String>, 52 - req: &mut Request, 50 + payload: JsonBody<ComposeWithName>, 53 51 depot: &mut Depot, 54 52 ) -> LuminaryResponse<()> { 55 53 let engine = obtain!(depot, LuminaryEngine); 56 - let bytes = req.payload().await.wrap_err("Failed to read request body")?; 57 - let compose = from_utf8(bytes).wrap_err("Failed to decode error")?; 58 - engine.put_compose(&project.into_inner(), compose).await?; 54 + 55 + engine 56 + .put_compose(&project.into_inner(), &payload.compose) 57 + .await?; 58 + 59 59 return Ok(().into()); 60 60 } 61 + 62 + #[derive(Debug, Clone, Deserialize, ToSchema)] 63 + struct ComposeWithName { 64 + /// The new name for the project. 65 + name: String, 66 + 67 + /// The compose file for this project. 68 + compose: String, 69 + }
+48 -37
packages/node/src/core/state.rs
··· 1 1 //! This module implements the core logic for managing Luminary projects. 2 2 3 - use std::{collections::HashMap, path::Path}; 3 + use std::{ 4 + collections::HashMap, 5 + path::{Path, PathBuf}, 6 + }; 4 7 5 8 use bollard::{ 6 9 query_parameters::{EventsOptionsBuilder, ListContainersOptionsBuilder}, ··· 125 128 .wrap_err("Failed to list project directory contents")?; 126 129 127 130 while let Some(entry) = entries.next_entry().await? { 128 - let mut path = entry.path(); 129 - if path.is_dir() 130 - && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()) 131 - { 132 - path.push(COMPOSE_FILENAME); 133 - if path.exists() { 134 - let file = File::open(path) 135 - .await 136 - .wrap_err("Failed to open compose file")? 137 - .into_std() 138 - .await; 131 + if let Err(err) = self.load_project_dir(entry.path(), list).await { 132 + error!("{}", eyre_fmt!(err)); 133 + } 134 + } 139 135 140 - // Run this in a thread as it uses a blocking file reader instead of an async one 141 - let compose: Compose = tokio::task::spawn_blocking(move || { 142 - return serde_saphyr::from_reader(file); 143 - }) 136 + return Ok(()); 137 + } 138 + 139 + /// 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<()> { 141 + if path.is_dir() 142 + && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()) 143 + { 144 + path.push(COMPOSE_FILENAME); 145 + if path.exists() { 146 + let file = File::open(path) 144 147 .await 145 - .wrap_err("Compose deserialization failed.")? 146 - .wrap_err_with(|| format!("Failed to deserialize compose file for {}", &project_name))?; 148 + .wrap_err("Failed to open compose file")? 149 + .into_std() 150 + .await; 147 151 148 - let project = list 149 - .0 150 - .entry(project_name.clone()) 151 - .or_insert_with(|| LuminaryProject { 152 - name: project_name.clone(), 153 - services: LuminaryServiceList::new(), 154 - }); 152 + // Run this in a thread as it uses a blocking file reader instead of an async one 153 + let compose: Compose = tokio::task::spawn_blocking(move || { 154 + return serde_saphyr::from_reader(file); 155 + }) 156 + .await 157 + .wrap_err("Compose deserialization failed.")? 158 + .wrap_err_with(|| format!("Failed to deserialize compose file for {}", &project_name))?; 155 159 156 - for (service_name, _) in compose.services.0 { 157 - let existing = project.services.0.get(&service_name); 158 - project.services.0.insert( 159 - service_name.clone(), 160 - LuminaryService { 161 - stale: false, 162 - action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle), 163 - status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down), 164 - identifier: LuminaryIdentifier::new(project_name.clone(), service_name), 165 - }, 166 - ); 167 - } 160 + let project = list 161 + .0 162 + .entry(project_name.clone()) 163 + .or_insert_with(|| LuminaryProject { 164 + name: project_name.clone(), 165 + services: LuminaryServiceList::new(), 166 + }); 167 + 168 + for (service_name, _) in compose.services.0 { 169 + let existing = project.services.0.get(&service_name); 170 + project.services.0.insert( 171 + service_name.clone(), 172 + LuminaryService { 173 + stale: false, 174 + action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle), 175 + status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down), 176 + identifier: LuminaryIdentifier::new(project_name.clone(), service_name), 177 + }, 178 + ); 168 179 } 169 180 } 170 181 }
+1
packages/panel/src/lib/api/index.ts
··· 4 4 import { goto } from "$app/navigation"; 5 5 import { error } from "$lib"; 6 6 7 + export type { components } from "./openapi"; 7 8 export * from "./realtime.svelte"; 8 9 9 10 const TOKEN_KEY = "luminary-token";
+24 -7
packages/panel/src/routes/(authenticated)/projects/[project]/+page.svelte
··· 8 8 import Tabs from "$lib/component/Tabs.svelte"; 9 9 import { getProjects } from "$lib/api"; 10 10 import { page } from "$app/state"; 11 - import { isMobile } from "$lib"; 11 + import { api, isMobile } from "$lib"; 12 12 import Fa from "svelte-fa"; 13 + 14 + type Payload = api.components["schemas"]["luminary_node.api.project.ComposeWithName"]; 13 15 14 16 let project = $derived(getProjects()[page.params.project!]); 15 17 let { data } = $props(); 16 18 17 19 // svelte-ignore state_referenced_locally 18 - let copy = $state({ 20 + let payload: Payload = $state({ 19 21 name: project.name, 20 22 compose: data.compose, 21 23 }); ··· 23 25 // Watch for changes to set unsaved state 24 26 let unsaved = $state(false); 25 27 $effect(() => { 26 - unsaved = copy.name !== project.name || copy.compose !== data.compose; 28 + unsaved = payload.name !== project.name || payload.compose !== data.compose; 27 29 }); 30 + 31 + function revert() { 32 + payload.compose = data.compose; 33 + payload.name = project.name; 34 + } 35 + 36 + function save() { 37 + api.client.PUT(`/api/project/{project}`, { 38 + params: { path: { project: project.name } }, 39 + body: payload, 40 + }); 41 + 42 + unsaved = false; 43 + data.compose = payload.compose; 44 + } 28 45 </script> 29 46 30 47 <div class="flexc gap-10"> ··· 59 76 {#snippet compose()} 60 77 <div> 61 78 <label for="name">Name</label> 62 - <input required id="name" type="text" bind:value={copy.name} /> 79 + <input required id="name" type="text" bind:value={payload.name} /> 63 80 </div> 64 81 65 82 <h2>Compose</h2> 66 - <ComposeEditor bind:content={copy.compose} /> 83 + <ComposeEditor bind:content={payload.compose} /> 67 84 {/snippet} 68 85 69 86 {#if unsaved} 70 87 <div style="color: var(--peach); margin-bottom: 10px;">* Unsaved changes</div> 71 88 <div class="flexr gap-10"> 72 - <button class="flexr gap-5 center"> 89 + <button class="flexr gap-5 center" onclick={save}> 73 90 <Fa icon={faSave} /> Save 74 91 </button> 75 - <button class="flexr gap-5 center"> 92 + <button class="flexr gap-5 center" onclick={revert}> 76 93 <Fa icon={faClockRotateLeft} /> Revert 77 94 </button> 78 95 </div>