A focused Docker Compose management web application.
0
fork

Configure Feed

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

feat: service status and actions page

Brooke 8c24d967 aee3cc2f

+288 -35
+5 -5
packages/node/src/core/model.rs
··· 126 126 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord, ToSchema)] 127 127 #[serde(rename_all = "camelCase")] 128 128 pub enum LuminaryStatus { 129 + /// Represents a service that is in the process of changing state. 130 + Loading, 131 + 129 132 /// Represents a service that has exited (usually due to an error). 130 133 Exited, 131 134 132 - /// Represents a service that is offline. 133 - Down, 134 - 135 135 /// Represents a service that has been paused. 136 136 Paused, 137 137 138 - /// Represents a service that is in the process of changing state. 139 - Loading, 138 + /// Represents a service that is offline. 139 + Down, 140 140 141 141 /// Represents a service that is running and online. 142 142 Running,
+1
packages/panel/src/lib/api/state.svelte.ts
··· 6 6 import { goto } from "$app/navigation"; 7 7 8 8 export type LuminaryStateList = components["schemas"]["luminary_node.core.model.LuminaryStateList"]; 9 + export type LuminaryProject = components["schemas"]["luminary_node.core.model.LuminaryProject"]; 9 10 type LogMessage = components["schemas"]["luminary_node.logging.LogMessage"]; 10 11 11 12 const INITIAL_RETRY_DELAY = 1000;
+19 -5
packages/panel/src/lib/component/LoaderButton.svelte
··· 8 8 import type { Snippet } from "svelte"; 9 9 10 10 let { 11 - onclick, 11 + style = "button", 12 + loading = false, 12 13 children, 13 - loading = $bindable(), 14 - }: { onclick?: () => Promise<void>; children: Snippet<[boolean]>; loading: boolean } = $props(); 14 + disabled, 15 + onclick, 16 + }: { 17 + style?: "button" | "a" | "outline"; 18 + children: Snippet<[boolean]> | string; 19 + onclick?: () => Promise<void>; 20 + disabled?: boolean; 21 + loading?: boolean; 22 + } = $props(); 15 23 </script> 16 24 17 - <button class="full" {onclick} disabled={loading}> 25 + <button class="full {style}" disabled={loading || disabled} {onclick}> 18 26 {#if loading} 19 27 <span class="loader"></span> 20 28 {/if} 21 29 22 - <div>{@render children(loading)}</div> 30 + <div> 31 + {#if typeof children === "string"} 32 + {children} 33 + {:else} 34 + {@render children(loading)} 35 + {/if} 36 + </div> 23 37 </button> 24 38 25 39 <style lang="scss">
+18 -6
packages/panel/src/lib/component/PromiseButton.svelte
··· 23 23 --> 24 24 25 25 <script lang="ts"> 26 - import type { Snippet } from "svelte"; 26 + import type { ComponentProps, Snippet } from "svelte"; 27 27 import LoaderButton from "./LoaderButton.svelte"; 28 28 29 - let { onclick, children }: { onclick: () => Promise<void>; children: Snippet<[boolean]> } = $props(); 29 + let { 30 + onclick, 31 + children, 32 + disabled, 33 + loading, 34 + style, 35 + }: { 36 + style?: ComponentProps<typeof LoaderButton>["style"]; 37 + children: Snippet<[boolean]> | string; 38 + onclick: () => Promise<any>; 39 + disabled?: boolean; 40 + loading?: boolean; 41 + } = $props(); 30 42 31 - let loading = $state(false); 43 + let waiting = $state(false); 32 44 33 45 async function handleClick() { 34 - loading = true; 46 + waiting = true; 35 47 try { 36 48 await onclick(); 37 49 } finally { 38 - loading = false; 50 + waiting = false; 39 51 } 40 52 } 41 53 </script> 42 54 43 - <LoaderButton onclick={handleClick} bind:loading {children} /> 55 + <LoaderButton onclick={handleClick} {disabled} {style} {children} loading={waiting || loading} />
+5 -2
packages/panel/src/lib/component/Tabs.svelte
··· 51 51 <span {...item.heading}>{tab.label}</span> 52 52 53 53 {#if mobile} 54 - <Fa icon={item.isExpanded ? faChevronDown : faChevronUp} style="margin-left: auto;" /> 54 + <Fa icon={item.isExpanded ? faChevronUp : faChevronDown} style="margin-left: auto;" /> 55 55 {/if} 56 56 </button> 57 57 {#if mobile} ··· 110 110 111 111 &:hover { 112 112 text-decoration: none; 113 - color: var(--mauve); 113 + 114 + @media (min-width: 426px) { 115 + color: var(--mauve); 116 + } 114 117 } 115 118 116 119 &.active {
+31 -1
packages/panel/src/lib/style.scss
··· 49 49 &:hover { 50 50 text-decoration: underline; 51 51 } 52 + 53 + &:disabled { 54 + color: var(--subtext0); 55 + text-decoration: none; 56 + cursor: not-allowed; 57 + } 52 58 } 53 59 54 60 button:not(.a), ··· 62 68 background-color: var(--lavender); 63 69 cursor: pointer; 64 70 65 - padding: 10px; 71 + padding: 5px 10px; 66 72 border-radius: 10px; 67 73 border: none; 68 74 ··· 76 82 77 83 &:disabled { 78 84 background-color: var(--overlay1); 85 + cursor: not-allowed; 86 + } 87 + 88 + &.outline { 89 + background-color: transparent; 90 + border: 1px solid var(--subtext0); 91 + color: inherit; 79 92 } 80 93 } 81 94 ··· 146 159 justify-content: space-between; 147 160 } 148 161 162 + &.wrap { 163 + flex-wrap: wrap; 164 + } 165 + 149 166 @for $i from 1 through 100 { 150 167 &.gap-#{$i} { 151 168 gap: #{$i}px; 152 169 } 153 170 } 171 + 172 + & > .grow { 173 + flex-grow: 1; 174 + } 154 175 } 155 176 156 177 .flexc { ··· 162 183 height: 100% !important; 163 184 width: 100% !important; 164 185 } 186 + 187 + .fit { 188 + width: fit-content; 189 + } 190 + 191 + .subtext { 192 + font-size: 14px; 193 + color: var(--subtext0); 194 + }
+1 -1
packages/panel/src/routes/(authenticated)/projects/+page.svelte
··· 47 47 <StatusIcon status={project.status} /> 48 48 {project.name} 49 49 </h2> 50 - <div style="color: var(--subtext0);"> 50 + <div class="subtext"> 51 51 {Object.keys(project.services).length} services {project.status} 52 52 </div> 53 53 </a>
+34 -13
packages/panel/src/routes/(authenticated)/projects/[project]/+page.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 + import { faCircleInfo, faGears, faLayerGroup, faPencil } from "@fortawesome/free-solid-svg-icons"; 3 + import StatusIcon from "$lib/component/StatusIcon.svelte"; 2 4 import Tabs from "$lib/component/Tabs.svelte"; 3 - import { faCircleInfo, faGears, faPencil } from "@fortawesome/free-solid-svg-icons"; 5 + import StatusTab from "./StatusTab.svelte"; 6 + import { getList } from "$lib/api"; 7 + import { page } from "$app/state"; 8 + import Fa from "svelte-fa"; 9 + 10 + let project = $derived(getList()[page.params.project!]); 11 + let { data } = $props(); 4 12 </script> 5 13 6 - <Tabs 7 - tabs={[ 8 - { label: "status", icon: faCircleInfo, content: status }, 9 - { label: "compose", icon: faPencil, content: compose }, 10 - { label: "variables", icon: faGears, content: variables }, 11 - ]} 12 - /> 14 + <div class="flexc gap-10"> 15 + <!-- Title Bar --> 16 + <h1 class="flexr gap-10 center fit"> 17 + <Fa icon={faLayerGroup} size="lg" /> 18 + <div style="display: inline-block;"> 19 + <div style="font-size: 22px;">{project.name}</div> 20 + <div class="subtext flexr gap-5"> 21 + <StatusIcon status={project.status} /> 22 + {project.status} 23 + </div> 24 + </div> 25 + </h1> 26 + 27 + <Tabs 28 + tabs={[ 29 + { label: "status", icon: faCircleInfo, content: status }, 30 + { label: "compose", icon: faPencil, content: compose }, 31 + { label: "variables", icon: faGears, content: variables }, 32 + ]} 33 + /> 34 + </div> 13 35 14 36 {#snippet status()} 15 - <h1>Status Tab</h1> 16 - <p>Woah</p> 37 + <StatusTab {project} /> 17 38 {/snippet} 18 39 19 40 {#snippet compose()} 20 - <h1>Compose Tab</h1> 41 + <h2>Compose Tab</h2> 21 42 {/snippet} 22 43 23 44 {#snippet variables()} 24 - <h1>Variables Tab</h1> 45 + <h2>Variables Tab</h2> 25 46 {/snippet}
+2 -2
packages/panel/src/routes/(authenticated)/projects/[project]/+page.ts
··· 1 - import { api } from "$lib"; 2 - import { error } from "@sveltejs/kit"; 3 1 import type { PageLoad } from "./$types"; 2 + import { error } from "@sveltejs/kit"; 3 + import { api } from "$lib"; 4 4 5 5 export const prerender = false; 6 6
+172
packages/panel/src/routes/(authenticated)/projects/[project]/StatusTab.svelte
··· 1 + <script lang="ts"> 2 + import PromiseButton from "$lib/component/PromiseButton.svelte"; 3 + import { faArrowsRotate, faPlay, faRocket, faStop } from "@fortawesome/free-solid-svg-icons"; 4 + import Fa from "svelte-fa"; 5 + import { api } from "$lib"; 6 + import StatusIcon from "$lib/component/StatusIcon.svelte"; 7 + 8 + let { project }: { project: api.LuminaryProject } = $props(); 9 + 10 + let allAction = $derived.by(() => { 11 + let services = Object.values(project.services); 12 + let action = services.pop()?.action; 13 + 14 + if (services.some((service) => service.action !== action)) return "idle"; 15 + return action; 16 + }); 17 + </script> 18 + 19 + <h2>Actions</h2> 20 + <div class="flexr gap-5 wrap"> 21 + <div> 22 + <PromiseButton 23 + style="outline" 24 + disabled={project.busy} 25 + loading={allAction === "starting"} 26 + onclick={() => 27 + api.client.POST("/api/project/{project}/start", { params: { path: { project: project.name } } })} 28 + > 29 + <div class="flexr center gap-10"> 30 + <Fa icon={faPlay} /> 31 + Start All 32 + </div> 33 + </PromiseButton> 34 + </div> 35 + <div> 36 + <PromiseButton 37 + style="outline" 38 + disabled={project.busy} 39 + loading={allAction === "restarting"} 40 + onclick={() => 41 + api.client.POST("/api/project/{project}/restart", { params: { path: { project: project.name } } })} 42 + > 43 + <div class="flexr center gap-10"> 44 + <Fa icon={faArrowsRotate} /> 45 + Restart All 46 + </div> 47 + </PromiseButton> 48 + </div> 49 + <div> 50 + <PromiseButton 51 + style="outline" 52 + disabled={project.busy} 53 + loading={allAction === "stopping"} 54 + onclick={() => 55 + api.client.POST("/api/project/{project}/stop", { params: { path: { project: project.name } } })} 56 + > 57 + <div class="flexr center gap-10"> 58 + <Fa icon={faStop} /> 59 + Stop 60 + </div> 61 + </PromiseButton> 62 + </div> 63 + <div> 64 + <PromiseButton 65 + style="outline" 66 + disabled={project.busy} 67 + onclick={() => 68 + api.client.POST("/api/project/{project}/redeploy", { params: { path: { project: project.name } } })} 69 + > 70 + <div class="flexr center gap-10"> 71 + <Fa icon={faRocket} /> 72 + Redeploy All 73 + </div> 74 + </PromiseButton> 75 + </div> 76 + </div> 77 + 78 + <h2>Services</h2> 79 + 80 + <div class="flexc gap-5"> 81 + {#each Object.values(project.services) as service} 82 + <div class="service flexr gap-10"> 83 + <div class="flex center" style="width: 30px;"> 84 + <StatusIcon status={service.status} /> 85 + </div> 86 + <div class="grow"> 87 + <h3>{service.serviceName}</h3> 88 + <div class="subtext">{service.status}</div> 89 + </div> 90 + 91 + <div class="flexr center gap-5"> 92 + <PromiseButton 93 + style="a" 94 + disabled={service.action !== "idle"} 95 + loading={service.action === "starting"} 96 + onclick={() => 97 + api.client.POST("/api/project/{project}/service/{service}/start", { 98 + params: { path: { project: project.name, service: service.serviceName } }, 99 + })} 100 + > 101 + <div class="flexr center gap-10"> 102 + <Fa icon={faPlay} /> 103 + </div> 104 + </PromiseButton> 105 + <PromiseButton 106 + style="a" 107 + disabled={service.action !== "idle"} 108 + loading={service.action === "restarting"} 109 + onclick={() => 110 + api.client.POST("/api/project/{project}/service/{service}/restart", { 111 + params: { path: { project: project.name, service: service.serviceName } }, 112 + })} 113 + > 114 + <div class="flexr center gap-10"> 115 + <Fa icon={faArrowsRotate} /> 116 + </div> 117 + </PromiseButton> 118 + 119 + <PromiseButton 120 + style="a" 121 + disabled={service.action !== "idle"} 122 + loading={service.action === "stopping"} 123 + onclick={() => 124 + api.client.POST("/api/project/{project}/service/{service}/stop", { 125 + params: { path: { project: project.name, service: service.serviceName } }, 126 + })} 127 + > 128 + <div class="flexr center gap-10"> 129 + <Fa icon={faStop} /> 130 + </div> 131 + </PromiseButton> 132 + 133 + <PromiseButton 134 + style="a" 135 + disabled={service.action !== "idle"} 136 + onclick={() => 137 + api.client.POST("/api/project/{project}/service/{service}/redeploy", { 138 + params: { path: { project: project.name, service: service.serviceName } }, 139 + })} 140 + > 141 + <div class="flexr center gap-10"> 142 + <Fa icon={faRocket} /> 143 + </div> 144 + </PromiseButton> 145 + </div> 146 + </div> 147 + {/each} 148 + </div> 149 + 150 + <style lang="scss"> 151 + h2 { 152 + margin-bottom: 5px; 153 + font-size: 16px; 154 + 155 + &:not(:first-child) { 156 + margin-top: 15px; 157 + } 158 + } 159 + 160 + h3 { 161 + font-size: 14px; 162 + } 163 + 164 + .service { 165 + border: 1px solid var(--surface2); 166 + border-radius: 10px; 167 + 168 + padding: 10px; 169 + 170 + max-width: 1000px; 171 + } 172 + </style>