init from gitlab

This commit is contained in:
texm
2023-04-25 14:33:14 +08:00
parent 6a85a41ff0
commit c8202a5c82
281 changed files with 19861 additions and 1 deletions

7
ui/src/app.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
/*height: 100vh;*/
}

13
ui/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<!--html lang="en" data-theme="light"-->
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/x-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,49 @@
<script>
import Icon from "$common/Icon.svelte";
import { createEventDispatcher } from "svelte";
export let isAuthenticated = false;
export let showExecutionButton = false;
export let executionWindowVisible = false;
const authenticatedLinks = [
{ href: "/settings", text: "Settings" },
{ href: "/logout", text: "Logout" },
];
const linkStyle = "px-3 py-2 btn btn-ghost";
const dispatch = createEventDispatcher();
const executionButtonClicked = () => dispatch("executionButtonClicked");
</script>
<nav
class="bg-neutral text-neutral-content flex flex-col md:flex-row items-center w-full justify-between p-4 mx-auto"
>
<a
class="hover:font-bold inline-flex items-center justify-center btn btn-ghost normal-case text-xl"
href="/">shokku</a
>
{#if showExecutionButton}
<button
class="btn gap-2 hover:btn-secondary"
class:btn-active={executionWindowVisible}
on:click={executionButtonClicked}
>
<Icon type="file-text" />
command output
</button>
{/if}
<div class="flex flex-col">
<ul class="flex flex-col md:flex-row items-center space-x-2 text-sm font-medium">
{#if isAuthenticated}
{#each authenticatedLinks as link}
<li>
<a class={linkStyle} href={link["href"]}>{link["text"]}</a>
</li>
{/each}
{/if}
</ul>
</div>
</nav>

View File

@@ -0,0 +1,47 @@
<script>
import Loader from "$common/Loader.svelte";
export let status = {};
let msgContainer;
let finished = false;
let output = [];
let bg = "bg-base-200";
$: if (status) {
let outputPrev = output.length;
output = status["output"] || [];
finished = status["finished"];
if (status["finished"] && !status["success"]) {
bg = "bg-error";
}
if (msgContainer) {
msgContainer.scroll({
top: msgContainer.scrollHeight,
});
}
}
</script>
<div class="max-w-80 max-h-96 overflow-x-scroll" bind:this={msgContainer}>
{#each output as line, i}
<pre data-prefix={i} class:text-warning={line["type"] === "stderr"}><code
>{line["message"]}</code
></pre>
{/each}
{#if !finished}
<div class="m-4">
<Loader />
</div>
{/if}
</div>
<style lang="postcss">
pre[data-prefix]:before {
content: attr(data-prefix);
width: 2rem;
margin-right: 2ch;
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,88 @@
<script>
import {
commandExecutionIds,
commandExecutions,
executionIdDescriptions,
} from "$lib/stores";
import Icon from "$common/Icon.svelte";
import CommandExecution from "./CommandExecution.svelte";
export let watchingCompleted = false;
let selectedId;
let executions = {};
let ids = [];
$: if ($commandExecutionIds !== null) {
let allIds = new Set();
for (let id in executions) allIds.add(id);
for (let i in $commandExecutionIds) allIds.add($commandExecutionIds[i]);
ids = [...allIds];
let newId;
for (let i in ids) {
const id = ids[i];
if (!executions[id]) newId = id;
executions[id] = $commandExecutions[id] || [];
}
if (newId) selectedId = newId;
if ($commandExecutionIds.length > 0 && !selectedId) {
selectedId = $commandExecutionIds[$commandExecutionIds.length - 1];
}
watchingCompleted = Object.keys(executions).length > 0;
}
const removeSelectedId = () => {
commandExecutionIds.remove(selectedId);
let newExec = {};
let nextId;
for (const id in executions) {
if (id !== selectedId) {
newExec[id] = executions[id];
nextId = id;
}
}
executions = newExec;
selectedId = nextId;
watchingCompleted = Object.keys(executions).length > 0;
};
</script>
<div
class="bg-neutral rounded-lg shadow-lg border-info border-2 text-neutral-content w-full h-fit min-h-16 p-2"
>
<div class="flex flex-row items-center">
<ul class="menu menu-compact menu-horizontal items-center rounded-box p-1">
{#each Object.keys(executions) as id}
<li>
<a
class:active={id === selectedId}
on:click={() => (selectedId = id)}
>
{$executionIdDescriptions[id]}
</a>
</li>
{/each}
</ul>
<div class="flex-grow" />
{#if selectedId && executions[selectedId]}
<div class="items-center">
<button
class="btn btn-sm btn-square hover:btn-error"
on:click={removeSelectedId}
>
<Icon type="delete" size="sm" />
</button>
</div>
{/if}
</div>
{#if selectedId}
<CommandExecution status={executions[selectedId]} />
{/if}
</div>

View File

@@ -0,0 +1,94 @@
<script>
import { afterUpdate, beforeUpdate } from "svelte";
export let onInput;
export let output = [];
let bg = "bg-base-200";
let input = "";
const defaultAppend = (lines) => {
output = [...output, ...lines];
};
const echoInput = (s) => {
defaultAppend({ output: s });
};
$: if (!onInput) onInput = echoInput;
const KEY_ENTER = 13;
const inputKeyPress = (e) => {
if (e.charCode !== KEY_ENTER) return;
onInput(input);
input = "";
};
let autoscroll;
let scrollDiv;
beforeUpdate(() => {
autoscroll =
scrollDiv &&
scrollDiv.offsetHeight + scrollDiv.scrollTop >
scrollDiv.scrollHeight - 20;
});
afterUpdate(() => {
if (autoscroll) scrollDiv.scrollTo(0, scrollDiv.scrollHeight);
});
</script>
<div class="flex flex-col">
<div
class="flex flex-shrink items-center px-2 w-full h-8 bg-neutral-focus text-neutral-content"
>
<slot name="titlebar" />
</div>
<div
class="flex-grow max-w-80 h-80 overflow-x-scroll bg-base-content p-2"
bind:this={scrollDiv}
>
<div class="whitespace-pre-wrap flex flex-col">
{#each output as line, i}
{#if line.input}
<span class="text-base-100/70 mt-2" data-prefix=">"
><code class="text-inherit">{line.input}</code></span
>
{/if}
{#if line.output}
<span class="text-neutral-content"
><code class="text-inherit">{line.output}</code></span
>
{/if}
{#if line.error}
<span class="text-warning"
><code class="text-inherit">{line.error}</code></span
>
{/if}
{/each}
</div>
</div>
<div
class="flex-shrink px-2 flex flex-row gap-2 min-h-12 w-full h-12 bg-neutral-focus text-neutral-content"
>
<span class="text-md self-center">></span>
<input
type="text"
on:keypress={inputKeyPress}
bind:value={input}
class="input px-0 bg-neutral-focus rounded-t-none py-1 input-bordered w-full"
/>
</div>
</div>
<style lang="postcss">
span[data-prefix]:before {
content: attr(data-prefix);
width: 2rem;
margin-right: 1ch;
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,27 @@
<script>
import { createEventDispatcher } from "svelte";
import Icon from "./Icon.svelte";
const dispatch = createEventDispatcher();
export let message;
export let type = "error";
export let canDismiss = false;
const clicked = () => {
if (canDismiss) message = "";
};
</script>
<div class="mt-2" class:hidden={!message || message === ""}>
<div
class="alert alert-error shadow-lg"
class:cursor-pointer={canDismiss}
on:click={clicked}
>
<div>
<Icon {type} />
<span class="text-error-content">{message}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<script>
export let title = "";
export let actionsRight = false;
</script>
<div class="card shadow-lg bg-base-200">
<div class="card-body p-4">
<h2 class="card-title">{title}</h2>
<slot />
<div class="card-actions" class:justify-end={actionsRight}>
<slot name="actions" />
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div class="flex flex-col gap-5">
<slot />
</div>

View File

@@ -0,0 +1,7 @@
<div class="flex flex-row max-h-full justify-center">
<div class="card flex-shrink-0 w-full max-w-xl max-h-full">
<div class="card-body overflow-scroll">
<slot />
</div>
</div>
</div>

View File

@@ -0,0 +1,10 @@
<script>
export let lines = [];
export let prefix = "";
</script>
<div class="mockup-code text-left w-fit">
{#each lines as line}
<pre data-prefix={prefix}><code>{line}</code></pre>
{/each}
</div>

View File

@@ -0,0 +1,53 @@
<script>
import Modal from "./Modal.svelte";
import { createEventDispatcher } from "svelte";
export let name;
export let title = "Confirm";
export let action = "foo the bar";
export let open = false;
export let doingAction = false;
export let extraOption = "";
let extraOptionChecked = false;
const dispatch = createEventDispatcher();
const close = () => (open = false);
const accept = () => {
dispatch("accepted", {
extraOptionChecked,
});
close();
};
</script>
<Modal {name} {title} bind:open preventClose={doingAction}>
<div class="mb-4">
<span class="text-md">Are you sure you want to {action}?</span>
{#if extraOption}
<div
class="form-control w-52 border-2 border-base-200 rounded-lg mt-2 p-2"
>
<label class="label cursor-pointer">
<span class="label-text">{extraOption}</span>
<input
type="checkbox"
bind:checked={extraOptionChecked}
class="checkbox"
/>
</label>
</div>
{/if}
</div>
<button class="btn btn-primary" class:loading={doingAction} on:click={accept}
>Yes</button
>
<button
class="btn btn-secondary btn-ghost"
class:loading={doingAction}
on:click={close}>No</button
>
</Modal>

View File

@@ -0,0 +1,27 @@
<div class="card card-body py-3 px-3 h-full max-h-full text-base-content">
<div class="hidden sm:flex rounded-lg flex-row w-full max-h-full">
<slot name="sidebar" />
<div class="pl-3 h-full w-full flex flex-col overflow-x-scroll">
<div class="w-full">
<slot name="header" />
</div>
<div class="overflow-scroll w-full h-full rounded-lg">
<slot name="content" />
</div>
</div>
</div>
<!-- mobile nav -->
<div class="inline-block sm:hidden">
<slot name="header" />
<slot name="sidebar" />
<div class="overflow-scroll h-full">
<div class="rounded-lg w-full h-full overflow-scroll pb-6">
<slot name="content" />
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<script>
import Icon from "$components/common/Icon.svelte";
export let action;
export let error;
export let errorMessage = null;
let displayMessage = errorMessage;
$: if (error && !errorMessage) {
try {
let parsed = JSON.parse(error.message);
if (parsed.message) {
let code = parsed.error.split(",")[0].substring(5, 8);
displayMessage = `HTTP ${code}: ${parsed.message}`;
} else {
displayMessage = `${parsed.type} error`;
}
} catch {
displayMessage = error;
}
}
</script>
<div class="card bg-error shadow-xl my-2">
<div class="card-body text-error-content">
<div class="grid grid-cols-3 items-center mb-4">
<div class="col-span-2 text-neutral-content">
<span class="text-xl leading-8">Error {action}</span>
</div>
<div class="flex justify-end">
<Icon type="warning" />
</div>
</div>
<p>{displayMessage}</p>
</div>
</div>

View File

@@ -0,0 +1,114 @@
<script>
import CirclePlus from "tabler-icons-svelte/icons/CirclePlus.svelte";
import CircleMinus from "tabler-icons-svelte/icons/CircleMinus.svelte";
import Copy from "tabler-icons-svelte/icons/Copy.svelte";
import Trash from "tabler-icons-svelte/icons/Trash.svelte";
import Cloud from "tabler-icons-svelte/icons/Cloud.svelte";
import RotateClockwise2 from "tabler-icons-svelte/icons/RotateClockwise2.svelte";
import Stack from "tabler-icons-svelte/icons/Stack.svelte";
import Folder from "tabler-icons-svelte/icons/Folder.svelte";
import Link from "tabler-icons-svelte/icons/Link.svelte";
import Unlink from "tabler-icons-svelte/icons/Unlink.svelte";
import DeviceFloppy from "tabler-icons-svelte/icons/DeviceFloppy.svelte";
import Pencil from "tabler-icons-svelte/icons/Pencil.svelte";
import Tool from "tabler-icons-svelte/icons/Tool.svelte";
import Box from "tabler-icons-svelte/icons/Box.svelte";
import ChartCircles from "tabler-icons-svelte/icons/ChartCircles.svelte";
import LayersSubtract from "tabler-icons-svelte/icons/LayersSubtract.svelte";
import InfoCircle from "tabler-icons-svelte/icons/InfoCircle.svelte";
import FileText from "tabler-icons-svelte/icons/FileText.svelte";
import AlertCircle from "tabler-icons-svelte/icons/AlertCircle.svelte";
import BrandGit from "tabler-icons-svelte/icons/BrandGit.svelte";
import Database from "tabler-icons-svelte/icons/Database.svelte";
import Settings from "tabler-icons-svelte/icons/Settings.svelte";
import PlayerPlay from "tabler-icons-svelte/icons/PlayerPlay.svelte";
import PlayerStop from "tabler-icons-svelte/icons/PlayerStop.svelte";
import Upload from "tabler-icons-svelte/icons/Upload.svelte";
import Terminal2 from "tabler-icons-svelte/icons/Terminal2.svelte";
import ArrowLeft from "tabler-icons-svelte/icons/ArrowLeft.svelte";
import ArrowRight from "tabler-icons-svelte/icons/ArrowRight.svelte";
import Key from "tabler-icons-svelte/icons/Key.svelte";
import ExternalLink from "tabler-icons-svelte/icons/ExternalLink.svelte";
// using https://devicon.dev
import Postgres from "./devicons/Postgres.svelte";
import Redis from "./devicons/Redis.svelte";
import SQLite from "./devicons/SQLite.svelte";
import MySQL from "./devicons/MySQL.svelte";
import MongoDB from "./devicons/MongoDB.svelte";
import Github from "./devicons/Github.svelte";
import Docker from "./devicons/Docker.svelte";
import Go from "./devicons/Go.svelte";
import Javascript from "./devicons/Javascript.svelte";
import Python from "./devicons/Python.svelte";
import Ruby from "./devicons/Ruby.svelte";
export let type;
export let size = "md";
const icons = {
add: CirclePlus,
remove: CircleMinus,
delete: Trash,
"external-link": ExternalLink,
docker: Docker,
go: Go,
javascript: Javascript,
python: Python,
ruby: Ruby,
cloud: Cloud,
copy: Copy,
restart: RotateClockwise2,
build: Stack,
rebuild: Stack,
folder: Folder,
link: Link,
unlink: Unlink,
save: DeviceFloppy,
plus: CirclePlus,
edit: Pencil,
cube: Box,
left: ArrowLeft,
right: ArrowRight,
key: Key,
circles: ChartCircles,
layers: LayersSubtract,
info: InfoCircle,
"file-text": FileText,
warning: AlertCircle,
error: AlertCircle,
git: BrandGit,
terminal: Terminal2,
database: Database,
spanner: Tool,
settings: Settings,
start: PlayerPlay,
stop: PlayerStop,
upload: Upload,
postgres: Postgres,
redis: Redis,
sqlite: SQLite,
mysql: MySQL,
mongodb: MongoDB,
mongo: MongoDB,
github: Github,
};
const sizeClasses = {
xs: "w-3 h-3",
sm: "w-5 h-5",
md: "w-6 h-6",
lg: "w-8 h-8",
xl: "w-10 h-10",
};
const sizes = {
xs: "14",
sm: "20",
md: "24",
lg: "32",
xl: "38",
};
</script>
<div class={sizeClasses[size]}>
<svelte:component this={icons[type]} size={sizes[size]} />
</div>

View File

@@ -0,0 +1,145 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { setAppConfig } from "$lib/api";
import Icon from "$common/Icon.svelte";
import ConfirmationModal from "$common/ConfirmationModal.svelte";
import Alert from "$common/Alert.svelte";
export let vars;
export let saving = false;
export let stateDirty = false;
export let showSaveButton = false;
export let neutralButtons = false;
let varsList = [];
onMount(() => {
for (let key of Object.keys(vars)) {
varsList.push([key, vars[key]]);
}
varsList = varsList;
});
let confirmationModalOpen = false;
let deletingKey = "";
let deletingIndex = -1;
const confirmRemoveKey = (index) => {
return () => {
deletingIndex = index;
deletingKey = varsList[index][0];
if (varsList[index][0] === "" && varsList[index][1] === "") {
removeKey();
return;
}
confirmationModalOpen = true;
};
};
const removeKey = () => {
varsList.splice(deletingIndex, 1);
varsList = varsList;
checkStateDirty();
deletingIndex = -1;
deletingKey = "";
};
const addNewEnvVar = () => {
varsList.push(["", ""]);
varsList = varsList;
};
const checkStateDirty = () => {
stateDirty = false;
if (varsList.length !== Object.keys(vars).length) {
stateDirty = true;
return;
}
for (let [key, val] of varsList) {
if (!(key in vars) || vars[key] !== val) {
stateDirty = true;
return;
}
}
};
const dispatch = createEventDispatcher();
const pairChange = (position) => {
return (e) => {
const index = e.target.dataset["index"];
varsList[index][position] = e.target.value;
checkStateDirty();
dispatch("changed", varsList);
};
};
const keyChanged = pairChange(0);
const valChanged = pairChange(1);
</script>
<div class="w-full grid grid-cols-2 gap-2 mb-2">
{#each varsList as pair, i}
<div>
<label class="input-group input-group-md">
<span>Key</span>
<input
type="text"
on:change={keyChanged}
data-index={i}
value={pair[0]}
class="input input-md input-bordered w-full"
/>
</label>
</div>
<div class="flex flex-row">
<div class="flex-grow">
<label class="input-group input-group-md">
<span>Value</span>
<input
type="text"
on:change={valChanged}
data-index={i}
value={pair[1]}
class="input input-md input-bordered w-full"
/>
</label>
</div>
<button
class="btn btn-ghost btn-circle text-error ml-2"
on:click={confirmRemoveKey(i)}
>
<Icon type="remove" />
</button>
</div>
{/each}
</div>
<div class="">
<button
class="btn gap-2"
class:btn-neutral={neutralButtons}
class:text-neutral={!neutralButtons}
on:click={addNewEnvVar}
>
Add
<Icon type="add" />
</button>
{#if stateDirty && showSaveButton}
<button
class="btn btn-primary mt-2 gap-2"
class:loading={saving}
on:click={() => dispatch("save", varsList)}
>
Save
<Icon type="save" />
</button>
{/if}
</div>
<ConfirmationModal
name="delete-env-var"
action="delete '{deletingKey}'"
bind:open={confirmationModalOpen}
on:accepted={removeKey}
/>

View File

@@ -0,0 +1,105 @@
<div class="three-body">
<div class="three-body__dot after:bg-accent" />
<div class="three-body__dot after:bg-accent" />
<div class="three-body__dot after:bg-accent" />
</div>
<style lang="postcss">
.three-body {
--uib-size: 35px;
--uib-speed: 1.1s;
position: relative;
height: var(--uib-size);
width: var(--uib-size);
animation: spin calc(var(--uib-speed) * 2.5) infinite linear;
}
.three-body__dot {
position: absolute;
height: 100%;
width: 30%;
}
.three-body__dot:after {
content: "";
position: absolute;
height: 0%;
width: 100%;
padding-bottom: 100%;
border-radius: 50%;
}
.three-body__dot:nth-child(1) {
bottom: 5%;
left: 0;
transform: rotate(60deg);
transform-origin: 50% 85%;
}
.three-body__dot:nth-child(1)::after {
bottom: 0;
left: 0;
animation: wobble1 var(--uib-speed) infinite ease-in-out;
animation-delay: calc(var(--uib-speed) * -0.3);
}
.three-body__dot:nth-child(2) {
bottom: 5%;
right: 0;
transform: rotate(-60deg);
transform-origin: 50% 85%;
}
.three-body__dot:nth-child(2)::after {
bottom: 0;
left: 0;
animation: wobble1 var(--uib-speed) infinite calc(var(--uib-speed) * -0.15)
ease-in-out;
}
.three-body__dot:nth-child(3) {
bottom: -5%;
left: 0;
transform: translateX(116.666%);
}
.three-body__dot:nth-child(3)::after {
top: 0;
left: 0;
animation: wobble2 var(--uib-speed) infinite ease-in-out;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes wobble1 {
0%,
100% {
transform: translateY(0%) scale(1);
opacity: 1;
}
50% {
transform: translateY(-66%) scale(0.65);
opacity: 0.8;
}
}
@keyframes wobble2 {
0%,
100% {
transform: translateY(0%) scale(1);
opacity: 1;
}
50% {
transform: translateY(66%) scale(0.65);
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script>
export let logs;
</script>
<div class="overflow-x-scroll h-full bg-neutral px-4 rounded-lg">
<div class="py-4 h-full text-neutral-content">
{#each logs as line, i}
<pre data-prefix={i}><code>{line.trim()}</code></pre>
{/each}
</div>
</div>
<style lang="postcss">
pre[data-prefix]:before {
content: attr(data-prefix);
width: 2rem;
margin-right: 2ch;
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,45 @@
<script>
export let name;
export let title;
export let open = false;
export let preventClose = false;
let modalId = name + "-modal";
const closeModal = () => {
if (preventClose) return;
open = false;
};
const checkCloseModal = (e) => {
if (e.target.id === modalId) {
closeModal();
e.stopPropagation();
}
};
</script>
<div
class="modal modal-middle"
class:modal-open={open}
id={modalId}
on:click={checkCloseModal}
>
<div class="modal-box p-6 w-auto max-w-full">
<div class="mb-4">
<div class="h-full text-base-content">
<span class="text-xl leading-7">{title}</span>
</div>
<button
class="btn btn-sm btn-circle absolute right-6 top-5"
class:btn-loading={preventClose}
for={modalId}
on:click={closeModal}
>
</button>
</div>
<slot />
</div>
</div>

View File

@@ -0,0 +1,15 @@
<script>
import Error from "./Error.svelte";
import Loader from "./Loader.svelte";
export let query;
export let action = "";
</script>
{#if $query.isLoading}
<Loader />
{:else if $query.isError}
<Error error={$query.error} {action} />
{:else}
<slot />
{/if}

View File

@@ -0,0 +1,57 @@
<script>
import { page } from "$app/stores";
export let prefix = "";
export let pages = [];
const prefixLen = prefix.length + 1;
const getCurrentPage = () => $page.url.pathname.slice(prefixLen);
let currentPath = "";
let currentPage = getCurrentPage();
let pageLabel = "";
let openMobileMenu;
$: if ($page.url.pathname !== currentPath) {
currentPath = $page.url.pathname;
currentPage = getCurrentPage();
for (let i = 0; i < pages.length; i++) {
let info = pages[i];
if (info.path === currentPage) pageLabel = info.name;
}
openMobileMenu = false;
}
</script>
<div
class="menu rounded-box hidden sm:inline-block min-w-fit max-h-full bg-base-200 overflow-y-scroll shadow-lg h-fit"
>
{#each pages as info, i}
<li class="">
<a href="{prefix}/{info.path}" class:active={info.path === currentPage}>
<span>{info.name}</span>
</a>
</li>
{/each}
</div>
<div class="inline-block sm:hidden w-full">
<div class="collapse collapse-arrow border border-base-300 w-full">
<input type="checkbox" class="peer" bind:checked={openMobileMenu} />
<div class="collapse-title text-xl font-medium peer-checked:bg-base-300">
{pageLabel}
</div>
<div class="collapse-content peer-checked:bg-base-300">
<div class="menu w-full">
{#each pages as info, i}
<li>
<a
href="{prefix}/{info.path}"
class:active={info.path === currentPage}
>
<span>{info.name}</span>
</a>
</li>
{/each}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,92 @@
<script>
import { fly } from "svelte/transition";
import { createEventDispatcher } from "svelte";
import Card from "$common/Card.svelte";
export let steps = [];
export let props = {};
export let data = {};
export let loading;
export let confirmButtonText = "Confirm";
let finishedStep = {};
let currentStep = 0;
const canSetStep = (step) => {
if (loading) return false;
if (step <= 1) return finishedStep[0];
return finishedStep[step - 1] && canSetStep(step - 1);
};
const updateStepStatus = (step, { complete }) =>
(finishedStep[step] = complete);
const maybeSetStep = (step) => {
if (!canSetStep(step)) return;
currentStep = step;
};
const dispatch = createEventDispatcher();
const confirmClicked = () => dispatch("complete");
</script>
<div class="flex flex-row gap-4 max-h-full w-full">
<div class="flex bg-base-200 shadow-xl rounded-lg py-4 px-2 h-fit">
<ul class="steps steps-vertical">
{#each steps as step, i}
<li
class="step px-4"
class:cursor-pointer={!loading && finishedStep[i]}
class:step-primary={finishedStep[i]}
on:click={() => maybeSetStep(i)}
data-content={i === currentStep ? "●" : null}
>
<span>{step.label}</span>
</li>
{/each}
</ul>
</div>
<div
class="flex flex-col bg-base-200 p-4 rounded-lg shadow-xl flex-grow max-h-full"
>
<div class="overflow-scroll" in:fly={{ y: 100, duration: 250 }}>
<svelte:component
this={steps[currentStep].component}
{props}
on:statusChange={(e) => updateStepStatus(currentStep, e.detail)}
bind:data
/>
</div>
<div class="h-fit">
<slot name="errors" />
<div class="flex gap-2 mt-2 h-fit">
<button
class="btn"
class:btn-disabled={loading || currentStep === 0}
on:click={() => maybeSetStep(currentStep - 1)}
>
Previous
</button>
<button
class="btn btn-primary"
class:hidden={currentStep + 1 === steps.length}
class:btn-disabled={!finishedStep[currentStep]}
on:click={() => maybeSetStep(currentStep + 1)}
>
Next
</button>
<button
class="btn btn-primary"
class:hidden={currentStep + 1 !== steps.length}
class:loading
on:click={() => confirmClicked()}
>
{confirmButtonText}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div class="bg-warning p-4">
<span class="text-lg text-warning-content">TODO</span>
</div>

View File

@@ -0,0 +1,15 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
d="M124.8 52.1c-4.3-2.5-10-2.8-14.8-1.4-.6-5.2-4-9.7-8-12.9l-1.6-1.3-1.4 1.6c-2.7 3.1-3.5 8.3-3.1 12.3.3 2.9 1.2 5.9 3 8.3-1.4.8-2.9 1.9-4.3 2.4-2.8 1-5.9 2-8.9 2H79V49H66V24H51v12H26v13H13v14H1.8l-.2 1.5c-.5 6.4.3 12.6 3 18.5l1.1 2.2.1.2c7.9 13.4 21.7 19 36.8 19 29.2 0 53.3-13.1 64.3-40.6 7.4.4 15-1.8 18.6-8.9l.9-1.8-1.6-1zM28 39h10v11H28V39zm13.1 44.2c0 1.7-1.4 3.1-3.1 3.1-1.7 0-3.1-1.4-3.1-3.1 0-1.7 1.4-3.1 3.1-3.1 1.7.1 3.1 1.4 3.1 3.1zM28 52h10v11H28V52zm-13 0h11v11H15V52zm27.7 50.2c-15.8-.1-24.3-5.4-31.3-12.4 2.1.1 4.1.2 5.9.2 1.6 0 3.2 0 4.7-.1 3.9-.2 7.3-.7 10.1-1.5 2.3 5.3 6.5 10.2 14 13.8h-3.4zM51 63H40V52h11v11zm0-13H40V39h11v11zm13 13H53V52h11v11zm0-13H53V39h11v11zm0-13H53V26h11v11zm13 26H66V52h11v11zM38.8 81.2c-.2-.1-.5-.2-.8-.2-1.2 0-2.2 1-2.2 2.2 0 1.2 1 2.2 2.2 2.2s2.2-1 2.2-2.2c0-.3-.1-.6-.2-.8-.2.3-.4.5-.8.5-.5 0-.9-.4-.9-.9.1-.4.3-.7.5-.8z"
fill="#019BC6"
/>
</svg>

View File

@@ -0,0 +1,18 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"
/><path
d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm2.446 2.729c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zM31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm3.261 3.361c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm4.5 1.951c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm4.943.361c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm4.598-.782c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"
/>
</svg>

View File

@@ -0,0 +1,19 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<g fill="#00acd7" fill-rule="evenodd"
><path
d="M11.156 54.829c-.243 0-.303-.122-.182-.303l1.273-1.637c.12-.182.424-.303.666-.303H34.55c.243 0 .303.182.182.364l-1.03 1.576c-.121.181-.424.363-.606.363zM2.004 60.404c-.242 0-.303-.12-.182-.303l1.273-1.636c.121-.182.424-.303.667-.303h27.636c.242 0 .364.182.303.364l-.485 1.454c-.06.243-.303.364-.545.364zM16.67 65.98c-.242 0-.302-.182-.181-.364l.848-1.515c.122-.182.364-.363.607-.363h12.12c.243 0 .364.181.364.424l-.12 1.454c0 .243-.243.425-.425.425zM79.58 53.738c-3.819.97-6.425 1.697-10.182 2.666-.91.243-.97.303-1.758-.606-.909-1.03-1.576-1.697-2.848-2.303-3.819-1.878-7.516-1.333-10.97.91-4.121 2.666-6.242 6.605-6.182 11.514.06 4.849 3.394 8.849 8.182 9.516 4.121.545 7.576-.91 10.303-4 .545-.667 1.03-1.394 1.636-2.243H56.064c-1.272 0-1.575-.788-1.151-1.818.788-1.879 2.242-5.03 3.09-6.606.183-.364.607-.97 1.516-.97h22.06c-.12 1.637-.12 3.273-.363 4.91-.667 4.363-2.303 8.363-4.97 11.878-4.364 5.758-10.06 9.333-17.273 10.303-5.939.788-11.454-.364-16.302-4-4.485-3.394-7.03-7.879-7.697-13.454-.788-6.606 1.151-12.546 5.151-17.758 4.303-5.636 10-9.212 16.97-10.485 5.697-1.03 11.151-.363 16.06 2.97 3.212 2.121 5.515 5.03 7.03 8.545.364.546.122.849-.606 1.03z"
/><path
d="M99.64 87.253c-5.515-.122-10.546-1.697-14.788-5.334-3.576-3.09-5.818-7.03-6.545-11.697-1.091-6.848.787-12.909 4.909-18.302 4.424-5.819 9.757-8.849 16.97-10.122 6.181-1.09 12-.484 17.272 3.091 4.788 3.273 7.757 7.697 8.545 13.515 1.03 8.182-1.333 14.849-6.97 20.546-4 4.06-8.909 6.606-14.545 7.757-1.636.303-3.273.364-4.848.546zm14.424-24.485c-.06-.788-.06-1.394-.182-2-1.09-6-6.606-9.394-12.363-8.06-5.637 1.272-9.273 4.848-10.606 10.545-1.091 4.727 1.212 9.515 5.575 11.454 3.334 1.455 6.667 1.273 9.879-.363 4.788-2.485 7.394-6.364 7.697-11.576z"
fill-rule="nonzero"
/></g
>
</svg>

View File

@@ -0,0 +1,15 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path fill="#F0DB4F" d="M1.408 1.408h125.184v125.185H1.408z" /><path
fill="#323330"
d="M116.347 96.736c-.917-5.711-4.641-10.508-15.672-14.981-3.832-1.761-8.104-3.022-9.377-5.926-.452-1.69-.512-2.642-.226-3.665.821-3.32 4.784-4.355 7.925-3.403 2.023.678 3.938 2.237 5.093 4.724 5.402-3.498 5.391-3.475 9.163-5.879-1.381-2.141-2.118-3.129-3.022-4.045-3.249-3.629-7.676-5.498-14.756-5.355l-3.688.477c-3.534.893-6.902 2.748-8.877 5.235-5.926 6.724-4.236 18.492 2.975 23.335 7.104 5.332 17.54 6.545 18.873 11.531 1.297 6.104-4.486 8.08-10.234 7.378-4.236-.881-6.592-3.034-9.139-6.949-4.688 2.713-4.688 2.713-9.508 5.485 1.143 2.499 2.344 3.63 4.26 5.795 9.068 9.198 31.76 8.746 35.83-5.176.165-.478 1.261-3.666.38-8.581zM69.462 58.943H57.753l-.048 30.272c0 6.438.333 12.34-.714 14.149-1.713 3.558-6.152 3.117-8.175 2.427-2.059-1.012-3.106-2.451-4.319-4.485-.333-.584-.583-1.036-.667-1.071l-9.52 5.83c1.583 3.249 3.915 6.069 6.902 7.901 4.462 2.678 10.459 3.499 16.731 2.059 4.082-1.189 7.604-3.652 9.448-7.401 2.666-4.915 2.094-10.864 2.07-17.444.06-10.735.001-21.468.001-32.237z"
/>
</svg>

View File

@@ -0,0 +1,92 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#439934"
d="M88.038 42.812c1.605 4.643 2.761 9.383 3.141 14.296.472 6.095.256 12.147-1.029 18.142-.035.165-.109.32-.164.48-.403.001-.814-.049-1.208.012-3.329.523-6.655 1.065-9.981 1.604-3.438.557-6.881 1.092-10.313 1.687-1.216.21-2.721-.041-3.212 1.641-.014.046-.154.054-.235.08l.166-10.051-.169-24.252 1.602-.275c2.62-.429 5.24-.864 7.862-1.281 3.129-.497 6.261-.98 9.392-1.465 1.381-.215 2.764-.412 4.148-.618z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#45A538"
d="M61.729 110.054c-1.69-1.453-3.439-2.842-5.059-4.37-8.717-8.222-15.093-17.899-18.233-29.566-.865-3.211-1.442-6.474-1.627-9.792-.13-2.322-.318-4.665-.154-6.975.437-6.144 1.325-12.229 3.127-18.147l.099-.138c.175.233.427.439.516.702 1.759 5.18 3.505 10.364 5.242 15.551 5.458 16.3 10.909 32.604 16.376 48.9.107.318.384.579.583.866l-.87 2.969z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#46A037"
d="M88.038 42.812c-1.384.206-2.768.403-4.149.616-3.131.485-6.263.968-9.392 1.465-2.622.417-5.242.852-7.862 1.281l-1.602.275-.012-1.045c-.053-.859-.144-1.717-.154-2.576-.069-5.478-.112-10.956-.18-16.434-.042-3.429-.105-6.857-.175-10.285-.043-2.13-.089-4.261-.185-6.388-.052-1.143-.236-2.28-.311-3.423-.042-.657.016-1.319.029-1.979.817 1.583 1.616 3.178 2.456 4.749 1.327 2.484 3.441 4.314 5.344 6.311 7.523 7.892 12.864 17.068 16.193 27.433z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#409433"
d="M65.036 80.753c.081-.026.222-.034.235-.08.491-1.682 1.996-1.431 3.212-1.641 3.432-.594 6.875-1.13 10.313-1.687 3.326-.539 6.652-1.081 9.981-1.604.394-.062.805-.011 1.208-.012-.622 2.22-1.112 4.488-1.901 6.647-.896 2.449-1.98 4.839-3.131 7.182a49.142 49.142 0 01-6.353 9.763c-1.919 2.308-4.058 4.441-6.202 6.548-1.185 1.165-2.582 2.114-3.882 3.161l-.337-.23-1.214-1.038-1.256-2.753a41.402 41.402 0 01-1.394-9.838l.023-.561.171-2.426c.057-.828.133-1.655.168-2.485.129-2.982.241-5.964.359-8.946z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#4FAA41"
d="M65.036 80.753c-.118 2.982-.23 5.964-.357 8.947-.035.83-.111 1.657-.168 2.485l-.765.289c-1.699-5.002-3.399-9.951-5.062-14.913-2.75-8.209-5.467-16.431-8.213-24.642a4498.887 4498.887 0 00-6.7-19.867c-.105-.31-.407-.552-.617-.826l4.896-9.002c.168.292.39.565.496.879a6167.476 6167.476 0 016.768 20.118c2.916 8.73 5.814 17.467 8.728 26.198.116.349.308.671.491 1.062l.67-.78-.167 10.052z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#4AA73C"
d="M43.155 32.227c.21.274.511.516.617.826a4498.887 4498.887 0 016.7 19.867c2.746 8.211 5.463 16.433 8.213 24.642 1.662 4.961 3.362 9.911 5.062 14.913l.765-.289-.171 2.426-.155.559c-.266 2.656-.49 5.318-.814 7.968-.163 1.328-.509 2.632-.772 3.947-.198-.287-.476-.548-.583-.866-5.467-16.297-10.918-32.6-16.376-48.9a3888.972 3888.972 0 00-5.242-15.551c-.089-.263-.34-.469-.516-.702l3.272-8.84z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#57AE47"
d="M65.202 70.702l-.67.78c-.183-.391-.375-.714-.491-1.062-2.913-8.731-5.812-17.468-8.728-26.198a6167.476 6167.476 0 00-6.768-20.118c-.105-.314-.327-.588-.496-.879l6.055-7.965c.191.255.463.482.562.769 1.681 4.921 3.347 9.848 5.003 14.778 1.547 4.604 3.071 9.215 4.636 13.813.105.308.47.526.714.786l.012 1.045c.058 8.082.115 16.167.171 24.251z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#60B24F"
d="M65.021 45.404c-.244-.26-.609-.478-.714-.786-1.565-4.598-3.089-9.209-4.636-13.813-1.656-4.93-3.322-9.856-5.003-14.778-.099-.287-.371-.514-.562-.769 1.969-1.928 3.877-3.925 5.925-5.764 1.821-1.634 3.285-3.386 3.352-5.968.003-.107.059-.214.145-.514l.519 1.306c-.013.661-.072 1.322-.029 1.979.075 1.143.259 2.28.311 3.423.096 2.127.142 4.258.185 6.388.069 3.428.132 6.856.175 10.285.067 5.478.111 10.956.18 16.434.008.861.098 1.718.152 2.577z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#A9AA88"
d="M62.598 107.085c.263-1.315.609-2.62.772-3.947.325-2.649.548-5.312.814-7.968l.066-.01.066.011a41.402 41.402 0 001.394 9.838c-.176.232-.425.439-.518.701-.727 2.05-1.412 4.116-2.143 6.166-.1.28-.378.498-.574.744l-.747-2.566.87-2.969z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#B6B598"
d="M62.476 112.621c.196-.246.475-.464.574-.744.731-2.05 1.417-4.115 2.143-6.166.093-.262.341-.469.518-.701l1.255 2.754c-.248.352-.59.669-.728 1.061l-2.404 7.059c-.099.283-.437.483-.663.722l-.695-3.985z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#C2C1A7"
d="M63.171 116.605c.227-.238.564-.439.663-.722l2.404-7.059c.137-.391.48-.709.728-1.061l1.215 1.037c-.587.58-.913 1.25-.717 2.097l-.369 1.208c-.168.207-.411.387-.494.624-.839 2.403-1.64 4.819-2.485 7.222-.107.305-.404.544-.614.812-.109-1.387-.22-2.771-.331-4.158z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#CECDB7"
d="M63.503 120.763c.209-.269.506-.508.614-.812.845-2.402 1.646-4.818 2.485-7.222.083-.236.325-.417.494-.624l-.509 5.545c-.136.157-.333.294-.398.477-.575 1.614-1.117 3.24-1.694 4.854-.119.333-.347.627-.525.938-.158-.207-.441-.407-.454-.623-.051-.841-.016-1.688-.013-2.533z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#DBDAC7"
d="M63.969 123.919c.178-.312.406-.606.525-.938.578-1.613 1.119-3.239 1.694-4.854.065-.183.263-.319.398-.477l.012 3.64-1.218 3.124-1.411-.495z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#EBE9DC"
d="M65.38 124.415l1.218-3.124.251 3.696-1.469-.572z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#CECDB7"
d="M67.464 110.898c-.196-.847.129-1.518.717-2.097l.337.23-1.054 1.867z"
/><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#4FAA41"
d="M64.316 95.172l-.066-.011-.066.01.155-.559-.023.56z"
/>
</svg>

View File

@@ -0,0 +1,15 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
fill="currentColor"
d="M116.948 97.807c-6.863-.187-12.104.452-16.585 2.341-1.273.537-3.305.552-3.513 2.147.7.733.809 1.829 1.365 2.731 1.07 1.73 2.876 4.052 4.488 5.268 1.762 1.33 3.577 2.751 5.465 3.902 3.358 2.047 7.107 3.217 10.34 5.268 1.906 1.21 3.799 2.733 5.658 4.097.92.675 1.537 1.724 2.732 2.147v-.194c-.628-.8-.79-1.898-1.366-2.733l-2.537-2.537c-2.48-3.292-5.629-6.184-8.976-8.585-2.669-1.916-8.642-4.504-9.755-7.609l-.195-.195c1.892-.214 4.107-.898 5.854-1.367 2.934-.786 5.556-.583 8.585-1.365l4.097-1.171v-.78c-1.531-1.571-2.623-3.651-4.292-5.073-4.37-3.72-9.138-7.437-14.048-10.537-2.724-1.718-6.089-2.835-8.976-4.292-.971-.491-2.677-.746-3.318-1.562-1.517-1.932-2.342-4.382-3.511-6.633-2.449-4.717-4.854-9.868-7.024-14.831-1.48-3.384-2.447-6.72-4.293-9.756-8.86-14.567-18.396-23.358-33.169-32-3.144-1.838-6.929-2.563-10.929-3.513-2.145-.129-4.292-.26-6.438-.391-1.311-.546-2.673-2.149-3.902-2.927C17.811 4.565 5.257-2.16 1.633 6.682c-2.289 5.581 3.421 11.025 5.462 13.854 1.434 1.982 3.269 4.207 4.293 6.438.674 1.467.79 2.938 1.367 4.489 1.417 3.822 2.652 7.98 4.487 11.511.927 1.788 1.949 3.67 3.122 5.268.718.981 1.951 1.413 2.145 2.927-1.204 1.686-1.273 4.304-1.95 6.44-3.05 9.615-1.899 21.567 2.537 28.683 1.36 2.186 4.567 6.871 8.975 5.073 3.856-1.57 2.995-6.438 4.098-10.732.249-.973.096-1.689.585-2.341v.195l3.513 7.024c2.6 4.187 7.212 8.562 11.122 11.514 2.027 1.531 3.623 4.177 6.244 5.073v-.196h-.195c-.508-.791-1.303-1.119-1.951-1.755-1.527-1.497-3.225-3.358-4.487-5.073-3.556-4.827-6.698-10.11-9.561-15.609-1.368-2.627-2.557-5.523-3.709-8.196-.444-1.03-.438-2.589-1.364-3.122-1.263 1.958-3.122 3.542-4.098 5.854-1.561 3.696-1.762 8.204-2.341 12.878-.342.122-.19.038-.391.194-2.718-.655-3.672-3.452-4.683-5.853-2.554-6.07-3.029-15.842-.781-22.829.582-1.809 3.21-7.501 2.146-9.172-.508-1.666-2.184-2.63-3.121-3.903-1.161-1.574-2.319-3.646-3.124-5.464-2.09-4.731-3.066-10.044-5.267-14.828-1.053-2.287-2.832-4.602-4.293-6.634-1.617-2.253-3.429-3.912-4.683-6.635-.446-.968-1.051-2.518-.391-3.513.21-.671.508-.951 1.171-1.17 1.132-.873 4.284.29 5.462.779 3.129 1.3 5.741 2.538 8.392 4.294 1.271.844 2.559 2.475 4.097 2.927h1.756c2.747.631 5.824.195 8.391.975 4.536 1.378 8.601 3.523 12.292 5.854 11.246 7.102 20.442 17.21 26.732 29.269 1.012 1.942 1.45 3.794 2.341 5.854 1.798 4.153 4.063 8.426 5.852 12.488 1.786 4.052 3.526 8.141 6.05 11.513 1.327 1.772 6.451 2.723 8.781 3.708 1.632.689 4.307 1.409 5.854 2.34 2.953 1.782 5.815 3.903 8.586 5.855 1.383.975 5.64 3.116 5.852 4.879zM29.729 23.466c-1.431-.027-2.443.156-3.513.389v.195h.195c.683 1.402 1.888 2.306 2.731 3.513.65 1.367 1.301 2.732 1.952 4.097l.194-.193c1.209-.853 1.762-2.214 1.755-4.294-.484-.509-.555-1.147-.975-1.755-.556-.811-1.635-1.272-2.339-1.952z"
/>
</svg>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
fill="#FFD845"
d="M49.33 62h29.159C86.606 62 93 55.132 93 46.981V19.183c0-7.912-6.632-13.856-14.555-15.176-5.014-.835-10.195-1.215-15.187-1.191-4.99.023-9.612.448-13.805 1.191C37.098 6.188 35 10.758 35 19.183V30h29v4H23.776c-8.484 0-15.914 5.108-18.237 14.811-2.681 11.12-2.8 17.919 0 29.53C7.614 86.983 12.569 93 21.054 93H31V79.952C31 70.315 39.428 62 49.33 62zm-1.838-39.11c-3.026 0-5.478-2.479-5.478-5.545 0-3.079 2.451-5.581 5.478-5.581 3.015 0 5.479 2.502 5.479 5.581-.001 3.066-2.465 5.545-5.479 5.545zm74.789 25.921C120.183 40.363 116.178 34 107.682 34H97v12.981C97 57.031 88.206 65 78.489 65H49.33C41.342 65 35 72.326 35 80.326v27.8c0 7.91 6.745 12.564 14.462 14.834 9.242 2.717 17.994 3.208 29.051 0C85.862 120.831 93 116.549 93 108.126V97H64v-4h43.682c8.484 0 11.647-5.776 14.599-14.66 3.047-9.145 2.916-17.799 0-29.529zm-41.955 55.606c3.027 0 5.479 2.479 5.479 5.547 0 3.076-2.451 5.579-5.479 5.579-3.015 0-5.478-2.502-5.478-5.579 0-3.068 2.463-5.547 5.478-5.547z"
/>
</svg>

View File

@@ -0,0 +1,36 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
fill="#A41E11"
d="M121.8 93.1c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.9-11.5 3.8-17.3 1S13 98.1 6.3 94.9c-3.3-1.6-5-2.9-5-4.2V78s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9v12.5c0 1.3-1.5 2.7-4.9 4.4z"
/><path
fill="#D82C20"
d="M121.8 80.5C115.1 84 80.4 98.2 73 102.1c-7.4 3.9-11.5 3.8-17.3 1-5.8-2.8-42.7-17.7-49.4-20.9C-.3 79-.5 76.8 6 74.3c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.9z"
/><path
fill="#A41E11"
d="M121.8 72.5C115.1 76 80.4 90.2 73 94.1c-7.4 3.8-11.5 3.8-17.3 1C49.9 92.3 13 77.4 6.3 74.2c-3.3-1.6-5-2.9-5-4.2V57.3s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9V68c0 1.3-1.5 2.7-4.9 4.5z"
/><path
fill="#D82C20"
d="M121.8 59.8c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.8-11.5 3.8-17.3 1C49.9 79.6 13 64.7 6.3 61.5s-6.8-5.4-.3-7.9c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.9z"
/><path
fill="#A41E11"
d="M121.8 51c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.8-11.5 3.8-17.3 1C49.9 70.9 13 56 6.3 52.8c-3.3-1.6-5.1-2.9-5.1-4.2V35.9s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9v12.5c.1 1.3-1.4 2.6-4.8 4.4z"
/><path
fill="#D82C20"
d="M121.8 38.3C115.1 41.8 80.4 56 73 59.9c-7.4 3.8-11.5 3.8-17.3 1S13 43.3 6.3 40.1s-6.8-5.4-.3-7.9c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.8z"
/><path
fill="#fff"
d="M80.4 26.1l-10.8 1.2-2.5 5.8-3.9-6.5-12.5-1.1 9.3-3.4-2.8-5.2 8.8 3.4 8.2-2.7L72 23zM66.5 54.5l-20.3-8.4 29.1-4.4z"
/><ellipse fill="#fff" cx="38.4" cy="35.4" rx="15.5" ry="6" /><path
fill="#7A0C00"
d="M93.3 27.7l17.2 6.8-17.2 6.8z"
/><path fill="#AD2115" d="M74.3 35.3l19-7.6v13.6l-1.9.8z" />
</svg>

View File

@@ -0,0 +1,17 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#D91404"
d="M35.971 111.33l81.958 11.188c-9.374-15.606-18.507-30.813-27.713-46.144L35.971 111.33zm89.71-86.383c-2.421 3.636-4.847 7.269-7.265 10.907a67619.72 67619.72 0 00-24.903 37.485c-.462.696-1.061 1.248-.41 2.321 8.016 13.237 15.969 26.513 23.942 39.777 1.258 2.095 2.53 4.182 4.157 6.192l4.834-96.58-.355-.102zM16.252 66.22c.375.355 1.311.562 1.747.347 7.689-3.779 15.427-7.474 22.948-11.564 2.453-1.333 4.339-3.723 6.452-5.661 6.997-6.417 13.983-12.847 20.966-19.278.427-.395.933-.777 1.188-1.275 2.508-4.902 4.973-9.829 7.525-14.898-3.043-1.144-5.928-2.263-8.849-3.281-.396-.138-1.02.136-1.449.375-6.761 3.777-13.649 7.353-20.195 11.472-3.275 2.061-5.943 5.098-8.843 7.743-4.674 4.266-9.342 8.542-13.948 12.882a24.011 24.011 0 00-3.288 3.854c-3.15 4.587-6.206 9.24-9.402 14.025 1.786 1.847 3.41 3.613 5.148 5.259zm28.102-6.271l-11.556 48.823 54.3-34.987-42.744-13.836zm76.631-34.846l-46.15 7.71 15.662 38.096c10.221-15.359 20.24-30.41 30.488-45.806zM44.996 56.644l41.892 13.6c-5.25-12.79-10.32-25.133-15.495-37.737L44.996 56.644zM16.831 75.643L2.169 110.691l27.925-.825-13.263-34.223zm13.593 26.096l.346-.076c3.353-13.941 6.754-27.786 10.177-42.272L18.544 71.035c3.819 9.926 7.891 20.397 11.88 30.704zm84.927-78.897c-4.459-1.181-8.918-2.366-13.379-3.539-6.412-1.686-12.829-3.351-19.237-5.052-.801-.213-1.38-.352-1.851.613-2.265 4.64-4.6 9.245-6.901 13.868-.071.143-.056.328-.111.687l41.47-6.285.009-.292zM89.482 12.288l36.343 10.054-6.005-17.11-30.285 6.715-.053.341zM33.505 114.007c-4.501-.519-9.122-.042-13.687.037-3.75.063-7.5.206-11.25.323-.386.012-.771.09-1.156.506 31.003 2.866 62.005 5.732 93.007 8.6l.063-.414-29.815-4.07c-12.384-1.691-24.747-3.551-37.162-4.982zM2.782 99.994c3.995-9.27 7.973-18.546 11.984-27.809.401-.929.37-1.56-.415-2.308-1.678-1.597-3.237-3.318-5.071-5.226-2.479 12.24-4.897 24.177-7.317 36.113l.271.127c.185-.297.411-.578.548-.897zm78.74-90.153c6.737-1.738 13.572-3.097 20.367-4.613.44-.099.87-.244 1.303-.368l-.067-.332-29.194 3.928c2.741 1.197 4.853 2.091 7.591 1.385z"
/>
</svg>

View File

@@ -0,0 +1,35 @@
<script>
export let size = 24;
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 128 128"
>
<defs
><linearGradient
id="sqlite-original-a"
x1="-15.615"
x2="-6.741"
y1="-9.108"
y2="-9.108"
gradientTransform="rotate(90 -90.486 64.634) scale(9.2712)"
gradientUnits="userSpaceOnUse"
><stop stop-color="#95d7f4" offset="0" /><stop
stop-color="#0f7fcc"
offset=".92"
/><stop stop-color="#0f7fcc" offset="1" /></linearGradient
></defs
><path
d="M69.5 99.176c-.059-.73-.094-1.2-.094-1.2S67.2 83.087 64.57 78.642c-.414-.707.043-3.594 1.207-7.88.68 1.169 3.54 6.192 4.118 7.81.648 1.824.78 2.347.78 2.347s-1.57-8.082-4.144-12.797a162.286 162.286 0 012.004-6.265c.973 1.71 3.313 5.859 3.828 7.3.102.293.192.543.27.774.023-.137.05-.274.074-.414-.59-2.504-1.75-6.86-3.336-10.082 3.52-18.328 15.531-42.824 27.84-53.754H16.9c-5.387 0-9.789 4.406-9.789 9.789v88.57c0 5.383 4.406 9.789 9.79 9.789h52.897a118.657 118.657 0 01-.297-14.652"
fill="#0b7fcc"
/><path
d="M65.777 70.762c.68 1.168 3.54 6.188 4.117 7.809.649 1.824.781 2.347.781 2.347s-1.57-8.082-4.144-12.797a164.535 164.535 0 012.004-6.27c.887 1.567 2.922 5.169 3.652 6.872l.082-.961c-.648-2.496-1.633-5.766-2.898-8.328 3.242-16.871 13.68-38.97 24.926-50.898H16.899a6.94 6.94 0 00-6.934 6.933v82.11c17.527-6.731 38.664-12.88 56.855-12.614-.672-2.605-1.441-4.96-2.25-6.324-.414-.707.043-3.597 1.207-7.879"
fill="url(#sqlite-original-a)"
/><path
d="M115.95 2.781c-5.5-4.906-12.164-2.933-18.734 2.899a44.347 44.347 0 00-2.914 2.859c-11.25 11.926-21.684 34.023-24.926 50.895 1.262 2.563 2.25 5.832 2.894 8.328.168.64.32 1.242.442 1.754.285 1.207.437 1.996.437 1.996s-.101-.383-.515-1.582c-.078-.23-.168-.484-.27-.773-.043-.125-.105-.274-.172-.434-.734-1.703-2.765-5.305-3.656-6.867-.762 2.25-1.437 4.36-2.004 6.265 2.578 4.715 4.149 12.797 4.149 12.797s-.137-.523-.782-2.347c-.578-1.621-3.441-6.64-4.117-7.809-1.164 4.281-1.625 7.172-1.207 7.88.809 1.362 1.574 3.722 2.25 6.323 1.524 5.867 2.586 13.012 2.586 13.012s.031.469.094 1.2a118.653 118.653 0 00.297 14.651c.504 6.11 1.453 11.363 2.664 14.172l.828-.449c-1.781-5.535-2.504-12.793-2.188-21.156.48-12.793 3.422-28.215 8.856-44.289 9.191-24.27 21.938-43.738 33.602-53.035-10.633 9.602-25.023 40.684-29.332 52.195-4.82 12.891-8.238 24.984-10.301 36.574 3.55-10.863 15.047-15.53 15.047-15.53s5.637-6.958 12.227-16.888c-3.95.903-10.43 2.442-12.598 3.352-3.2 1.344-4.067 1.8-4.067 1.8s10.371-6.312 19.27-9.171c12.234-19.27 25.562-46.648 12.141-58.621"
fill="#003956"
/>
</svg>

View File

@@ -0,0 +1,58 @@
<script>
import Icon from "$common/Icon.svelte";
export let info;
export let contentType;
let state;
$: if (info) {
if (info["is_running"]) state = "Running";
else if (info["is_deployed"]) state = "Deployed";
else if (!info["is_setup"]) state = "Needs Setup";
}
</script>
<div class="card shadow-lg hover:shadow-xl bg-neutral">
<div class="card-body w-full">
<div class="flex items-center">
<div class="text-neutral-content text-xl leading-8">
<div class="flex flex-row gap-3">
{#if info.type}
<Icon size="lg" type={info.type} />
{/if}
<span>{info.name}</span>
</div>
</div>
<div class="flex-grow" />
<div>
{#if contentType === "app" && state}
<div
class="flex justify-end"
class:text-accent={state === "Needs Setup"}
class:text-warning={state === "Stopped"}
class:text-success={state === "Running"}
>
<div class="inline mr-2">
<span class="align-middle leading-8">
{state}
</span>
</div>
<Icon type="circles" size="lg" />
</div>
{/if}
</div>
</div>
<div class="mt-4">
<a class="w-auto" href="/{contentType}/{info.name}">
<button class="btn gap-2 btn-primary">
<Icon type="info" />
<span class="text-lg">View</span>
</button>
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<script>
import { goto } from "$app/navigation";
import Icon from "$common/Icon.svelte";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import DashboardCard from "./DashboardCard.svelte";
export let contentType = "";
export let states = {};
export let query;
let contentTypeTitle;
$: contentTypeTitle =
contentType.substring(0, 1).toUpperCase() + contentType.substring(1);
</script>
<QueryDataWrapper {query} action="loading {contentTypeTitle}s">
<div class="flex flex-row gap-4 p-2 items-center">
<span class="text-2xl font-bold text-base-content">{contentTypeTitle}s</span
>
<div class="flex-grow" />
<div class="flex justify-end" class:hidden={$query.data.length === 0}>
<button
class="btn btn-secondary btn-sm gap-2"
on:click={() => goto(`/${contentType}/new`)}
>
Create <Icon size="sm" type="plus" />
</button>
</div>
</div>
{#if $query.data.length > 0}
<div class="flex flex-col gap-4">
{#each $query.data as info}
<DashboardCard {contentType} {info} />
{/each}
</div>
{:else}
<div class="card card-bordered w-auto shadow-lg hover:shadow-xl bg-neutral">
<div class="card-body">
<div class="grid grid-cols-2 items-center">
<div class="h-full text-neutral-content">
<span class="text-xl leading-8">No {contentTypeTitle}s</span>
</div>
</div>
<div class="mt-4">
<button
class="btn gap-2 btn-primary"
on:click={() => goto(`/${contentType}/new`)}
>
<Icon size="md" type="add" />
<span class="text-lg">Create {contentTypeTitle}</span>
</button>
</div>
</div>
</div>
{/if}
</QueryDataWrapper>

View File

@@ -0,0 +1,91 @@
<script>
import { createEventDispatcher } from "svelte";
import Icon from "$common/Icon.svelte";
import ConfirmationModal from "$common/ConfirmationModal.svelte";
import LinkModal from "./LinkModal.svelte";
export let serviceName;
export let appName;
export let serviceType;
export let cardType = "app";
export let isLinked = false;
export let loading;
const dispatch = createEventDispatcher();
let cardLink;
$: cardLink =
cardType === "service" ? `/service/${serviceName}` : `/app/${appName}`;
let linkModalOpen = false;
const doLink = ({ detail }) => {
dispatch("link", { serviceName, appName, options: detail });
};
let unlinkModalOpen = false;
const doUnlink = () => {
dispatch("unlink", { serviceName, appName });
};
</script>
<div
class="flex flex-row w-auto p-4 bg-neutral text-neutral-content items-center gap-3 rounded-lg"
class:bg-neutral-focus={!isLinked}
>
<a class="btn" class:gap-4={cardType === "service"} href={cardLink}>
<div class="">
{#if cardType === "service"}<Icon type={serviceType} size="lg" />{/if}
</div>
<div class="">
<span>{cardType === "service" ? serviceName : appName}</span>
</div>
</a>
<div class="flex-grow" />
<div class:hidden={!isLinked}>
<button
class="btn gap-2"
class:loading
on:click={() => (unlinkModalOpen = true)}
>
{#if loading}
Unlinking
{:else}
<Icon type="unlink" />
Unlink
{/if}
</button>
</div>
<div class:hidden={isLinked}>
<button
class="btn gap-2"
class:loading
on:click={() => (linkModalOpen = true)}
>
{#if loading}
Linking
{:else}
<Icon type="link" size="lg" />
Link
{/if}
</button>
</div>
</div>
{#if isLinked}
<ConfirmationModal
name="unlink"
title="Confirm Unlinking"
action="unlink service '{serviceName}' and app '{appName}'"
bind:open={unlinkModalOpen}
on:accepted={doUnlink}
/>
{:else}
<LinkModal
{serviceType}
preventClose={loading}
bind:open={linkModalOpen}
on:link={doLink}
/>
{/if}

View File

@@ -0,0 +1,51 @@
<script>
import { createEventDispatcher } from "svelte";
import Modal from "$common/Modal.svelte";
import Generic from "./link-configs/Generic.svelte";
export let open = false;
export let preventClose = false;
export let serviceType = "";
const typeComponents = {};
let configComponent = typeComponents[serviceType] || Generic;
const serviceConfigComponents = {};
$: if (serviceType && serviceType in serviceConfigComponents)
configComponent = serviceConfigComponents[serviceType];
let serviceConfig = {};
const dispatch = createEventDispatcher();
const dispatchLinkService = () => {
dispatch("link", serviceConfig);
open = false;
};
</script>
<Modal name="link-app-service" title="Linking" bind:open {preventClose}>
<span class="text-lg">This will cause your app to restart!</span>
<div
class="collapse collapse-arrow border border-base-300 bg-base-100 rounded-box"
>
<input type="checkbox" />
<div class="collapse-title text-md font-medium">Advanced Options</div>
<div class="collapse-content">
<svelte:component this={configComponent} bind:config={serviceConfig} />
</div>
</div>
<div class="mt-3">
<button
class="btn"
class:loading={preventClose}
on:click={dispatchLinkService}
>
Submit
</button>
</div>
</Modal>

View File

@@ -0,0 +1,32 @@
<script>
export let config;
config = {
alias: "",
query_string: "",
};
</script>
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">Alternative name for environment variable</span>
</label>
<input
bind:value={config["alias"]}
type="text"
placeholder="BLUE_DATABASE"
class="input input-bordered w-full max-w-xs"
/>
</div>
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">Query string to append to connection</span>
</label>
<input
bind:value={config["query_string"]}
type="text"
placeholder="pool=5&foo=bar"
class="input input-bordered w-full max-w-xs"
/>
</div>

View File

@@ -0,0 +1,119 @@
<script>
import Icon from "$common/Icon.svelte";
import ProcessResourceView from "./ProcessResourceView.svelte";
import ProcessResourceEditor from "./ProcessResourceEditor.svelte";
import ProcessScaleSelector from "./ProcessScaleSelector.svelte";
import ProcessDeploymentEditor from "./ProcessDeploymentEditor.svelte";
import { createEventDispatcher } from "svelte";
export let processName;
export let resourceDefaults;
export let report;
export let checksReport;
let resources;
let scale;
let cpuLimit;
let memLimit;
let memReserved;
const getResourceByKey = (key, resource) => {
let resSettings = resources[key][resource];
if (resSettings === null) return {};
return {
amount: resSettings["amount"],
unit: resSettings["type"]["suffix"],
};
};
const resourceLimit = (resource) => getResourceByKey("limits", resource);
const resourceReservation = (resource) =>
getResourceByKey("reservations", resource);
$: if (report) {
scale = report["scale"];
resources = report["resources"];
cpuLimit = resourceLimit("cpu");
memLimit = resourceLimit("memory");
memReserved = resourceReservation("memory");
}
const resourceView = 0;
const resourceEditView = 1;
const deploymentEditView = 2;
let currentView = resourceView;
const setView = (view) => (currentView = view);
const dispatch = createEventDispatcher();
const resourcesEdited = () => {
dispatch("resourcesEdited");
setView(resourceView);
};
</script>
<div
class="w-full bg-neutral text-neutral-content rounded-lg p-4 flex flex-col gap-2"
>
<div class="flex flex-row items-center gap-2 bg-neutral-focus rounded-lg p-3">
<div class="">
<span class="text-xl">{processName}</span>
</div>
<div class="flex-grow" />
<div class="">
<ProcessScaleSelector {processName} {scale} />
</div>
</div>
<div class="flex flex-row gap-2">
{#if currentView !== resourceView}
<button
on:click={() => setView(resourceView)}
class="btn btn-sm gap-2 w-52 bg-neutral-focus"
>
<Icon type="left" size="sm" />
back
</button>
{:else}
<div class="">
<button
on:click={() => setView(resourceEditView)}
class="btn btn-sm btn-outline btn-ghost text-neutral-content gap-2"
>
Edit Resources
<Icon type="build" size="sm" />
</button>
</div>
<div class="">
<button
on:click={() => setView(deploymentEditView)}
class="btn btn-sm btn-outline btn-ghost text-neutral-content gap-2"
>
Edit Deployment Settings
<Icon type="cube" size="sm" />
</button>
</div>
{/if}
</div>
<div class="rounded-lg">
{#if report}
{#if currentView === resourceView}
<ProcessResourceView {cpuLimit} {memLimit} {memReserved} />
{:else if currentView === resourceEditView}
<ProcessResourceEditor
{processName}
{resourceDefaults}
on:successfulEdit={resourcesEdited}
{cpuLimit}
{memLimit}
{memReserved}
/>
{:else if currentView === deploymentEditView}
<ProcessDeploymentEditor {processName} report={checksReport} />
{/if}
{/if}
</div>
</div>

View File

@@ -0,0 +1,72 @@
<script>
import { setAppProcessDeployChecksState } from "$lib/api";
import { page } from "$app/stores";
let appName = $page.params.name;
export let processName;
export let report;
let globalDisabled = false;
let isDisabled = false;
let globalSkipped = false;
let isSkipped = false;
$: if (report) {
globalDisabled = report["all_disabled"];
globalSkipped = report["all_skipped"];
if (processName in report["disabled_processes"]) isDisabled = true;
if (processName in report["skipped_processes"]) isSkipped = true;
}
let loading = false;
const stateChanged = async () => {
loading = true;
try {
let state = "enabled";
if (isSkipped) state = "skipped";
if (isDisabled) state = "disabled";
await setAppProcessDeployChecksState(appName, processName, state);
} finally {
loading = false;
}
};
</script>
<div
class:tooltip={globalSkipped || isDisabled || globalDisabled}
class="tooltip-warning"
data-tip="Checks are {globalSkipped
? 'skipped'
: 'disabled'} for the entire app"
>
<label class="label cursor-pointer w-96 mt-2">
<span class="label-text text-neutral-content">Skip Deploy Checks</span>
<input
type="checkbox"
class="toggle"
disabled={globalDisabled || isDisabled}
bind:checked={isSkipped}
on:change={stateChanged}
/>
</label>
</div>
<div
class:tooltip={globalDisabled}
class="tooltip-warning"
data-tip="Checks are disabled for the entire app"
>
<label class="label cursor-pointer w-96 mt-2">
<span class="label-text text-neutral-content"
>Disable Deploy Checks (may cause downtime)</span
>
<input
type="checkbox"
class="toggle"
disabled={globalDisabled}
bind:checked={isDisabled}
on:change={stateChanged}
/>
</label>
</div>

View File

@@ -0,0 +1,55 @@
<script>
export let name;
export let value;
export let defaults;
export let typeLabel = "Limit";
export let unit = null;
export let withUnit = false;
let enabled = !!value;
const units = ["b", "k", "m", "g"];
const onToggled = () => {
if (!enabled) {
value = null;
unit = null;
return;
}
if (!value) value = defaults ? defaults["amount"] : 0;
if (!unit && withUnit) {
unit = defaults && defaults["type"] ? defaults["type"]["suffix"] : "m";
}
};
</script>
<div class="bg-neutral-focus rounded-lg w-fit p-2">
<label class="label cursor-pointer w-52">
<span class="label-text text-neutral-content">{typeLabel} {name}</span>
<input
type="checkbox"
class="toggle"
bind:checked={enabled}
on:change={onToggled}
/>
</label>
{#if enabled}
<div>
<label class="label">
<span class="label-text text-neutral-content">{name} {typeLabel}</span>
</label>
<label class="input-group">
<span class="text-neutral">{name}</span>
<input type="number" bind:value class="input input-bordered" />
{#if withUnit}
<select class="select select-bordered" bind:value={unit}>
{#each units as unitOption}
<option value={unitOption}>{unitOption}</option>
{/each}
</select>
{/if}
</label>
</div>
{/if}
</div>

View File

@@ -0,0 +1,97 @@
<script>
import { setAppProcessResources } from "$lib/api";
import { createEventDispatcher } from "svelte";
import { page } from "$app/stores";
import { useMutation } from "@sveltestack/svelte-query";
import Error from "$common/Error.svelte";
import ProcessResource from "./ProcessResource.svelte";
export let processName;
export let resourceDefaults;
export let cpuLimit;
export let memLimit;
export let memReserved;
const appName = $page.params.name;
const setResourcesMutation = useMutation((resources) =>
setAppProcessResources(appName, processName, resources)
);
const dispatch = createEventDispatcher();
let defaultCPULimit, defaultMemLimit, defaultMemReservation;
const setDefaults = () => {
const limits = resourceDefaults["limits"] || {};
const reservations = resourceDefaults["reservations"] || {};
defaultCPULimit = limits["cpu"];
defaultMemLimit = limits["memory"];
defaultMemReservation = reservations["memory"];
};
$: if (resourceDefaults) setDefaults();
let cpuLimitAmount;
let memLimitAmount, memLimitUnit;
let memReservedAmount, memReservedUnit;
const setValues = () => {
cpuLimitAmount = cpuLimit["amount"];
memLimitAmount = memLimit["amount"];
memLimitUnit = memLimit["unit"];
memReservedAmount = memReserved["amount"];
memReservedUnit = memReserved["unit"];
};
$: if (cpuLimit || memLimit || memReserved) setValues();
const submit = async () => {
const limits = {
cpu: cpuLimitAmount,
memory: memLimitAmount,
memory_unit: memLimitUnit,
};
const reservations = {
memory: memReservedAmount,
memory_unit: memReservedUnit,
};
await $setResourcesMutation.mutateAsync({ limits, reservations });
dispatch("successfulEdit");
};
</script>
<div class="text-neutral">
<div class="form-control mb-4 flex flex-col gap-2">
<ProcessResource
name="CPU"
defaults={defaultCPULimit}
bind:value={cpuLimitAmount}
/>
<ProcessResource
name="Memory"
withUnit={true}
defaults={defaultMemLimit}
bind:value={memLimitAmount}
bind:unit={memLimitUnit}
/>
<ProcessResource
name="Memory"
typeLabel="Reserve"
withUnit={true}
defaults={defaultMemReservation}
bind:value={memReservedAmount}
bind:unit={memReservedUnit}
/>
</div>
{#if $setResourcesMutation.isError}
<Error action="updating resources" error={$setResourcesMutation.error} />
{/if}
<button
class="btn btn-primary"
class:btn-disabled={$setResourcesMutation.isError}
class:loading={$setResourcesMutation.isLoading}
on:click={submit}
>
Save
</button>
</div>

View File

@@ -0,0 +1,54 @@
<script>
import Icon from "$common/Icon.svelte";
export let cpuLimit;
export let memLimit;
export let memReserved;
let showCPU, showMemLimit, showMemReserved;
$: {
showCPU = cpuLimit && cpuLimit["amount"];
showMemLimit = memLimit && memLimit["amount"];
showMemReserved = memReserved && memReserved["amount"];
}
</script>
{#if showCPU || showMemLimit || showMemReserved}
<div class="flex flex-row gap-4">
{#if showCPU}
<div class="stats shadow">
<div class="stat">
<div class="stat-title">vCPU Limit</div>
<div class="stat-value items-center flex gap-2">
<Icon type="cube" size="lg" />
<span>{cpuLimit["amount"]}</span>
</div>
</div>
</div>
{/if}
{#if showMemLimit}
<div class="stats shadow">
<div class="stat">
<div class="stat-title">Memory Limit</div>
<div class="stat-value items-center flex gap-2">
<Icon type="layers" size="lg" />
<span>{memLimit["amount"]}{memLimit["unit"]}</span>
</div>
</div>
</div>
{/if}
{#if showMemReserved}
<div class="stats shadow">
<div class="stat">
<div class="stat-title">Memory Reserved</div>
<div class="stat-value items-center flex gap-2">
<Icon type="layers" size="lg" />
<span>{memReserved["amount"]}{memReserved["unit"]}</span>
</div>
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,72 @@
<script>
import { page } from "$app/stores";
import { setAppProcessScale } from "$lib/api";
import { commandExecutionIds, executionIdDescriptions } from "$lib/stores";
import { createEventDispatcher } from "svelte";
import { useMutation, useQueryClient } from "@sveltestack/svelte-query";
export let processName;
export let scale;
const appName = $page.params.name;
const queryClient = useQueryClient();
let scaleRange = [];
$: if (scale !== null) {
let scaleRangeStart = Math.max(0, scale - 3);
scaleRange = [...Array(6).keys()].map((i) => i + scaleRangeStart);
}
const dispatch = createEventDispatcher();
const invalidateAppOverview = () =>
queryClient.invalidateQueries(["getAppOverview", appName]);
const setScaleMutation = useMutation(
async (newScale) => {
const id = await setAppProcessScale(appName, processName, newScale);
$executionIdDescriptions[
id
] = `Scaling ${appName}-${processName} [${scale}->${newScale}]`;
return commandExecutionIds.addID(id);
},
{
onSuccess: (_, newScale) => {
if (newScale === 0 || scale === 0) invalidateAppOverview();
scale = newScale;
},
}
);
</script>
<div class="flex flex-col gap-2">
<div class="items-center flex flex-row gap-2">
<span class="text-lg">Scale: </span>
{#if $setScaleMutation.isLoading}
<button class="btn btn-square btn-sm loading" />
{:else}
<button
class="btn btn-square btn-sm"
on:click={() => $setScaleMutation.mutate(scale - 1)}
>
-
</button>
<select
class="select select-sm select-ghost"
disabled={$setScaleMutation.isLoading}
on:change={({ target }) =>
$setScaleMutation.mutate(parseInt(target.value))}
>
{#each scaleRange as n}
<option value={n} selected={scale === n}>{n}</option>
{/each}
</select>
<button
class="btn btn-square btn-sm"
on:click={() => $setScaleMutation.mutate(scale + 1)}
>
+
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,64 @@
<script>
import {
getAppProcesses,
getAppProcessReport,
getAppDeployChecksReport,
} from "$lib/api";
import Loader from "$common/Loader.svelte";
import Error from "$common/Error.svelte";
import ProcessCard from "./ProcessCard.svelte";
import { page } from "$app/stores";
import { useQuery, useQueryClient } from "@sveltestack/svelte-query";
import { createEventDispatcher } from "svelte";
const appName = $page.params.name;
const dispatch = createEventDispatcher();
const queryClient = useQueryClient();
const psReportQueryKey = ["getAppProcessReport", appName];
const processReport = useQuery(psReportQueryKey, () =>
getAppProcessReport(appName)
);
const psQueryKey = ["getAppProcesses", appName];
const appProcesses = useQuery(psQueryKey, () => getAppProcesses(appName));
const checksQueryKey = ["getAppDeployChecksReport", appName];
const checksReport = useQuery(checksQueryKey, () =>
getAppDeployChecksReport(appName)
);
const invalidateProcessReport = () =>
queryClient.invalidateQueries(psReportQueryKey);
let reports = {};
let resourceDefaults = {};
$: if ($processReport.isSuccess) {
reports = $processReport.data["processes"];
resourceDefaults = $processReport.data["resource_defaults"];
}
</script>
<div class="flex flex-col gap-2">
{#if $processReport.isLoading || $appProcesses.isLoading || $checksReport.isLoading}
<Loader />
{:else if $processReport.isError}
<Error action="loading app process report" error={$processReport.error} />
{:else if $appProcesses.isError}
<Error action="loading app processes" error={$appProcesses.error} />
{:else if $checksReport.isError}
<Error action="loading deploy checks" error={$checksReport.error} />
{:else}
{#each $appProcesses.data as processName, i}
<ProcessCard
{processName}
{resourceDefaults}
checksReport={$checksReport.data}
report={reports[processName]}
on:resourcesEdited={invalidateProcessReport}
/>
{/each}
{/if}
</div>

View File

@@ -0,0 +1 @@
redis index page

View File

@@ -0,0 +1,134 @@
<script>
import Modal from "$common/Modal.svelte";
import { useQuery } from "@sveltestack/svelte-query";
import { requestGenerateTotp, confirmTotpCode } from "$lib/apis/setup.js";
import Error from "$common/Error.svelte";
import Icon from "$common/Icon.svelte";
export let isSetup;
export let totpSecret;
export let recoveryCode;
let codeConfirmed = false;
let modalOpen = false;
const genQuery = useQuery("requestGenerateTotp", requestGenerateTotp, {
enabled: false,
});
const generateTotp = () => {
genQuery.setEnabled(true);
modalOpen = true;
};
let image;
$: if ($genQuery.data) {
image = $genQuery.data["image"];
totpSecret = $genQuery.data["secret"];
recoveryCode = $genQuery.data["recovery_code"];
}
const copySecret = () => {
navigator.clipboard.writeText(totpSecret);
};
let codeInput;
let confirmError;
const tryConfirm = async () => {
confirmError = null;
let ok = await confirmTotpCode(totpSecret, codeInput);
if (ok) {
codeConfirmed = true;
} else {
confirmError = "invalid code";
}
};
const confirmSetup = () => {
isSetup = true;
modalOpen = false;
};
</script>
{#if $genQuery.isError}
<Error error={$genQuery.error} action="generating totp" />
{/if}
<button
class="btn btn-primary"
class:loading={$genQuery.isLoading}
class:btn-disabled={isSetup}
class:bg-success={isSetup}
class:text-success-content={isSetup}
on:click={generateTotp}
>
{isSetup ? "TOTP Setup" : "Setup TOTP"}
</button>
<Modal
name="totp-setup"
title="TOTP Setup"
open={modalOpen}
preventClose={false}
>
<div class="w-full flex flex-col" class:hidden={codeConfirmed}>
<div class="">
<span>Scan:</span>
<img class="w-[160px] h-[160px]" src="data:image/png;base64,{image}" />
<span>Or enter secret manually:</span>
<br />
<div
class="btn btn-ghost btn-icon gap-2 rounded-lg p-2"
on:click={copySecret}
>
<Icon type="copy" />
<code>{totpSecret}</code>
</div>
</div>
<div class="mt-2">
<span>Then enter code from authenticator:</span>
<label class="input-group">
<span class="text-neutral">Code</span>
<input
type="text"
class="input input-bordered"
on:change={() => (confirmError = null)}
bind:value={codeInput}
/>
</label>
</div>
<div class="mt-2">
{#if confirmError}
<div class="">
<span class="text-error">{confirmError}</span>
</div>
{/if}
<button
class="btn btn-primary"
class:btn-error={!!confirmError}
disabled={!codeInput}
on:click={tryConfirm}
>
Submit
</button>
</div>
</div>
<div class="w-full flex flex-col" class:hidden={!codeConfirmed}>
<span> This is your recovery code, store it somewhere safe. </span>
<br />
<div
class="btn btn-ghost w-fit btn-icon gap-2 rounded-lg p-2"
on:click={copySecret}
>
<Icon type="copy" />
<code>{recoveryCode}</code>
</div>
<button class="btn btn-primary mt-2" on:click={confirmSetup}>
I have stored this somewhere safe
</button>
</div>
</Modal>

6
ui/src/lib/api.js Normal file
View File

@@ -0,0 +1,6 @@
export * from "./apis/apps.js";
export * from "./apis/auth.js";
export * from "./apis/services.js";
export * from "./apis/settings.js";
export * from "./apis/exec.js";
export * from "./apis/setup.js";

287
ui/src/lib/apis/apps.js Normal file
View File

@@ -0,0 +1,287 @@
import {createApiClient} from "./client.js";
const appsClient = createApiClient("/apps/");
export const getAppsList = async () => {
return await appsClient.url("list")
.get()
.json(res => res["apps"]);
}
export const getAllAppsOverview = async () => {
return await appsClient.url("report")
.get()
.json(res => res["apps"]);
}
export const getAppOverview = async (appName) => {
return await appsClient.url("overview")
.query({"name": appName})
.get()
.json();
}
export const getAppIsSetup = async (appName) => {
return await appsClient.url("setup")
.query({"name": appName})
.get()
.json(res => res["is_setup"]);
}
export const createApp = async (appName) => {
return appsClient.url("create")
.post({"name": appName})
.res(res => res.ok);
}
export const destroyApp = async (appName) => {
return await appsClient.url("destroy")
.post({"name": appName})
.res(res => res.ok);
}
export const startApp = async (appName) => {
return appsClient.url("start")
.post({"name": appName})
.json(res => res["execution_id"]);
}
export const stopApp = async (appName) => {
return appsClient.url("stop")
.post({"name": appName})
.json(res => res["execution_id"]);
}
export const restartApp = async (appName) => {
return appsClient.url("restart")
.post({"name": appName})
.json(res => res["execution_id"]);
}
export const rebuildApp = async (appName) => {
return appsClient.url("rebuild")
.post({"name": appName})
.json(res => res["execution_id"]);
}
export const getAppInfo = async (appName) => {
return await appsClient.url("info")
.query({"name": appName})
.get()
.json(res => res["info"]);
}
export const renameApp = async (curName, newName) => {
return await appsClient.url("rename")
.post({"current_name": curName, "new_name": newName})
.res(res => res.ok);
}
export const setupAppAsync = async (appName, source, options) => {
const body = {"name": appName, ...options}
let req = appsClient.url("setup/" + source)
if (source === "upload-archive")
return await req.formData(body).post().json(res => res["execution_id"]);
return await req.post(body).json(res => res["execution_id"]);
}
export const setupApp = async (appName, source, options) => {
return await appsClient.url("setup/" + source)
.post({"name": appName, ...options})
.res(res => res.ok);
}
export const getAppSetupConfig = async (appName) => {
return await appsClient.url("setup/config")
.query({"name": appName})
.get()
.json();
}
export const listAppServices = async (appName) => {
return await appsClient.url("services")
.query({name: appName})
.get()
.json(res => res["services"]);
}
export const getAppDeployChecksReport = async (appName) => {
return await appsClient.url("deploy-checks")
.query({"name": appName})
.get()
.json(res => res);
}
export const setAppDeployChecksState = async (appName, state) => {
return await appsClient.url("deploy-checks")
.post({
"name": appName,
"state": state,
})
.res(res => res.ok);
}
export const setAppProcessDeployChecksState = async (appName, process, state) => {
return await appsClient.url("process/deploy-checks")
.post({
"name": appName,
"process": process,
"state": state,
})
.res(res => res.ok);
}
export const getAppProcesses = async (appName) => {
return await appsClient.url("process/list")
.query({"name": appName})
.get()
.json(res => res["processes"]);
}
export const getAppProcessReport = async (appName) => {
return await appsClient.url("process/report")
.query({"name": appName})
.get()
.json();
}
export const setAppProcessResources = async (appName, process, resources) => {
return await appsClient.url("process/resources")
.post({"name": appName, "process": process, ...resources})
.res(res => res.ok);
}
export const getAppProcessScale = async (appName) => {
return await appsClient.url("process/scale")
.query({"name": appName})
.get()
.json(res => res["scale"]);
}
export const setAppProcessScale = async (appName, processName, scale) => {
return await appsClient.url("process/scale")
.post({"name": appName, "process": processName, "scale": scale})
.json(res => res["execution_id"]);
}
export const getAppNetworksReport = async (appName) => {
return await appsClient.url("networks")
.query({"name": appName})
.get()
.json(res => res);
}
export const updateAppNetworks = async (appName, config) => {
return await appsClient.url("networks")
.post({"name": appName, ...config})
.res(res => res.ok);
}
export const getAppDomainsReport = async (appName) => {
return await appsClient.url("domains")
.query({"name": appName})
.get()
.json(res => res);
}
export const setAppDomainsEnabled = async (appName, enabled) => {
return await appsClient.url("domains/state")
.post({"name": appName, "enabled": enabled})
.res(res => res.ok);
}
export const getAppLetsEncryptEnabled = async (appName) => {
return await appsClient.url("letsencrypt")
.query({"name": appName})
.get()
.json(res => res["enabled"]);
}
export const addAppDomain = async (appName, domain) => {
return await appsClient.url("domain")
.post({"name": appName, "domain": domain})
.res(res => res.ok);
}
export const removeAppDomain = async (appName, domain) => {
return await appsClient.url("domain")
.json({"name": appName, "domain": domain})
.delete()
.res(res => res.ok);
}
export const getAppLogs = async (appName) => {
return await appsClient.url("logs")
.query({"name": appName})
.get()
.json(res => res["logs"]);
}
export const getAppConfig = async (appName) => {
return await appsClient.url("config")
.query({"name": appName})
.get()
.json(res => res["config"]);
}
export const setAppConfig = async ({appName, newConfig}) => {
return await appsClient.url("config")
.post({"name": appName, "config": newConfig})
.res(res => res.ok);
}
export const getAppStorages = async (appName) => {
return await appsClient.url("storage")
.query({"name": appName})
.get()
.json(res => res);
}
export const mountAppStorage = async (appName, options) => {
return await appsClient.url("storage/mount")
.post({"name": appName, ...options})
.res(res => res.ok);
}
export const unmountAppStorage = async (appName, options) => {
return await appsClient.url("storage/unmount")
.post({"name": appName, ...options})
.res(res => res.ok);
}
export const getSelectedBuilder = async (appName) => {
return await appsClient.url("builder")
.query({"name": appName})
.get()
.json(res => {
return res["selected"];
});
}
export const setSelectedBuilder = async (appName, builder) => {
return await appsClient.url("builder")
.post({"name": appName, "builder": builder})
.res(res => res.ok);
}
export const getAppBuildDirectory = async (appName) => {
return await appsClient.url("build-directory")
.query({"name": appName})
.get()
.json(res => res["directory"]);
}
export const setAppBuildDirectory = async (appName, newDir) => {
return await appsClient.url("build-directory")
.post({"name": appName, "directory": newDir})
.res(res => res.ok);
}
export const clearAppBuildDirectory = async (appName) => {
return await appsClient.url("build-directory")
.query({"name": appName})
.delete()
.res(res => res.ok);
}

40
ui/src/lib/apis/auth.js Normal file
View File

@@ -0,0 +1,40 @@
import {createApiClient} from "./client.js";
export const authClient = createApiClient("/auth/");
export const doLogin = async (authDetails) => {
return await authClient.url("login")
.post(authDetails)
.json()
.catch(_ => false)
}
export const refreshAuthToken = async () => {
return authClient.url("refresh").post().res();
}
export const doLogout = async () => {
return await authClient.url("logout")
.post()
.res(r => r.ok);
}
export const fetchAuthDetails = async () => {
return await authClient.url("details")
.get()
.forbidden(err => {})
.json(r => r)
.catch(err => {})
}
export const getGithubAuthInfo = async () => {
return await authClient.url("github")
.get()
.json();
}
export const completeGithubAuth = async (code, redirectUrl) => {
return await authClient.url("github/auth")
.post({code, "redirect_url": redirectUrl})
.res(r => r.ok)
}

14
ui/src/lib/apis/client.js Normal file
View File

@@ -0,0 +1,14 @@
import wretch from "wretch";
import Cookies from "js-cookie";
const dev = false;
const csrfMiddleware = next => (url, opts) => {
const token = Cookies.get("_csrf");
if (token) opts.headers = {...opts.headers, "X-CSRF-Token": token};
return next(url, opts);
};
export const createApiClient = (base) => {
return wretch("/api" + base).middlewares([csrfMiddleware])
}

29
ui/src/lib/apis/exec.js Normal file
View File

@@ -0,0 +1,29 @@
import {createApiClient} from "./client.js";
const execClient = createApiClient("/exec/");
export const getExecutionStatus = async (id) => {
return await execClient.url("status")
.query({"execution_id": id})
.get()
.json(res => res);
}
export const executeCommandInProcess = async (appName, processName, command) => {
return await execClient.url("command")
.post({appName, processName, command})
.json(res => res);
}
/*
const getSocketURI = (appName, processName) => {
const loc = window.location;
const proto = (loc.protocol === 'https:') ? "wss:" : "ws:";
const queryParams = `app_name=${appName}&process_name=${processName}`
return `${proto}//${loc.host}/api/exec/socket?${queryParams}`
}
export const getAppProcessExecutionSocket = (appName, processName) => {
const uri = getSocketURI(appName, processName);
return new WebSocket(uri)
}
*/

137
ui/src/lib/apis/services.js Normal file
View File

@@ -0,0 +1,137 @@
import {createApiClient} from "./client.js";
const servicesClient = createApiClient("/services/");
export const createService = async (name, type, config) => {
return await servicesClient.url(`create`)
.post({name, config, type})
.res(res => res.ok);
}
export const cloneService = async (name, newName) => {
return await servicesClient.url(`clone`)
.post({name, newName})
.res(res => res.ok);
}
export const listServices = async () => {
return await servicesClient.url("list")
.get()
.json(res => res["services"]);
}
export const getServiceType = async (serviceName) => {
return await servicesClient.url("type")
.query({"name": serviceName})
.get()
.json(res => res["type"]);
}
export const getServiceInfo = async (serviceName, serviceType) => {
return await servicesClient.url("info")
.query({"name": serviceName, "type": serviceType})
.get()
.json(res => res["info"]);
}
export const linkServiceToApp = async (serviceName, appName, options) => {
return await servicesClient.url("link")
.post({
"service_name": serviceName,
"app_name": appName,
...options,
})
.json(res => res["execution_id"]);
}
export const unlinkServiceFromApp = async (serviceName, appName) => {
return await servicesClient.url("unlink")
.post({
"service_name": serviceName,
"app_name": appName,
})
.json(res => res["execution_id"]);
}
export const getServiceLinkedApps = async (serviceName, serviceType) => {
return await servicesClient.url("linked-apps")
.query({"name": serviceName, "type": serviceType})
.get()
.json(res => res["apps"]);
}
export const getServiceLogs = async (serviceName, serviceType) => {
return await servicesClient.url("logs")
.query({"name": serviceName, "type": serviceType})
.get()
.json(res => res["logs"]);
}
export const startService = async (serviceType, serviceName) => {
return await servicesClient.url("start")
.post({"name": serviceName, "type": serviceType})
.res(res => res.ok);
}
export const stopService = async (serviceType, serviceName) => {
return await servicesClient.url("stop")
.post({"name": serviceName, "type": serviceType})
.res(res => res.ok);
}
export const restartService = async (serviceType, serviceName) => {
return await servicesClient.url("restart")
.post({"name": serviceName, "type": serviceType})
.res(res => res.ok);
}
export const destroyService = async (serviceType, serviceName) => {
return await servicesClient.url("destroy")
.post({"name": serviceName, "type": serviceType})
.res(res => res.ok);
}
export const getServiceBackupReport = async (serviceName) => {
return await servicesClient.url("backups/report")
.query({"name": serviceName})
.get()
.json(res => res["report"]);
}
export const doServiceBackup = async (serviceName) => {
return await servicesClient.url("backups/run")
.post({"name": serviceName})
.res(res => res.ok);
}
export const updateServiceBackupAuth = async (serviceName, config) => {
return await servicesClient.url("backups/auth")
.post({"name": serviceName, "config": config})
.res(res => res.ok);
}
export const setServiceBackupsSchedule = async (serviceName, schedule) => {
return await servicesClient.url("backups/schedule")
.post({"name": serviceName, "schedule": schedule})
.res(res => res.ok);
}
export const unscheduleServiceBackups = async (serviceName) => {
return await servicesClient.url("backups/schedule")
.body({"name": serviceName})
.delete()
.res(res => res.ok);
}
export const setServiceBackupEncryption = async (serviceName, passphrase) => {
return await servicesClient.url("backups/encryption")
.post({"name": serviceName, "passphrase": passphrase})
.res(res => res.ok);
}
export const unsetServiceBackupEncryption = async (serviceName) => {
return await servicesClient.url("backups/encryption")
.body({"name": serviceName})
.delete()
.res(res => res.ok);
}

View File

@@ -0,0 +1,88 @@
import {createApiClient} from "./client.js";
const settingsClient = createApiClient("/settings/");
export const getSettings = async () => {
return await settingsClient.url("basic")
.get()
.json(res => res["settings"]);
}
export const getLetsEncryptStatus = async () => {
return await settingsClient.url("letsencrypt")
.get()
.json(res => res);
}
export const getUsers = async () => {
return await settingsClient.url("users")
.get()
.json(res => res["users"]);
}
export const getSSHKeys = async () => {
return await settingsClient.url("ssh-keys")
.get()
.json(res => res["keys"]);
}
export const getGlobalDomainsList = async () => {
return await settingsClient.url("domains")
.get()
.json(res => res["domains"]);
}
export const addGlobalDomain = async (domain) => {
return await settingsClient.url("domains")
.post({domain})
.res(res => res.ok);
}
export const removeGlobalDomain = async (domain) => {
return await settingsClient.url("domains")
.query({domain})
.delete()
.res(res => res.ok);
}
export const getVersions = async () => {
return await settingsClient.url("versions")
.get()
.json(res => res);
}
export const getNetworksList = async (appName) => {
return await settingsClient.url("networks")
.get()
.json(res => res["networks"]);
}
export const getPlugins = async (appName) => {
return await settingsClient.url("plugins")
.get()
.json(res => res["plugins"]);
}
export const getDockerRegistryReport = async () => {
return await settingsClient.url("registry")
.get()
.json(res => res);
}
export const setDockerRegistry = async ({server, username, password}) => {
return await settingsClient.url("registry")
.post({server, username, password})
.res(res => res.ok);
}
export const addGitAuth = async ({host, username, password}) => {
return await settingsClient.url("git-auth")
.post({host, username, password})
.res(res => res.ok);
}
export const removeGitAuth = async (host) => {
return await settingsClient.url("git-auth")
.post({host})
.res(res => res.ok);
}

60
ui/src/lib/apis/setup.js Normal file
View File

@@ -0,0 +1,60 @@
import {createApiClient} from "./client.js";
const setupKeyMiddleware = next => (url, opts) => {
const key = localStorage.getItem("setup_key");
if (key) opts.headers = {...opts.headers, "X-Setup-Key": key};
return next(url, opts);
};
const setupClient = createApiClient("/setup/")
.middlewares([setupKeyMiddleware]);
export const verifySetupKeyValid = async () => {
return setupClient.url("verify-key")
.get()
.res(r => r.ok)
.catch(r => false);
}
export const completeCreateAppHandshake = async (code) => {
return setupClient.url("github/create-app")
.post({code})
.json();
}
export const getGithubAppSetupStatus = async () => {
return setupClient.url("github/status")
.get()
.json();
}
export const getGithubAppInstallInfo = async () => {
return setupClient.url("github/install-info")
.get()
.json();
}
export const completeGithubSetup = async (params) => {
return setupClient.url("github/completed")
.post(params)
.res();
}
export const requestGenerateTotp = async () => {
return setupClient.url("totp/new")
.post()
.json()
}
export const confirmTotpCode = async (secret, code) => {
return setupClient.url("totp/confirm")
.post({secret, code})
.json(res => res["valid"])
.catch(_ => false)
}
export const confirmPasswordSetup = async (opts) => {
return setupClient.url("password")
.post(opts)
.res(res => res.ok)
}

95
ui/src/lib/auth.js Normal file
View File

@@ -0,0 +1,95 @@
import Cookies from "js-cookie";
import jwt_decode from "jwt-decode";
import {refreshAuthToken} from "./api";
const AuthDataCookieKey = "auth_data";
const timeTillExpiry = (authCookie) => {
const expiryTimestampMillis = authCookie["exp"] * 1000;
const expiryTime = new Date(expiryTimestampMillis);
return expiryTime.getTime() - Date.now();
}
export const readAuthCookie = () => {
const val = Cookies.get(AuthDataCookieKey);
if (!val) return null;
const payload = jwt_decode(val);
const timeLeft = timeTillExpiry(payload);
if (timeLeft < 0) return null;
return payload;
}
// check every 30 seconds, refresh token at 2 minutes left
const refreshTokenCutoffMs = 2 * 60_000;
const refreshIntervalDelayMs = 30_000;
const tryRefreshAuth = async () => {
const auth = readAuthCookie();
if (!auth) return;
if (timeTillExpiry(auth) - refreshTokenCutoffMs > 0) return;
try {
await refreshAuthToken();
} catch (e) {
console.error("error refreshing auth", e);
}
}
let refreshIntervalId;
export const startAuthRefresh = async () => {
if (refreshIntervalId) stopAuthRefresh();
refreshIntervalId = setInterval(tryRefreshAuth, refreshIntervalDelayMs)
}
export const stopAuthRefresh = () => {
if (refreshIntervalId) clearInterval(refreshIntervalId);
refreshIntervalId = null;
}
const generateRandomString = (n) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let vals = new Uint8Array(n);
window.crypto.getRandomValues(vals);
return String.fromCharCode.apply(null, vals.map(
x => chars.charCodeAt(x % chars.length)));
}
export const createStoredState = (storageKey) => {
const state = generateRandomString(16);
localStorage.setItem(storageKey, state);
return state;
}
export const checkStoredState = (storageKey, supplied) => {
const storedState = localStorage.getItem(storageKey);
if (supplied === storedState) {
localStorage.removeItem("github_state");
return true;
}
return false;
}
export const getSetupStatus = async (refresh) => {
if (!refresh) {
const storedStatus = localStorage.getItem("setup_status");
if (!!storedStatus) return JSON.parse(storedStatus);
}
const status = await fetch("/api/setup/status").then(r => r.json());
setSetupStatus(status)
return status;
}
export const setSetupStatus = (status) => {
localStorage.setItem("setup_status", JSON.stringify(status));
}
export const checkSetupKeySet = () => {
const storedKey = localStorage.getItem("setup_key");
return !!storedKey;
}
export const setSetupKey = (key) => {
localStorage.setItem("setup_key", key);
}

87
ui/src/lib/stores.js Normal file
View File

@@ -0,0 +1,87 @@
import {readable, writable} from "svelte/store";
import {getExecutionStatus} from "./api.js";
const createAppThemeStore = () => {
const themeKey = "app_theme";
const defaultTheme = "business";
const storedValue = localStorage.getItem(themeKey);
const {subscribe, set} = writable(storedValue || defaultTheme);
return {
subscribe,
set: (val) => {
localStorage.setItem(themeKey, val);
set(val);
}
}
}
let completionCallbacks = {}
const createCommandExecutionIdsStore = () => {
const {subscribe, update} = writable([]);
const remove = (id) => update(val => val.filter(el => el !== id));
return {
subscribe,
addID: (id) => {
update(ids => [...ids, id]);
return new Promise(res => completionCallbacks[id] = res);
},
signalFinished: (id, success) => {
remove(id);
if (completionCallbacks[id]) {
completionCallbacks[id](success);
delete completionCallbacks[id];
}
},
remove,
}
}
const pollCommandExecutionsFn = (idsStore) => {
const pollIntervalMs = 5000;
let ids = [];
idsStore.subscribe(val => ids = val);
return (set) => {
let isPolling = {}
const interval = setInterval(async () => {
let didPoll = false;
let statuses = {}
for (const i in ids) {
const id = ids[i];
if (isPolling[id]) return;
if (!completionCallbacks[id]) return;
didPoll = true;
isPolling[id] = true;
try {
let status = await getExecutionStatus(id);
statuses[id] = status;
isPolling[id] = false
if (status["finished"]) {
idsStore.signalFinished(id, status["success"]);
}
} catch (e) {
if (e.status === 404) {
delete isPolling[id];
idsStore.remove(id);
return;
}
}
isPolling[id] = false;
}
if (didPoll) set(statuses);
}, pollIntervalMs);
return () => clearInterval(interval);
}
}
export const appTheme = createAppThemeStore();
export const commandExecutionIds = createCommandExecutionIdsStore();
export const commandExecutions = readable({}, pollCommandExecutionsFn(commandExecutionIds));
export const executionIdDescriptions = writable({});

38
ui/src/routes/+layout.js Normal file
View File

@@ -0,0 +1,38 @@
import {readAuthCookie, getSetupStatus} from "$lib/auth";
import {redirect} from "@sveltejs/kit";
export const ssr = false;
export const prerender = false;
export async function load({url, depends}) {
depends("app:load");
const path = url.pathname;
const onSetupPath = path.startsWith("/setup");
const invalidateStatus = url.searchParams.get("invalidate_setup") === "1";
let setupStatus;
try {
setupStatus = await getSetupStatus(onSetupPath || invalidateStatus);
} catch (e) {
console.error("failed to get setup status", e);
}
let isSetup = (setupStatus && setupStatus["is_setup"]);
if (onSetupPath) {
if (isSetup) throw redirect(302, "/");
return {};
}
if (!setupStatus || !setupStatus["is_setup"])
throw redirect(302, "/setup");
const authDetails = await readAuthCookie();
if (authDetails) return {authDetails};
const loginMethod = setupStatus["method"];
const loginBasePath = `/login/${loginMethod}`
if (path.startsWith(loginBasePath)) return {};
throw redirect(307, loginBasePath);
}

View File

@@ -0,0 +1,82 @@
<script>
import "$src/app.css";
import { onMount, onDestroy } from "svelte";
import { startAuthRefresh, stopAuthRefresh } from "$lib/auth";
import { commandExecutionIds, appTheme } from "$lib/stores";
import Header from "$components/Header.svelte";
import { QueryClient, QueryClientProvider } from "@sveltestack/svelte-query";
import CommandExecutionWindow from "$components/commands/CommandExecutionWindow.svelte";
import { goto } from "$app/navigation";
export let data = {};
let isAuthenticated;
$: isAuthenticated = !!data.authDetails;
let executionWindowVisible = null;
let executionWindowWatchingCompleted = false;
let showExecutionButton = false;
$: if ($commandExecutionIds !== null) {
showExecutionButton =
$commandExecutionIds.length > 0 || executionWindowWatchingCompleted;
executionWindowVisible = showExecutionButton;
}
const toggleExecutionWindow = () => {
executionWindowVisible = !executionWindowVisible;
};
onMount(startAuthRefresh);
onDestroy(stopAuthRefresh);
const checkQueryError = (err) => {
err = JSON.parse(err.message);
if (err.message === "setup key invalid") goto("/?invalidate_setup=1");
};
const queryClient = new QueryClient();
queryClient.setDefaultOptions({
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: 1,
onError: checkQueryError,
},
});
</script>
<svelte:head>
<title>shokku</title>
</svelte:head>
<div data-theme={$appTheme}>
<QueryClientProvider client={queryClient}>
<div class="flex flex-col h-screen w-screen max-w-screen max-h-screen">
<div>
<Header
{isAuthenticated}
{showExecutionButton}
{executionWindowVisible}
on:executionButtonClicked={toggleExecutionWindow}
/>
</div>
<div class="flex flex-grow min-h-0 h-full">
<div class="flex-grow w-full min-h-0 h-full max-h-screen">
<slot />
</div>
</div>
</div>
<div
class="absolute z-50 top-24 px-8 w-full"
class:hidden={!executionWindowVisible}
>
<CommandExecutionWindow
bind:watchingCompleted={executionWindowWatchingCompleted}
/>
</div>
</QueryClientProvider>
</div>

View File

@@ -0,0 +1,37 @@
<script>
import { useQuery } from "@sveltestack/svelte-query";
import { getAllAppsOverview, listServices } from "$lib/api";
import DashboardCardList from "$components/dashboard/DashboardCardList.svelte";
const queryOpts = {
refetchOnMount: true,
};
const appsOverview = useQuery(
"getAllAppsOverview",
getAllAppsOverview,
queryOpts
);
const servicesList = useQuery("listServices", listServices, queryOpts);
const appStates = {};
const serviceStates = {};
</script>
<div class="flex flex-col md:flex-row gap-8 p-4 h-full overflow-y-scroll">
<div class="hidden md:inline flex-grow" />
<div class="max-w-full md:max-w-lg md:flex-grow">
<DashboardCardList
contentType="app"
query={appsOverview}
states={appStates}
/>
</div>
<div class="max-w-full md:max-w-lg md:flex-grow">
<DashboardCardList
contentType="service"
query={servicesList}
states={serviceStates}
/>
</div>
<div class="hidden md:inline flex-grow" />
</div>

View File

@@ -0,0 +1,5 @@
<script>
import { goto } from "$app/navigation";
goto("/");
</script>

View File

@@ -0,0 +1,61 @@
<script>
import { page } from "$app/stores";
import { getAppOverview } from "$lib/api";
import { useQuery, useQueryClient } from "@sveltestack/svelte-query";
import ContentPage from "$common/ContentPage.svelte";
import Sidebar from "$common/Sidebar.svelte";
import AppHeader from "./AppHeader.svelte";
const appName = $page.params.name;
let onSetupPage;
let currentPage;
$: if ($page.url) {
onSetupPage = $page.url.pathname.startsWith(`/app/${appName}/setup`);
currentPage = $page.url.pathname.substring(`/app/${appName}`.length + 1);
}
const pages = [
{ name: "Overview", path: "" },
{ name: "Builds", path: "builds" },
{ name: "Domains", path: "domains" },
{ name: "Environment", path: "environment" },
{ name: "Logs", path: "logs" },
{ name: "Network", path: "network" },
{ name: "Services", path: "services" },
// {"name": "Terminal", "path": "terminal"},
{ name: "Storage", path: "storage" },
{ name: "Settings", path: "settings" },
];
const queryClient = useQueryClient();
const appOverviewQueryKey = [{ appName }, "getAppOverview"];
const appOverview = useQuery(appOverviewQueryKey, () =>
getAppOverview(appName)
);
const statusChanged = () =>
queryClient.invalidateQueries(appOverviewQueryKey);
</script>
<ContentPage>
<div slot="sidebar" class:hidden={onSetupPage}>
<Sidebar {pages} prefix={"/app/" + $page.params.name} />
</div>
<div slot="header" class:hidden={onSetupPage} class="mb-2">
{#if $appOverview.isSuccess}
<AppHeader
on:statusChanged={statusChanged}
isDeployed={$appOverview.data["is_deployed"]}
isRunning={$appOverview.data["is_running"]}
isSetup={$appOverview.data["is_setup"]}
setupMethod={$appOverview.data["setup_method"]}
/>
{/if}
</div>
<div slot="content">
<slot />
</div>
</ContentPage>

View File

@@ -0,0 +1,74 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { getAppOverview } from "$lib/api";
import { useQuery } from "@sveltestack/svelte-query";
import Icon from "$common/Icon.svelte";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import Code from "$common/Code.svelte";
import Cards from "$common/Cards.svelte";
import Card from "$common/Card.svelte";
import ProcessesOverview from "$components/processes/ProcessesOverview.svelte";
const appName = $page.params.name;
const appOverviewQueryKey = [{ appName }, "getAppOverview"];
const appOverview = useQuery(appOverviewQueryKey, () =>
getAppOverview(appName)
);
let gitPushLines = [];
let isSetup = false;
let setupMethod = "";
let isDeployed = false;
let isRunning = false;
let numProcesses = 0;
$: if ($appOverview.isSuccess && $appOverview.data) {
const data = $appOverview.data;
isSetup = data["is_setup"];
setupMethod = data["setup_method"];
isDeployed = data["is_deployed"];
isRunning = data["is_running"];
numProcesses = data["num_processes"];
let deployBranch = data["git_deploy_branch"];
gitPushLines = [
`git remote add dokku dokku@${$page.url.host}:${appName}`,
`git push dokku ${deployBranch}:master`,
];
}
</script>
<Cards>
<Card title={isDeployed ? "Processes" : "Almost there..."}>
<QueryDataWrapper query={appOverview} action="loading app overview">
{#if isDeployed}
<ProcessesOverview />
{:else}
<div class="hero p-6">
<div class="hero-content text-center">
<div class="max-w-md">
{#if isSetup && setupMethod === "git"}
<p class="py-2">Add the remote and push your code to deploy:</p>
<Code lines={gitPushLines} prefix=">" />
{:else}
<p class="py-6">Some additional setup required to deploy</p>
<button
class="btn btn-primary gap-2"
on:click={() => goto(`/app/${appName}/setup`)}
>
<Icon type="spanner" size="sm" />
setup app
</button>
{/if}
</div>
</div>
</div>
{/if}
</QueryDataWrapper>
</Card>
</Cards>

View File

@@ -0,0 +1,111 @@
<script>
import {
getAppDomainsReport,
restartApp,
rebuildApp,
startApp,
stopApp,
} from "$lib/api";
import { commandExecutionIds, executionIdDescriptions } from "$lib/stores";
import { page } from "$app/stores";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import { createEventDispatcher } from "svelte";
import AppHeaderIconButton from "$src/routes/app/[name]/AppHeaderIconButton.svelte";
import Icon from "$common/Icon.svelte";
export let isSetup;
export let setupMethod;
export let isRunning;
export let isDeployed;
const appName = $page.params.name;
const queryClient = useQueryClient();
const queryKey = [{ appName }, "getAppDomainsReport"];
const domainsReport = useQuery(queryKey, () => getAppDomainsReport(appName));
const dispatch = createEventDispatcher();
const onSuccess = () => {
dispatch("statusChanged");
};
const newMutation = (action, fn) => {
const mutationFn = async () => {
const id = await fn(appName);
$executionIdDescriptions[id] = `${action} ${appName}`;
return commandExecutionIds.addID(id);
};
return useMutation(mutationFn, { onSuccess });
};
const restartMutation = newMutation("Restart", restartApp);
const rebuildMutation = newMutation("Rebuild", rebuildApp);
const startMutation = newMutation("Start", startApp);
const stopMutation = newMutation("Stop", stopApp);
let loading;
$: loading =
$startMutation.isLoading ||
$restartMutation.isLoading ||
$rebuildMutation.isLoading ||
$stopMutation.isLoading;
let appDomain;
$: if ($domainsReport.data) {
appDomain = null;
let domains = $domainsReport.data["domains"];
if (domains && domains.length > 0) {
appDomain = domains[0];
}
}
</script>
<div class="w-full min-h-16 flex flex-row bg-base-200 p-4 items-center rounded-lg">
<div class="flex flex-row items-center">
{#if appDomain}
<a href="http://{appDomain}" class="link link-hover" target="_blank">
<div class="flex flex-row">
<span class="text-3xl mr-1">
{appName}
</span>
<Icon type="external-link" size="sm" />
</div>
</a>
{:else}
<span class="text-3xl align-center">{appName}</span>
{/if}
</div>
<div class="flex-grow" ></div>
<div class="flex md:flex-row flex-col gap-2">
{#if isSetup && isDeployed && isRunning}
<AppHeaderIconButton
action="rebuild"
{loading}
on:clicked={$rebuildMutation.mutate}
/>
{/if}
{#if isDeployed && !isRunning}
<AppHeaderIconButton
action="start"
{loading}
on:clicked={$startMutation.mutate}
/>
{:else if isRunning}
<AppHeaderIconButton
action="stop"
{loading}
on:clicked={$stopMutation.mutate}
/>
<AppHeaderIconButton
action="restart"
{loading}
on:clicked={$restartMutation.mutate}
/>
{/if}
</div>
</div>

View File

@@ -0,0 +1,21 @@
<script>
import Icon from "$common/Icon.svelte";
import { createEventDispatcher } from "svelte";
export let action;
export let loading;
const dispatch = createEventDispatcher();
</script>
<button
class="btn btn-outline md:btn-sm"
class:gap-2={!loading}
class:loading
on:click={() => dispatch("clicked")}
>
{#if !loading}
<Icon type={action} size="sm" />
{/if}
{action}
</button>

View File

@@ -0,0 +1,27 @@
<script>
import Cards from "$common/Cards.svelte";
import Card from "$common/Card.svelte";
import BuilderSelection from "./BuilderSelection.svelte";
import BuildDirectory from "./BuildDirectory.svelte";
import DeployChecks from "./DeployChecks.svelte";
import SetupConfig from "./SetupConfig.svelte";
</script>
<Cards>
<Card title="Config">
<div class=""><SetupConfig /></div>
</Card>
<Card title="Builder Selection">
<div class="max-w-xs"><BuilderSelection /></div>
</Card>
<Card title="Build Directory">
<div class="max-w-xs"><BuildDirectory /></div>
</Card>
<Card title="Zero-Downtime Deployment Checks">
<div class="max-w-xs"><DeployChecks /></div>
</Card>
</Cards>

View File

@@ -0,0 +1,88 @@
<script>
import {
getAppBuildDirectory,
setAppBuildDirectory,
clearAppBuildDirectory,
} from "$lib/api";
import { page } from "$app/stores";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import Error from "$common/Error.svelte";
const appName = $page.params.name;
const queryClient = useQueryClient();
const queryKey = [{ appName }, "getAppBuildDirectory"];
let buildDir = "";
let enableBuildDirectory = false;
const onBuildDirFetched = (data) => {
buildDir = data;
enableBuildDirectory = !!buildDir;
};
const getBuildDir = useQuery(queryKey, () => getAppBuildDirectory(appName), {
onSuccess: onBuildDirFetched,
});
const setDir = useMutation(
(newDir) => setAppBuildDirectory(appName, newDir),
{
onSuccess: (_, newDir) => queryClient.setQueryData(queryKey, newDir),
}
);
const clearDir = useMutation(() => clearAppBuildDirectory(appName), {
onSuccess: () => queryClient.setQueryData(queryKey, null),
});
const onEnableToggled = async (e) => {
enableBuildDirectory = e.target.checked;
if (!e.target.checked) $clearDir.mutate(appName);
};
</script>
<QueryDataWrapper query={getBuildDir} action="loading build directory">
<label class="label cursor-pointer">
<span class="label-text">Use custom directory</span>
<input
type="checkbox"
class="toggle"
disabled={$setDir.isLoading || $clearDir.isLoading}
checked={enableBuildDirectory}
on:change={onEnableToggled}
/>
</label>
{#if enableBuildDirectory}
<label class="input-group input-group-md">
<span class="w-auto">Directory</span>
<input
type="text"
class="input input-md input-bordered flex-grow"
disabled={$setDir.isLoading || $clearDir.isLoading}
bind:value={buildDir}
/>
</label>
<div class="mt-4">
<button
class="btn btn-primary"
class:loading={$setDir.isLoading || $clearDir.isLoading}
class:hidden={$getBuildDir.data === buildDir}
on:click={() => $setDir.mutate(buildDir)}
>
Save
</button>
</div>
{/if}
</QueryDataWrapper>
{#if $setDir.isError}
<Error action="setting build directory" error={$setDir.error} />
{/if}
{#if $clearDir.isError}
<Error action="clearing build directory" error={$clearDir.error} />
{/if}

View File

@@ -0,0 +1,49 @@
<script>
import { getSelectedBuilder, setSelectedBuilder } from "$lib/api";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import Error from "$common/Error.svelte";
import { page } from "$app/stores";
const appName = $page.params.name;
const queryClient = useQueryClient();
const queryKey = [{ appName }, "getAppBuilder"];
const selectedBuilder = useQuery(queryKey, () => getSelectedBuilder(appName));
const updateBuilder = useMutation(
(newBuilder) => setSelectedBuilder(appName, newBuilder),
{
onSuccess: (_, newBuilder) =>
queryClient.setQueryData(queryKey, newBuilder),
}
);
const builders = {
auto: "Auto Detect",
dockerfile: "Dockerfile",
herokuish: "Herokuish",
lambda: "Lambda",
// "pack": "Cloud Native Buildpacks",
null: "Null Builder",
};
</script>
<QueryDataWrapper query={selectedBuilder} action="loading selected builder">
<select
class="select w-full select-bordered"
value={$selectedBuilder.data}
on:change={(e) => $updateBuilder.mutate(e.target.value)}
>
{#each Object.keys(builders) as builder}
<option value={builder}>{builders[builder]}</option>
{/each}
</select>
</QueryDataWrapper>
{#if $updateBuilder.isError}
<Error action="changing selected builder" error={$updateBuilder.error} />
{/if}

View File

@@ -0,0 +1,60 @@
<script>
import { page } from "$app/stores";
import { useMutation, useQuery } from "@sveltestack/svelte-query";
import { getAppDeployChecksReport, setAppDeployChecksState } from "$lib/api";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
const appName = $page.params.name;
let allDisabled = false;
let allSkipped = false;
const getReport = async () => {
const report = await getAppDeployChecksReport(appName);
allDisabled = report["all_disabled"];
allSkipped = report["all_skipped"];
return report;
};
const queryKey = [{ appName }, "getAppDeployChecks"];
const checksReport = useQuery(queryKey, getReport);
const stateMutation = useMutation((state) =>
setAppDeployChecksState(appName, state)
);
const updateSkipped = () => {
if (allDisabled) return;
$stateMutation.mutate(allSkipped ? "skipped" : "enabled");
};
const updateDisabled = () =>
$stateMutation.mutate(allDisabled ? "disabled" : "enabled");
</script>
<QueryDataWrapper query={checksReport} action="loading deploy checks state">
<label class="label mt-2" class:cursor-pointer={!allDisabled}>
<span class="label-text">Skip Deploy Checks</span>
<div
class="tooltip-warning"
class:tooltip={allDisabled}
data-tip="Checks are disabled"
>
<input
type="checkbox"
class="toggle"
disabled={allDisabled || $checksReport.isLoading}
bind:checked={allSkipped}
on:change={updateSkipped}
/>
</div>
</label>
<label class="label cursor-pointer mt-2">
<span class="label-text">Disable Deploy Checks</span>
<input
type="checkbox"
class="toggle"
disabled={$checksReport.isLoading}
bind:checked={allDisabled}
on:change={updateDisabled}
/>
</label>
</QueryDataWrapper>

View File

@@ -0,0 +1,62 @@
<script>
// TODO: delete this?
import { useMutation } from "@sveltestack/svelte-query";
import { setAppProcessDeployChecksState } from "$lib/api";
import { page } from "$app/stores";
import Error from "$common/Error.svelte";
export let process;
export let checks;
const appName = $page.params.name;
const stateMutation = useMutation((state) =>
setAppProcessDeployChecksState(appName, process, state)
);
const onToggleEnabled = async (e) =>
$stateMutation.mutate(e.target.checked ? "enabled" : "disabled");
const onToggleSkipped = async (e) =>
$stateMutation.mutate(e.target.checked ? "skipped" : "enabled");
const manualChecksTrigger = async () => {
alert("todo");
};
</script>
<span class="text-lg font-bold">{process}</span>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Enable Deployment Checks</span>
<input
type="checkbox"
class="toggle"
class:disabled={$stateMutation.isLoading}
on:change={onToggleEnabled}
checked={checks["enabled"]}
/>
</label>
</div>
{#if checks["enabled"]}
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Skip Deployment Checks</span>
<input
type="checkbox"
class="toggle"
class:disabled={$stateMutation.isLoading}
on:change={onToggleSkipped}
checked={checks["skipped"]}
/>
</label>
</div>
<button class="btn btn-sm" on:click={manualChecksTrigger}>Run Checks</button>
{/if}
{#if $stateMutation.isError}
<Error error={$stateMutation.error} />
{/if}

View File

@@ -0,0 +1,54 @@
<script>
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import { page } from "$app/stores";
import { useQuery } from "@sveltestack/svelte-query";
import { getAppSetupConfig } from "$lib/api";
const appName = $page.params.name;
const queryKey = [{ appName }, "getAppSetupConfig"];
const query = useQuery(queryKey, () => getAppSetupConfig(appName));
let isSetup, method, deploymentBranch, repoUrl, repoGitRef, image;
$: if ($query.isFetched && $query.data) {
isSetup = $query.data["is_setup"];
method = $query.data["method"];
image = $query.data["image"];
deploymentBranch = $query.data["deployment_branch"];
repoUrl = $query.data["repo_url"];
repoGitRef = $query.data["repo_git_ref"];
}
</script>
<QueryDataWrapper {query} action="getting setup config">
{#if isSetup}
{#if method === "git"}
<span
>Building on git push to branch <strong>{deploymentBranch}</strong
></span
>
{:else if method === "sync-repo"}
<span
>Synced from remote git repo <strong>{repoUrl}</strong>
using git ref {repoGitRef}</span
>
{:else if method === "docker"}
<span>Built from image <strong>{image}</strong></span>
{/if}
<div class="">
<a href={`/app/${appName}/setup`}>
<button class="btn btn-primary"> Update Configuration </button>
</a>
</div>
{:else}
<div>
<span class="text-lg text-warning"
>App needs to be setup before it can be built</span
>
</div>
<a href={`/app/${appName}/setup`}>
<button class="btn btn-info"> Go to setup </button>
</a>
{/if}
</QueryDataWrapper>

View File

@@ -0,0 +1,5 @@
<script>
import Todo from "$common/Todo.svelte";
</script>
<Todo />

View File

@@ -0,0 +1,75 @@
<script>
import { page } from "$app/stores";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import { addAppDomain, getAppDomainsReport, removeAppDomain } from "$lib/api";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import Card from "$common/Card.svelte";
import Icon from "$common/Icon.svelte";
import AddDomainModal from "./AddDomainModal.svelte";
import DomainListItem from "./DomainListItem.svelte";
const appName = $page.params.name;
const queryClient = useQueryClient();
const queryKey = [{ appName }, "getAppDomainsReport"];
const domainsReport = useQuery(queryKey, () => getAppDomainsReport(appName));
let addDomainModalOpen = false;
const onSuccess = () => {
addDomainModalOpen = false;
queryClient.invalidateQueries(queryKey);
};
const addDomainMutation = useMutation(
(domain) => addAppDomain(appName, domain),
{ onSuccess }
);
const removeDomainMutation = useMutation(
(domain) => removeAppDomain(appName, domain),
{ onSuccess }
);
const addDomain = ({ detail }) => $addDomainMutation.mutate(detail.domain);
const removeDomain = ({ detail }) =>
$removeDomainMutation.mutate(detail.domain);
let domains = ["bleh.com"];
$: if ($domainsReport.isSuccess && $domainsReport.data) {
domains = $domainsReport.data["domains"] || [];
}
</script>
<QueryDataWrapper query={domainsReport} action="fetching domains">
<Card title="Domains">
{#if domains.length === 0}
<p>No domains configured</p>
{:else}
<div class="flex flex-col gap-3 w-60">
{#each domains as domain, i}
<DomainListItem {domain} on:removeDomain={removeDomain} />
{/each}
</div>
{/if}
<div slot="actions">
<button class="btn gap-3" on:click={() => (addDomainModalOpen = true)}>
Add Domain
<Icon type="add" />
</button>
</div>
</Card>
</QueryDataWrapper>
<AddDomainModal
loading={$addDomainMutation.isLoading}
bind:open={addDomainModalOpen}
on:addDomain={addDomain}
/>

View File

@@ -0,0 +1,39 @@
<script>
import Modal from "$common/Modal.svelte";
import { createEventDispatcher } from "svelte";
import { page } from "$app/stores";
export let open;
export let loading;
const appName = $page.params.name;
const dispatch = createEventDispatcher();
let domain;
const signalAddDomain = () => dispatch("addDomain", { domain });
</script>
<Modal
name="add-domain"
title="Add a new domain for {appName}"
bind:open
preventClose={loading}
>
<div class="form-control">
<label class="input-group">
<span>Domain</span>
<input
type="text"
placeholder="foo.example.com"
class="input input-bordered"
bind:value={domain}
/>
</label>
</div>
<div class="mt-4">
<button class="btn" class:loading on:click={signalAddDomain}>
Confirm
</button>
</div>
</Modal>

View File

@@ -0,0 +1,49 @@
<script>
import { page } from "$app/stores";
import { removeAppDomain } from "$lib/api";
import { createEventDispatcher } from "svelte";
import Icon from "$common/Icon.svelte";
import ConfirmationModal from "$common/ConfirmationModal.svelte";
import Alert from "$common/Alert.svelte";
export let domain = "";
export let loading = false;
let domainHref = domain.startsWith("http") ? domain : `https://${domain}`;
const dispatch = createEventDispatcher();
const signalRemoved = () => dispatch("removeDomain", { domain });
let confirmationModalOpen = false;
</script>
<div
class="flex flex-row items-center gap-2 w-auto shadow-lg bg-base-100 p-2 rounded-lg"
>
<div>
<a class="link link-hover" href={domainHref}
><span class="text-lg">{domain}</span></a
>
</div>
<div class="flex-grow" />
<div class="">
<button
class="btn bg-base-100 hover:btn-error btn-sm btn-outline gap-2 btn-circle"
class:loading
on:click={() => (confirmationModalOpen = true)}
>
{#if !loading}
<Icon type="delete" size="sm" />
{/if}
</button>
</div>
</div>
<ConfirmationModal
name="remove-domain-modal"
title="Removing Domain"
action="remove domain {domain}"
on:accepted={signalRemoved}
bind:open={confirmationModalOpen}
doingAction={loading}
/>

View File

@@ -0,0 +1,47 @@
<script>
import { page } from "$app/stores";
import { getAppConfig, setAppConfig } from "$lib/api";
import { useQuery, useMutation } from "@sveltestack/svelte-query";
import Error from "$common/Error.svelte";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import KVEditor from "$common/KVEditor.svelte";
import Card from "$common/Card.svelte";
const appName = $page.params.name;
const queryKey = [{ appName }, "getAppConfig"];
const varsQuery = useQuery(queryKey, () => getAppConfig(appName));
let stateDirty = false;
const setConfigMutation = useMutation(setAppConfig, {
onSuccess: () => {
stateDirty = false;
},
});
const saveVars = async ({ detail }) => {
let newConfig = {};
for (let [k, v] of detail) newConfig[k] = v;
$setConfigMutation.mutate({ appName, newConfig });
};
</script>
<QueryDataWrapper query={varsQuery} action="getting environment variables">
<Card title="Environment Variables">
<KVEditor
vars={$varsQuery.data}
saving={$setConfigMutation.isLoading}
showSaveButton={true}
neutralButtons={true}
bind:stateDirty
on:save={saveVars}
/>
</Card>
</QueryDataWrapper>
{#if $setConfigMutation.isError}
<Error
action="updating environment variables"
error={$setConfigMutation.error}
/>
{/if}

View File

@@ -0,0 +1,22 @@
<script>
import { page } from "$app/stores";
import { getAppLogs } from "$lib/api";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import Logs from "$common/Logs.svelte";
import { useQuery } from "@sveltestack/svelte-query";
import Card from "$common/Card.svelte";
const appName = $page.params.name;
const queryKey = [{ appName }, "getAppLogs"];
const logsQuery = useQuery(queryKey, () => getAppLogs(appName));
</script>
<QueryDataWrapper query={logsQuery} action="fetching logs">
<Card title="Logs">
{#if !$logsQuery.data || $logsQuery.data.length === 0}
<p>No logs available</p>
{:else}
<Logs logs={$logsQuery.data} />
{/if}
</Card>
</QueryDataWrapper>

View File

@@ -0,0 +1,58 @@
<script>
import {
getNetworksList,
getAppNetworksReport,
updateAppNetworks,
} from "$lib/api";
import { page } from "$app/stores";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import Loader from "$common/Loader.svelte";
import Error from "$common/Error.svelte";
import AppNetworks from "./AppNetworks.svelte";
const appName = $page.params.name;
const queryClient = useQueryClient();
const reportQueryKey = [{ appName }, "getAppNetworkReport"];
const networkReport = useQuery(reportQueryKey, () =>
getAppNetworksReport(appName)
);
const networkList = useQuery("getNetworkList", getNetworksList);
const updateNetworks = useMutation(
(config) => updateAppNetworks(appName, config),
{ onSuccess: () => queryClient.invalidateQueries(reportQueryKey) }
);
let isLoaded;
$: isLoaded = $networkReport.isSuccess && $networkList.isSuccess;
</script>
{#if $networkReport.isLoading || $networkList.isLoading}
<Loader />
{/if}
{#if $networkList.isError}
<Error action="fetching network info" error={$networkList.error} />
{/if}
{#if $networkReport.isError}
<Error action="fetching app network info" error={$networkReport.error} />
{/if}
{#if isLoaded}
<AppNetworks
report={$networkReport.data}
networks={$networkList.data}
loading={$updateNetworks.isLoading}
on:saved={({ detail }) => $updateNetworks.mutate(detail)}
/>
{/if}
{#if $updateNetworks.isError}
<Error action="updating app network details" error={$updateNetworks.error} />
{/if}

View File

@@ -0,0 +1,154 @@
<script>
import { createEventDispatcher } from "svelte";
import NetworkSelect from "./NetworkSelect.svelte";
import Card from "$common/Card.svelte";
import Cards from "$common/Cards.svelte";
export let loading = false;
export let report = {};
export let networks = [];
let reportAI, reportPC, reportPD;
let initial, postCreate, postDeploy;
let reportBAI, bindAllInterfaces;
let reportTLD, tld;
let enableInitial = false;
let enablePostCreate = false;
let enablePostDeploy = false;
const resetValues = () => {
reportAI = report["attach_initial"];
initial = reportAI;
enableInitial = !!initial;
reportPC = report["attach_post_create"];
postCreate = reportPC;
enablePostCreate = !!postCreate;
reportPD = report["attach_post_deploy"];
postDeploy = reportPD;
enablePostDeploy = !!postDeploy;
reportBAI = report["bind_all_interfaces"];
bindAllInterfaces = reportBAI === true;
reportTLD = report["tld"];
tld = reportTLD;
};
$: if (report) resetValues();
const getDirty = () => {
let pairs = [
[enableInitial ? initial : "", "attach_initial"],
[enablePostCreate ? postCreate : "", "attach_post_create"],
[enablePostDeploy ? postDeploy : "", "attach_post_deploy"],
[bindAllInterfaces, "bind_all_interfaces"],
[tld, "tld"],
];
return pairs.filter((pair) => pair[0] !== report[pair[1]]);
};
const dispatch = createEventDispatcher();
const tryUpdateNetwork = () => {
let dirty = getDirty();
let updateMap = {};
for (let i in dirty) {
let [val, key] = dirty[i];
updateMap[key] = val;
}
dispatch("saved", updateMap);
};
</script>
<Cards>
<Card title="Attached Networks">
<NetworkSelect
labelText="Initial"
bind:enable={enableInitial}
bind:selected={initial}
dirty={initial !== reportAI && enableInitial !== !!reportAI}
on:save={tryUpdateNetwork}
{networks}
{loading}
/>
<NetworkSelect
labelText="Post-Create"
bind:enable={enablePostCreate}
bind:selected={postCreate}
dirty={postCreate !== reportPC && enablePostCreate !== !!reportPC}
on:save={tryUpdateNetwork}
{networks}
{loading}
/>
<NetworkSelect
labelText="Post-Deploy"
bind:enable={enablePostDeploy}
bind:selected={postDeploy}
dirty={postDeploy !== reportPD && enablePostDeploy !== !!reportPD}
on:save={tryUpdateNetwork}
{networks}
{loading}
/>
<div class="divider max-w-xs" />
<div class="form-control w-full max-w-xs">
<label class="label cursor-pointer">
<span class="label-text">Bind All Interfaces</span>
<input
type="checkbox"
class="toggle"
bind:checked={bindAllInterfaces}
/>
</label>
<button
class="btn btn-primary"
on:click={tryUpdateNetwork}
class:hidden={bindAllInterfaces === reportBAI}
class:loading
>
Update
</button>
</div>
</Card>
<Card title="Custom TLD">
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">Set Custom TLD</span>
</label>
<input
type="text"
class="input input-bordered w-full max-w-xs"
placeholder="svc.local"
bind:value={tld}
/>
</div>
<div slot="actions">
<button
class="btn btn-primary"
on:click={tryUpdateNetwork}
class:hidden={tld === reportTLD}
class:loading
>
Update
</button>
</div>
</Card>
{#if report["web_listeners"]}
<Card title="Web Listeners">
<p>{report["web_listeners"]}</p>
</Card>
{/if}
<!--div class="mt-2">
<button class="btn btn-primary" on:click={tryUpdateNetwork} class:loading>
Update
</button>
</div-->
</Cards>

View File

@@ -0,0 +1,40 @@
<script>
import { createEventDispatcher } from "svelte";
export let labelText;
export let enable;
export let selected = "";
export let networks = [];
export let loading;
export let dirty;
if (!selected) selected = "host";
const dispatch = createEventDispatcher();
</script>
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">{labelText}</span>
<input type="checkbox" class="toggle" bind:checked={enable} />
</label>
{#if enable}
<select
bind:value={selected}
class="select select-bordered w-full max-w-xs"
>
{#each networks as network}
<option value={network}>{network}</option>
{/each}
</select>
{/if}
<button
class="btn btn-primary my-2"
on:click={() => dispatch("save")}
class:hidden={!dirty}
class:loading
>
Update
</button>
</div>

View File

@@ -0,0 +1,135 @@
<script>
import {
listServices,
listAppServices,
linkServiceToApp,
unlinkServiceFromApp,
} from "$lib/api";
import { commandExecutionIds, executionIdDescriptions } from "$lib/stores";
import { page } from "$app/stores";
import Loader from "$common/Loader.svelte";
import Error from "$common/Error.svelte";
import LinkCard from "$components/links/LinkCard.svelte";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import Cards from "$common/Cards.svelte";
import Card from "$common/Card.svelte";
const appName = $page.params.name;
const queryClient = useQueryClient();
const linkedQueryKey = [{ appName }, "listAppServices"];
const allServices = useQuery("listServices", listServices);
const getAppServices = useQuery(linkedQueryKey, () =>
listAppServices(appName)
);
let linkedServices = [];
let unlinkedServices = [];
$: if ($getAppServices.isSuccess && $allServices.isSuccess) {
linkedServices = $getAppServices.data;
unlinkedServices = $allServices.data.filter((service) => {
return (
linkedServices.find((el) => service.name === el.name) === undefined
);
});
}
const invalidateServicesQuery = () =>
queryClient.invalidateQueries(linkedQueryKey);
let loading = {};
const linkService = async ({ serviceName, options }) => {
loading[serviceName] = true;
const id = await linkServiceToApp(serviceName, appName, options);
$executionIdDescriptions[id] = `Linking ${serviceName} to ${appName}`;
return await commandExecutionIds.addID(id);
};
const unlinkService = async ({ serviceName }) => {
loading[serviceName] = true;
const id = await unlinkServiceFromApp(serviceName, appName);
$executionIdDescriptions[id] = `Unlinking ${serviceName} from ${appName}`;
return await commandExecutionIds.addID(id);
};
const mutationOutcomes = {
onSuccess: () => invalidateServicesQuery(),
onSettled: (_, __, { serviceName }) => {
loading[serviceName] = false;
},
};
const linkMutation = useMutation(linkService, mutationOutcomes);
const unlinkMutation = useMutation(unlinkService, mutationOutcomes);
</script>
{#if $allServices.isLoading || $getAppServices.isLoading}
<Loader />
{/if}
{#if $allServices.isError}
<Error action="loading services" error={$allServices.error} />
{/if}
{#if $getAppServices.isError}
<Error action="loading linked services" error={$getAppServices.error} />
{/if}
{#if $allServices.isSuccess && $getAppServices.isSuccess}
<Cards>
{#if linkedServices.length > 0}
<Card title="Linked">
<div class="flex flex-row flex-wrap gap-2">
{#each linkedServices as service}
<LinkCard
cardType="service"
{appName}
isLinked={true}
serviceName={service.name}
serviceType={service.type}
on:unlink={({ detail }) => $unlinkMutation.mutate(detail)}
loading={loading[service.name]}
/>
{/each}
</div>
</Card>
{/if}
{#if unlinkedServices.length > 0}
<Card title="Unlinked">
<div class="flex flex-row flex-wrap w-auto gap-2">
<!-- unlinked services -->
{#each unlinkedServices as service}
<LinkCard
cardType="service"
{appName}
isLinked={false}
serviceName={service.name}
serviceType={service.type}
on:link={({ detail }) => $linkMutation.mutate(detail)}
loading={loading[service.name]}
/>
{/each}
</div>
</Card>
{:else}
<Card title="No Services">
<a href="/service/new" class="link">
<button class="btn btn-secondary">Create a service</button>
</a>
</Card>
{/if}
</Cards>
{/if}
{#if $linkMutation.isError}
<Error action="linking service" error={$linkMutation.error} />
{/if}
{#if $unlinkMutation.isError}
<Error action="unlinking service" error={$unlinkMutation.error} />
{/if}

View File

@@ -0,0 +1,16 @@
<script>
import DestroyAppButton from "./DestroyAppButton.svelte";
import RenameAppButton from "./RenameAppButton.svelte";
import Cards from "$common/Cards.svelte";
import Card from "$common/Card.svelte";
</script>
<Cards>
<Card title="Rename App">
<RenameAppButton />
</Card>
<Card title="Destroy App">
<DestroyAppButton />
</Card>
</Cards>

View File

@@ -0,0 +1,50 @@
<script>
import { goto } from "$app/navigation";
import { destroyApp } from "$lib/api";
import { page } from "$app/stores";
import ConfirmationModal from "$common/ConfirmationModal.svelte";
import Icon from "$common/Icon.svelte";
import { useMutation, useQueryClient } from "@sveltestack/svelte-query";
import Error from "$common/Error.svelte";
const appName = $page.params.name;
let confirmationModalOpen = false;
const queryClient = useQueryClient();
const onSuccess = async () => {
await queryClient.invalidateQueries("getAllAppsOverview");
await goto("/");
};
const destroyAppMutation = useMutation(destroyApp, { onSuccess });
</script>
<div class="">
<button
class="btn btn-error gap-2 w-56"
class:loading={$destroyAppMutation.isLoading}
on:click={() => (confirmationModalOpen = true)}
>
{#if $destroyAppMutation.isLoading}
Destroying...
{:else}
Destroy App
<Icon type="delete" />
{/if}
</button>
</div>
{#if $destroyAppMutation.isError}
<div class="my-2">
<Error action="destroying app" error={$destroyAppMutation.error} />
</div>
{/if}
<ConfirmationModal
name="destroy-app-modal"
title="Destroy App"
action="destroy {appName}"
on:accepted={() => $destroyAppMutation.mutate(appName)}
bind:open={confirmationModalOpen}
/>

View File

@@ -0,0 +1,63 @@
<script>
import { renameApp } from "$lib/api";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import Modal from "$common/Modal.svelte";
import Icon from "$common/Icon.svelte";
import { useMutation } from "@sveltestack/svelte-query";
const appName = $page.params.name;
let newName = appName;
let renameModalOpen = false;
const renameMutation = useMutation((newName) => renameApp(appName, newName), {
onSuccess: () => goto(`/app/${newName}`),
});
</script>
<div class="">
<button
class="btn btn-info gap-2 w-56"
on:click={() => (renameModalOpen = true)}
>
Rename App
<Icon type="edit" />
</button>
</div>
<Modal
name="create-app"
title="Rename '{appName}'"
bind:open={renameModalOpen}
preventClose={$renameMutation.isLoading}
>
<div class="form-control w-full max-w-xs mb-4">
<label class="label" for="new-name">
<span class="label-text">Enter a new name</span>
</label>
<input
id="new-name"
type="text"
bind:value={newName}
disabled={$renameMutation.isLoading}
class="input input-bordered w-full max-w-xs"
/>
</div>
<button
class="btn btn-primary"
on:click={() => $renameMutation.mutate(newName)}
class:loading={$renameMutation.isLoading}
>
Save
</button>
<button
class="btn btn-ghost"
on:click={() => (renameModalOpen = false)}
disabled={$renameMutation.isLoading}
>
Cancel
</button>
</Modal>

View File

@@ -0,0 +1,79 @@
<script>
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { useMutation, useQueryClient } from "@sveltestack/svelte-query";
import { setupApp, setupAppAsync } from "$lib/api";
import { commandExecutionIds, executionIdDescriptions } from "$lib/stores";
import Steps from "$common/Steps.svelte";
import Select from "./steps/Select.svelte";
import Configure from "./steps/Configure.svelte";
import Confirm from "./steps/Confirm.svelte";
import GitNew from "./configs/GitNew.svelte";
import GitSync from "./configs/GitSync.svelte";
import DockerImage from "./configs/DockerImage.svelte";
const appName = $page.params.name;
const steps = [
{ label: "Select an app source", component: Select },
{ label: "Configure source", component: Configure },
{ label: "Confirm", component: Confirm },
];
const sources = {
"new-repo": {
label: "Create New Git Repo",
createText: "create a git repo",
component: GitNew,
},
"sync-repo": {
label: "Sync Existing Git Repo",
createText: "sync an existing git repo",
component: GitSync,
},
"pull-image": {
label: "Docker Image",
createText: "pull a docker image",
component: DockerImage,
},
// "upload-archive": {label: "Upload Archive File", component: Archive, options: null},
};
const doSyncSetup = ({ source, options }) => {
return setupApp(appName, source, options);
};
const doAsyncSetup = async ({ source, options }) => {
const id = await setupAppAsync(appName, source, options);
$executionIdDescriptions[id] = `Setting up ${appName}`;
const success = await commandExecutionIds.addID(id);
if (success) return true;
throw new Error("execution was unsuccessful");
};
const queryClient = useQueryClient();
const onSuccess = async () => {
await queryClient.invalidateQueries([{ appName }]);
await goto(`/app/${appName}`);
};
const setupAppMutation = useMutation(doSyncSetup, { onSuccess });
const setupAppAsyncMutation = useMutation(doAsyncSetup, { onSuccess });
const trySetup = () => {
const source = data.selectedSource;
const options = data.sourceOptions[source];
if (source === "new-repo") $setupAppMutation.mutate({ source, options });
else $setupAppAsyncMutation.mutate({ source, options });
};
const props = { sources };
let data = {};
let loading;
$: loading = $setupAppMutation.isLoading || $setupAppAsyncMutation.isLoading;
</script>
<Steps {steps} {props} {data} {loading} on:complete={trySetup} />

View File

@@ -0,0 +1,62 @@
<script>
import { createEventDispatcher } from "svelte";
import Dropzone from "svelte-file-dropzone";
import Icon from "$common/Icon.svelte";
export let options;
if (!options) options = { archive: null };
const dispatch = createEventDispatcher();
const checkOptionsValid = () => {
dispatch("validityChange", { valid: !!options["archive"] });
};
function handleFilesSelect(e) {
const { acceptedFiles } = e.detail;
if (acceptedFiles.length > 0) {
const file = acceptedFiles[0];
if (file.type === "application/x-gzip" && !file.name.endsWith(".tar.gz"))
return;
archiveChosen(file);
}
}
const archiveChosen = (archiveFile) => {
options["archive"] = archiveFile;
checkOptionsValid();
};
let hovering = false;
const dragEnter = () => (hovering = true);
const dragLeave = () => (hovering = false);
</script>
<div
class="p-6 items-center rounded-lg cursor-pointer text-neutral-content bg-neutral hover:bg-neutral-focus"
class:bg-neutral-focus={hovering}
>
<Dropzone
disableDefaultStyles="true"
accept=".zip, application/gzip, .gz"
on:drop={handleFilesSelect}
on:dragenter={dragEnter}
on:dragleave={dragLeave}
>
<div class="flex flex-col items-center p-6">
<Icon type="upload" size="lg" />
<p class="my-1">
<span class="font-semibold">Click to upload</span> or drag and drop
</p>
<p class="text-sm">.zip or .tar.gz</p>
</div>
</Dropzone>
</div>
{#if options["archive"]}
<div class="mt-2">
<span
>Uploading <span class="font-bold">{options["archive"].name}</span></span
>
</div>
{/if}

View File

@@ -0,0 +1,23 @@
<script>
import { createEventDispatcher } from "svelte";
export let options;
if (!options) options = { image: "" };
const dispatch = createEventDispatcher();
const checkOptionsValid = () => {
dispatch("validityChange", { valid: !!options["image"] });
};
</script>
<div class="flex flex-col gap-2">
<label class="input-group input-group-md">
<span class="w-auto">Docker Image</span>
<input
type="text"
class="input input-md input-bordered flex-grow"
on:change={checkOptionsValid}
bind:value={options["image"]}
/>
</label>
</div>

View File

@@ -0,0 +1,35 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
export let options;
if (!options) {
options = {
deployment_branch: "main",
// envVar: "GIT_REV",
};
}
const dispatch = createEventDispatcher();
const checkOptionsValid = () => {
dispatch("validityChange", { valid: options.deployment_branch !== "" });
};
onMount(() => dispatch("validityChange", { valid: true }));
</script>
<div class="flex flex-col gap-2">
<label class="input-group input-group-md">
<span class="w-auto">Deployment Branch</span>
<input
type="text"
class="input input-md input-bordered flex-grow"
bind:value={options["deployment_branch"]}
on:change={checkOptionsValid}
/>
</label>
<!--label class="input-group input-group-md">
<span class="w-auto">Git Revision Environment Variable</span>
<input type="text" class="input input-md input-bordered flex-grow"
bind:value={options["envVar"]} />
</label-->
</div>

View File

@@ -0,0 +1,75 @@
<script>
import { createEventDispatcher } from "svelte";
export let options;
if (!options) {
options = {
repository_url: "",
git_ref: "",
_use_custom_dockerfile_path: false,
custom_dockerfile_path: "",
};
}
const dispatch = createEventDispatcher();
const checkOptionsValid = () => {
// TODO: check repo access etc
dispatch("validityChange", { valid: !!options["repository_url"] });
};
</script>
<div class="flex flex-col gap-2">
<label class="input-group input-group-md">
<span class="w-auto">Repository URL</span>
<input
type="text"
class="input input-md input-bordered flex-grow"
placeholder="https://github.com/heroku/node-js-getting-started.git"
bind:value={options["repository_url"]}
on:change={checkOptionsValid}
/>
</label>
<span class="text-sm"
>If the repository is private, ensure you set up access using the
credentials found in
<a href="/settings" class="link" target="_blank">settings</a>.
</span>
<label class="input-group input-group-md">
<span class="w-auto">Git Reference (branch, tag, commit)</span>
<input
type="text"
class="input input-md input-bordered flex-grow"
placeholder="optional"
bind:value={options["git_ref"]}
on:change={checkOptionsValid}
/>
</label>
<!--div class="form-control w-60 rounded-lg">
<label class="label cursor-pointer">
<span class="label-text">Specify Dockerfile Path?</span>
<input
type="checkbox"
class="checkbox checkbox-accent"
bind:checked={options["_use_custom_dockerfile_path"]}
on:change={checkOptionsValid}
/>
</label>
</div>
{#if options["_use_custom_dockerfile_path"]}
<label class="input-group input-group-md">
<span class="w-auto">Dockerfile Path</span>
<input
type="text"
class="input input-md input-bordered flex-grow"
placeholder="Dockerfile"
on:change={checkOptionsValid}
bind:value={options["custom_dockerfile_path"]}
/>
</label>
{/if}
!-->
</div>

View File

@@ -0,0 +1,30 @@
<script>
import { createEventDispatcher } from "svelte";
export let data = {};
export let props = {};
let sourceComponent;
$: if (data.selectedSource && props.sources[data.selectedSource]) {
sourceComponent = props.sources[data.selectedSource]?.component;
}
$: if (!data.sourceOptions) {
data.sourceOptions = {};
}
const dispatch = createEventDispatcher();
const onValidityChanged = ({ valid }) => {
dispatch("statusChange", { complete: valid });
};
</script>
<span class="text-lg">Configure source:</span>
{#if sourceComponent}
<svelte:component
this={sourceComponent}
bind:options={data.sourceOptions[data.selectedSource]}
on:validityChange={(e) => onValidityChanged(e.detail)}
/>
{/if}

View File

@@ -0,0 +1,40 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
export let props = {};
export let data = {};
let selectedSource = data.selectedSource;
let sourceOptions = data.sourceOptions[selectedSource];
const optionDisplayText = (option) => {
const value = sourceOptions[option];
if (typeof value === "object") return value.name;
return value;
};
const dispatch = createEventDispatcher();
onMount(() => dispatch("statusChange", { complete: true }));
</script>
<span
>Going to
<span class="font-bold">{props.sources[selectedSource].createText}</span>
with configuration</span
>
<div class="overflow-x-auto mt-3">
<table class="table table-compact w-full">
<thead>
<tr><th>Key</th><th>Value</th></tr>
</thead>
<tbody>
{#each Object.keys(sourceOptions) as option}
{#if !option.startsWith("_") && optionDisplayText(option) !== ""}
<tr>
<td>{option}</td>
<td>{optionDisplayText(option)}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,26 @@
<script>
import { createEventDispatcher } from "svelte";
export let props = {};
export let data = {};
const dispatch = createEventDispatcher();
const sourceSelected = (source) => {
data.selectedSource = source;
dispatch("statusChange", { complete: true });
};
</script>
<span class="text-lg">Choose a source:</span>
<div class="tabs tabs-boxed bg-base-100 mt-4">
{#each Object.keys(props.sources) as source}
<a
class="tab"
class:tab-active={source === data.selectedSource}
on:click={() => sourceSelected(source)}
>
{props.sources[source].label}
</a>
{/each}
</div>

View File

@@ -0,0 +1,74 @@
<script>
import { page } from "$app/stores";
import {
useMutation,
useQuery,
useQueryClient,
} from "@sveltestack/svelte-query";
import { getAppStorages, mountAppStorage, unmountAppStorage } from "$lib/api";
import Icon from "$common/Icon.svelte";
import QueryDataWrapper from "$common/QueryDataWrapper.svelte";
import StorageMount from "./StorageMount.svelte";
import CreateStorageModal from "./CreateStorageModal.svelte";
import Card from "$common/Card.svelte";
const appName = $page.params.name;
const queryClient = useQueryClient();
const queryKey = [{ appName }, "getAppStorages"];
const storageReport = useQuery(queryKey, () => getAppStorages(appName));
const invalidateStorages = () => queryClient.invalidateQueries(queryKey);
let createModalOpen = false;
const mountMutation = useMutation(
(options) => mountAppStorage(appName, options),
{
onSuccess: () => {
createModalOpen = false;
invalidateStorages();
},
}
);
let loading = {};
const unmountMutation = useMutation(
(options) => {
loading[options["hostDir"]] = true;
return unmountAppStorage(appName, options);
},
{
onSuccess: invalidateStorages,
onSettled: (_, __, options) => (loading[options["hostDir"]] = false),
}
);
</script>
<QueryDataWrapper query={storageReport} action="fetching app storage">
<Card title="Storage Mounts">
<div class="flex flex-col gap-3">
{#each $storageReport.data["mounts"] as mount, i}
<StorageMount
{...mount}
loading={loading[mount["host_dir"]]}
on:unmount={({ detail }) => $unmountMutation.mutate(detail)}
/>
{/each}
</div>
<div slot="actions">
<button class="btn gap-2" on:click={() => (createModalOpen = true)}>
<Icon type="add" />
New Storage Mount
</button>
</div>
</Card>
</QueryDataWrapper>
<CreateStorageModal
bind:open={createModalOpen}
loading={$mountMutation.isLoading}
error={$mountMutation.error}
on:create={({ detail }) => $mountMutation.mutate(detail)}
/>

View File

@@ -0,0 +1,84 @@
<script>
import { createEventDispatcher } from "svelte";
import Modal from "$common/Modal.svelte";
import Error from "$common/Error.svelte";
export let open;
export let loading;
export let error;
const storageTypes = {
"New Storage": { label: "Storage Name" },
// "Mount Existing Directory": {"label": "Existing Path to Mount"}
};
let selectedType = "New Storage";
let hostDir = "";
let mountDir = "";
let typeOptions = Object.keys(storageTypes);
const dispatch = createEventDispatcher();
const dispatchCreateStorage = () => {
const options = { selectedType, hostDir, mountDir };
dispatch("create", options);
};
</script>
<Modal
name="create-storage"
title="Create App Storage"
bind:open
preventClose={loading}
>
<div class="form-control" class:hidden={typeOptions.length < 2}>
<label class="label">
<span class="label-text">Select Storage Type</span>
</label>
<select
class="select select-bordered w-full max-w-xs"
bind:value={selectedType}
>
{#each typeOptions as storageType}
<option value={storageType}>
{storageType}
</option>
{/each}
</select>
</div>
<div class="my-3 p-2 border rounded-box flex flex-col gap-2">
<label class="input-group input-group-md">
<span class="w-auto">{storageTypes[selectedType]["label"]}</span>
<input
type="text"
placeholder="foo"
class="input input-md input-bordered flex-grow"
bind:value={hostDir}
/>
</label>
<label class="input-group input-group-md flex">
<span class="w-auto">In-App Path</span>
<input
type="text"
placeholder="/data"
class="input input-md input-bordered w-auto flex-grow"
bind:value={mountDir}
/>
</label>
</div>
{#if error}
<div class="mb-4">
<Error {error} action="mounting storage" />
</div>
{/if}
<div class="mt-3">
<button class="btn" class:loading on:click={dispatchCreateStorage}>
Submit
</button>
</div>
</Modal>

View File

@@ -0,0 +1,77 @@
<script>
import Icon from "$common/Icon.svelte";
import { createEventDispatcher } from "svelte";
import ConfirmationModal from "$common/ConfirmationModal.svelte";
export let hostDir;
export let mountDir;
export let loading;
export let isBuildMount;
export let isRunMount;
export let isDeployMount;
let hostDokkuDir;
let isDokkuManaged = false;
const dokkuDir = "/var/lib/dokku/data/storage/";
$: if (hostDir) {
if (hostDir.startsWith(dokkuDir)) {
isDokkuManaged = true;
hostDokkuDir = hostDir.slice(dokkuDir.length);
}
}
let confirmationModalOpen = false;
const dispatch = createEventDispatcher();
const tryUnmount = (e) => {
const restart = e.detail.extraOptionChecked;
dispatch("unmount", { hostDir, mountDir, restart });
};
</script>
<div class="flex flex-row gap-2">
<div class="flex items-center" class:flex-grow={!isDokkuManaged}>
<label class="input-group input-group-md" class:hidden={isDokkuManaged}>
<span class="w-auto">Mounted Path</span>
<input
type="text"
value={hostDir}
class="input input-md input-bordered"
disabled
class:flex-grow={!isDokkuManaged}
/>
</label>
<div class="" class:hidden={!isDokkuManaged}>
<span class="font-bold">{hostDokkuDir}</span>
</div>
</div>
<div class="flex-grow">
<label class="input-group input-group-md">
<span>Container Path</span>
<input
type="text"
class="input input-md input-bordered w-auto flex-grow"
disabled
value={mountDir}
/>
</label>
</div>
<button
class="btn btn-ghost btn-circle text-error-content ml-2"
class:loading
on:click={() => (confirmationModalOpen = true)}
>
<Icon type="remove" />
</button>
</div>
<ConfirmationModal
name="unmount-storage"
action="unmount this storage"
extraOption="Restart app?"
bind:open={confirmationModalOpen}
on:accepted={tryUnmount}
/>

View File

@@ -0,0 +1,74 @@
<script>
import { getAppProcesses, executeCommandInProcess } from "$lib/api";
import { page } from "$app/stores";
import Terminal from "$components/commands/Terminal.svelte";
import Error from "$common/Error.svelte";
const appName = $page.params.name;
let processes;
let selectedProcess;
let error;
let errorAction;
const initProcessSelection = async () => {
try {
let psList = await getAppProcesses(appName);
processes = psList;
if (psList.length === 1) {
selectedProcess = psList[0];
}
} catch (e) {
error = e;
errorAction = "loading processes";
}
};
let terminalOutput = [];
const onTerminalInput = async (cmd) => {
if (cmd === "clear") {
terminalOutput = [];
return;
}
terminalOutput = [...terminalOutput, { input: cmd }];
try {
const res = await executeCommandInProcess(appName, selectedProcess, cmd);
terminalOutput = [...terminalOutput, res];
} catch (e) {
console.error(e);
}
};
initProcessSelection();
</script>
{#if processes && !selectedProcess}
<div class="form-control w-full max-w-md p-2 flex flex-row gap-2">
<label class="label">
<span class="label-text">Process type:</span>
</label>
<select
class="select select-primary select-bordered"
bind:value={selectedProcess}
>
{#each processes as process}
<option>{process}</option>
{/each}
</select>
</div>
{/if}
{#if selectedProcess}
<Terminal onInput={onTerminalInput} bind:output={terminalOutput}>
<div slot="titlebar">
<span class="text-md"
>Connected to <span class="font-bold">{selectedProcess}</span></span
>
</div>
</Terminal>
{/if}
{#if error}
<Error {error} action={errorAction} />
{/if}

View File

@@ -0,0 +1,55 @@
<script>
import { useMutation, useQueryClient } from "@sveltestack/svelte-query";
import { createApp } from "$lib/api";
import { goto } from "$app/navigation";
import Error from "$common/Error.svelte";
import Card from "$common/Card.svelte";
const queryClient = useQueryClient();
const createAppMutation = useMutation((appName) => createApp(appName), {
onSuccess: () => queryClient.invalidateQueries("getAppsList"),
});
let newAppName = "";
let creationModalOpen = false;
const appCreationConfirmed = async () => {
const success = await $createAppMutation.mutateAsync(newAppName);
if (!success) return;
creationModalOpen = false;
await goto(`/app/${newAppName}/setup`);
};
</script>
<div class="flex flex-row justify-center p-4">
<!--div class="hidden md:inline flex-grow" /-->
<div class="p-3 w-fit">
<Card title="Create a new app">
<div class="form-control">
<label class="input-group">
<span class="label-text text-base-content">App Name</span>
<input
bind:value={newAppName}
type="text"
class="input input-bordered"
disabled={$createAppMutation.isLoading}
/>
</label>
</div>
<div slot="actions">
<button
class="btn btn-primary"
on:click={appCreationConfirmed}
class:loading={$createAppMutation.isLoading}
>
Create
</button>
</div>
{#if $createAppMutation.error}
<Error action="creating new app" error={$createAppMutation.error} />
{/if}
</Card>
<!--div class="hidden md:inline flex-grow" /-->
</div>
</div>

View File

@@ -0,0 +1,7 @@
<script>
import CenterCard from "$common/CenterCard.svelte";
</script>
<CenterCard>
<slot />
</CenterCard>

View File

@@ -0,0 +1,13 @@
import {readAuthCookie} from "$lib/auth";
import {redirect} from "@sveltejs/kit";
export const ssr = false;
export async function load({url}) {
const authDetails = await readAuthCookie();
if (authDetails) {
throw redirect(307, "/");
}
return {};
}

Some files were not shown because too many files have changed in this diff Show More