A focused Docker Compose management web application.
0
fork

Configure Feed

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

feat: create project

+155 -36
+2 -8
packages/node/src/api/project.rs
··· 1 1 //! Manages retrieving and updating project compose files. 2 2 3 - use eyre::eyre; 4 3 use salvo::Router; 5 4 use salvo::Writer; 6 5 use salvo::oapi::ToSchema; ··· 58 57 ) -> LuminaryResponse<LuminaryProjectWithCompose> { 59 58 let engine = obtain!(depot, LuminaryEngine); 60 59 61 - if project.len() == 0 || payload.to.as_ref().is_some_and(|name| name.len() == 0) { 62 - Err(eyre!("Project name cannot be empty"))?; 60 + if !payload.creating { 61 + engine.wait_until_idle(&project, None).await?; 63 62 } 64 63 65 - if let Some(compose) = &payload.compose { 66 - engine.validate_compose(compose)?; 67 - } 68 - 69 - engine.wait_until_idle(&project, None).await?; 70 64 engine.patch_project(&project, &payload.0).await?; 71 65 return get_project(engine, &payload.to.take().unwrap_or(project.0)).await; 72 66 }
+3
packages/node/src/core/model.rs
··· 193 193 194 194 /// If [Some], renames the current to the given name. If [None], no rename will take place. 195 195 pub to: Option<String>, 196 + 197 + /// Wether this request is for creating a new project. 198 + pub creating: bool, 196 199 }
+24 -4
packages/node/src/core/project.rs
··· 1 1 use std::path::{Path, PathBuf}; 2 2 3 3 use docker_compose_types::Compose; 4 - use eyre::{Context, Ok, Result}; 4 + use eyre::{Context, Ok, Result, bail}; 5 5 use futures_util::StreamExt; 6 6 use luminary_macros::wrap_err; 7 7 use tokio::fs::{self, read_to_string}; ··· 33 33 34 34 /// Validates a compose file by attempting to parse it and performing basic checks on the structure. 35 35 #[wrap_err("Invalid compose file")] 36 - pub fn validate_compose(&self, compose: &str) -> Result<()> { 36 + fn validate_compose(&self, compose: &str) -> Result<()> { 37 37 let compose = serde_saphyr::from_str::<Compose>(compose).wrap_err("Failed to parse compose file")?; 38 38 39 39 if compose.services.is_empty() { ··· 45 45 46 46 /// Updates the given project by applying the provided patch 47 47 pub async fn patch_project(&self, project: &str, patch: &LuminaryProjectPatch) -> Result<()> { 48 - self.set_action(project, None, LuminaryAction::Patching).await?; 48 + // Validate request 49 + if project.len() == 0 || patch.to.as_ref().is_some_and(|name| name.len() == 0) { 50 + bail!("Project name cannot be empty"); 51 + } 49 52 53 + if let Some(compose) = &patch.compose { 54 + self.validate_compose(compose)?; 55 + } 56 + 57 + if patch.creating { 58 + if self.get_project(&project).await.is_ok() { 59 + eyre::bail!("Project '{}' already exists", project); 60 + } 61 + } else { 62 + self.set_action(project, None, LuminaryAction::Patching) 63 + .await 64 + .ok(); 65 + } 66 + 67 + // Perform requested changes 50 68 let (project_path, compose_path) = self.get_path(project); 51 69 let mut changed = false; 52 70 ··· 60 78 changed = true; 61 79 } 62 80 63 - self.set_action(project, None, LuminaryAction::Idle).await?; 81 + if !patch.creating { 82 + self.set_action(project, None, LuminaryAction::Idle).await.ok(); 83 + } 64 84 65 85 if changed { 66 86 self.refresh().await?;
+30 -22
packages/panel/src/routes/(authenticated)/Navbar.svelte
··· 6 6 --> 7 7 8 8 <script lang="ts"> 9 + import Crane from "$lib/component/Crane.svelte"; 9 10 import { onNavigate } from "$app/navigation"; 10 11 import { slide } from "svelte/transition"; 12 + import type { Snippet } from "svelte"; 11 13 import { page } from "$app/state"; 12 14 import { isMobile } from "$lib"; 13 15 import Fa from "svelte-fa"; 14 16 import { 15 - faBars, 17 + faMagnifyingGlass, 16 18 faChevronLeft, 17 - faCircleUser, 18 - faGear, 19 19 faLayerGroup, 20 - faMagnifyingGlass, 21 20 faXmark, 21 + faBars, 22 + faGear, 23 + faPlusCircle, 24 + faLock, 22 25 } from "@fortawesome/free-solid-svg-icons"; 23 - import type { Snippet } from "svelte"; 24 - import Crane from "$lib/component/Crane.svelte"; 25 26 26 27 const EXPANDED_KEY = "luminary-navbar-expanded"; 27 28 28 29 const PAGES = [ 29 30 { icon: faLayerGroup, label: "Projects", href: "/projects" }, 31 + { icon: faPlusCircle, label: "Create", href: "/create" }, 32 + "search", 33 + { icon: faLock, label: "Admin", href: "/admin" }, 30 34 { icon: faGear, label: "Settings", href: "/settings" }, 31 - { icon: faCircleUser, label: "User", href: "/user" }, 32 - ] satisfies { icon: any; label: string; href: string }[]; 35 + ] satisfies ({ icon: any; label: string; href: string } | "search")[]; 33 36 34 37 let { children }: { children: Snippet<[]> } = $props(); 35 38 ··· 55 58 </script> 56 59 57 60 {#snippet links()} 58 - {#each PAGES as { icon, label, href }} 59 - <a class="entry" {href} class:current={page.url.pathname.startsWith(href)}> 60 - <div class="icon"> 61 - <Fa {icon} /> 62 - </div> 63 - <div class="label">{label}</div> 64 - </a> 61 + {#each PAGES as entry} 62 + {#if entry === "search"} 63 + <button class="a entry" style="margin-bottom: auto"> 64 + <div class="icon"> 65 + <Fa icon={faMagnifyingGlass} /> 66 + </div> 67 + <div class="label">Search</div> 68 + </button> 69 + {:else} 70 + {@const { icon, label, href } = entry} 71 + 72 + <a class="entry" {href} class:current={page.url.pathname.startsWith(href)}> 73 + <div class="icon"> 74 + <Fa {icon} /> 75 + </div> 76 + <div class="label">{label}</div> 77 + </a> 78 + {/if} 65 79 {/each} 66 80 {/snippet} 67 81 ··· 94 108 <nav class:expanded bind:clientWidth={navbarWidth}> 95 109 {@render links()} 96 110 97 - <button class="a entry" style="margin-top: auto"> 98 - <div class="icon"> 99 - <Fa icon={faMagnifyingGlass} /> 100 - </div> 101 - <div class="label">Search</div> 102 - </button> 103 - 104 111 <button class="a entry" onclick={toggleExpanded} aria-label="{expanded ? 'collapse' : 'expand'} sidebar"> 105 112 <div class="icon"> 106 113 <Fa icon={expanded ? faChevronLeft : faBars} /> ··· 135 142 background-color: var(--crust); 136 143 137 144 position: fixed; 145 + z-index: 100; 138 146 left: 0; 139 147 top: 0; 140 148
+1
packages/panel/src/routes/(authenticated)/create/+layout.ts
··· 1 + export const prerender = false;
+87
packages/panel/src/routes/(authenticated)/create/+page.svelte
··· 1 + <script lang="ts"> 2 + import { faBan, faCircleInfo, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; 3 + import { faSquarePlus } from "@fortawesome/free-regular-svg-icons"; 4 + import PromiseButton from "$lib/component/PromiseButton.svelte"; 5 + import EditTabs from "../projects/EditTabs.svelte"; 6 + import placeholder from "./placeholder.yml?raw"; 7 + import { goto } from "$app/navigation"; 8 + import { onMount } from "svelte"; 9 + import { api } from "$lib"; 10 + import Fa from "svelte-fa"; 11 + 12 + // svelte-ignore state_referenced_locally 13 + let project = $state({ 14 + compose: placeholder, 15 + name: "unnamed", 16 + }); 17 + 18 + async function save() { 19 + const response = await api.client.PATCH(`/api/project/{project}`, { 20 + body: { compose: project.compose, creating: true }, 21 + params: { path: { project: project.name } }, 22 + }); 23 + 24 + api.putProject(response.data!); 25 + await goto(`/projects/${project.name}${location.hash}`); 26 + } 27 + 28 + onMount(() => { 29 + const saveKeybind = (event: KeyboardEvent) => { 30 + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { 31 + event.preventDefault(); 32 + save(); 33 + } 34 + }; 35 + 36 + window.addEventListener("keydown", saveKeybind, true); 37 + 38 + return () => { 39 + window.removeEventListener("keydown", saveKeybind, true); 40 + }; 41 + }); 42 + </script> 43 + 44 + {#if project} 45 + <div class="flexc gap-10"> 46 + <!-- Title Bar --> 47 + <h1 class="flexr gap-10 center fit"> 48 + <Fa icon={faLayerGroup} size="lg" /> 49 + <div style="display: inline-block;"> 50 + <div style="font-size: 22px;">{project.name}</div> 51 + <div class="subtext flexr gap-5">New Project</div> 52 + </div> 53 + </h1> 54 + 55 + <EditTabs bind:data={project} /> 56 + </div> 57 + 58 + <div class="flexr gap-10"> 59 + <div> 60 + <PromiseButton onclick={save}> 61 + <div class="flexr gap-5 center"> 62 + <Fa icon={faSquarePlus} /> Create 63 + </div> 64 + </PromiseButton> 65 + </div> 66 + <a class="button flexr gap-5 center" href="../"> 67 + <Fa icon={faBan} /> Cancel 68 + </a> 69 + </div> 70 + {:else} 71 + <div class="flexc gap-10 center"> 72 + <Fa icon={faCircleInfo} size="lg" /> 73 + <div style="font-size: 22px;">Project no longer exists</div> 74 + </div> 75 + {/if} 76 + 77 + <style lang="scss"> 78 + // Modify h2 of all child components 79 + * :global(h2) { 80 + margin-bottom: 5px; 81 + font-size: 16px; 82 + 83 + &:not(:first-child) { 84 + margin-top: 15px; 85 + } 86 + } 87 + </style>
+3
packages/panel/src/routes/(authenticated)/create/placeholder.yml
··· 1 + services: 2 + hello: 3 + image: hello-world
+2
packages/panel/src/routes/(authenticated)/projects/+page.svelte
··· 100 100 {/if} 101 101 </div> 102 102 103 + <br /> 104 + 103 105 <style lang="scss"> 104 106 .projects { 105 107 container: projects / inline-size;
+2 -2
packages/panel/src/routes/(authenticated)/projects/EditTabs.svelte
··· 5 5 import type { ComponentProps } from "svelte"; 6 6 7 7 let { 8 - tabs, 8 + tabs = [], 9 9 data = $bindable(), 10 - }: { data: { name: string; compose: string }; tabs: ComponentProps<typeof Tabs>["tabs"] } = $props(); 10 + }: { data: { name: string; compose: string }; tabs?: ComponentProps<typeof Tabs>["tabs"] } = $props(); 11 11 </script> 12 12 13 13 <Tabs tabs={[...tabs, { label: "compose", icon: faPencil, content: compose }]} />
+1
packages/panel/src/routes/(authenticated)/projects/[project]/+page.svelte
··· 43 43 body: { 44 44 compose: copy.compose === data.compose ? null : copy.compose, 45 45 to: rename ? copy.name : null, 46 + creating: false, 46 47 }, 47 48 }); 48 49