init from gitlab
This commit is contained in:
7
ui/src/app.css
Normal file
7
ui/src/app.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
/*height: 100vh;*/
|
||||
}
|
||||
13
ui/src/app.html
Normal file
13
ui/src/app.html
Normal 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>
|
||||
49
ui/src/components/Header.svelte
Normal file
49
ui/src/components/Header.svelte
Normal 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>
|
||||
47
ui/src/components/commands/CommandExecution.svelte
Normal file
47
ui/src/components/commands/CommandExecution.svelte
Normal 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>
|
||||
88
ui/src/components/commands/CommandExecutionWindow.svelte
Normal file
88
ui/src/components/commands/CommandExecutionWindow.svelte
Normal 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>
|
||||
94
ui/src/components/commands/Terminal.svelte
Normal file
94
ui/src/components/commands/Terminal.svelte
Normal 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>
|
||||
27
ui/src/components/common/Alert.svelte
Normal file
27
ui/src/components/common/Alert.svelte
Normal 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>
|
||||
14
ui/src/components/common/Card.svelte
Normal file
14
ui/src/components/common/Card.svelte
Normal 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>
|
||||
3
ui/src/components/common/Cards.svelte
Normal file
3
ui/src/components/common/Cards.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="flex flex-col gap-5">
|
||||
<slot />
|
||||
</div>
|
||||
7
ui/src/components/common/CenterCard.svelte
Normal file
7
ui/src/components/common/CenterCard.svelte
Normal 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>
|
||||
10
ui/src/components/common/Code.svelte
Normal file
10
ui/src/components/common/Code.svelte
Normal 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>
|
||||
53
ui/src/components/common/ConfirmationModal.svelte
Normal file
53
ui/src/components/common/ConfirmationModal.svelte
Normal 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>
|
||||
27
ui/src/components/common/ContentPage.svelte
Normal file
27
ui/src/components/common/ContentPage.svelte
Normal 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>
|
||||
37
ui/src/components/common/Error.svelte
Normal file
37
ui/src/components/common/Error.svelte
Normal 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>
|
||||
114
ui/src/components/common/Icon.svelte
Normal file
114
ui/src/components/common/Icon.svelte
Normal 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>
|
||||
145
ui/src/components/common/KVEditor.svelte
Normal file
145
ui/src/components/common/KVEditor.svelte
Normal 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}
|
||||
/>
|
||||
105
ui/src/components/common/Loader.svelte
Normal file
105
ui/src/components/common/Loader.svelte
Normal 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>
|
||||
20
ui/src/components/common/Logs.svelte
Normal file
20
ui/src/components/common/Logs.svelte
Normal 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>
|
||||
45
ui/src/components/common/Modal.svelte
Normal file
45
ui/src/components/common/Modal.svelte
Normal 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>
|
||||
15
ui/src/components/common/QueryDataWrapper.svelte
Normal file
15
ui/src/components/common/QueryDataWrapper.svelte
Normal 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}
|
||||
57
ui/src/components/common/Sidebar.svelte
Normal file
57
ui/src/components/common/Sidebar.svelte
Normal 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>
|
||||
92
ui/src/components/common/Steps.svelte
Normal file
92
ui/src/components/common/Steps.svelte
Normal 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>
|
||||
3
ui/src/components/common/Todo.svelte
Normal file
3
ui/src/components/common/Todo.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="bg-warning p-4">
|
||||
<span class="text-lg text-warning-content">TODO</span>
|
||||
</div>
|
||||
15
ui/src/components/common/devicons/Docker.svelte
Normal file
15
ui/src/components/common/devicons/Docker.svelte
Normal 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>
|
||||
18
ui/src/components/common/devicons/Github.svelte
Normal file
18
ui/src/components/common/devicons/Github.svelte
Normal 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>
|
||||
19
ui/src/components/common/devicons/Go.svelte
Normal file
19
ui/src/components/common/devicons/Go.svelte
Normal 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>
|
||||
15
ui/src/components/common/devicons/Javascript.svelte
Normal file
15
ui/src/components/common/devicons/Javascript.svelte
Normal 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>
|
||||
92
ui/src/components/common/devicons/MongoDB.svelte
Normal file
92
ui/src/components/common/devicons/MongoDB.svelte
Normal 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>
|
||||
15
ui/src/components/common/devicons/MySQL.svelte
Normal file
15
ui/src/components/common/devicons/MySQL.svelte
Normal 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>
|
||||
22
ui/src/components/common/devicons/Postgres.svelte
Normal file
22
ui/src/components/common/devicons/Postgres.svelte
Normal file
File diff suppressed because one or more lines are too long
15
ui/src/components/common/devicons/Python.svelte
Normal file
15
ui/src/components/common/devicons/Python.svelte
Normal 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>
|
||||
36
ui/src/components/common/devicons/Redis.svelte
Normal file
36
ui/src/components/common/devicons/Redis.svelte
Normal 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>
|
||||
17
ui/src/components/common/devicons/Ruby.svelte
Normal file
17
ui/src/components/common/devicons/Ruby.svelte
Normal 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>
|
||||
35
ui/src/components/common/devicons/SQLite.svelte
Normal file
35
ui/src/components/common/devicons/SQLite.svelte
Normal 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>
|
||||
58
ui/src/components/dashboard/DashboardCard.svelte
Normal file
58
ui/src/components/dashboard/DashboardCard.svelte
Normal 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>
|
||||
59
ui/src/components/dashboard/DashboardCardList.svelte
Normal file
59
ui/src/components/dashboard/DashboardCardList.svelte
Normal 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>
|
||||
91
ui/src/components/links/LinkCard.svelte
Normal file
91
ui/src/components/links/LinkCard.svelte
Normal 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}
|
||||
51
ui/src/components/links/LinkModal.svelte
Normal file
51
ui/src/components/links/LinkModal.svelte
Normal 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>
|
||||
32
ui/src/components/links/link-configs/Generic.svelte
Normal file
32
ui/src/components/links/link-configs/Generic.svelte
Normal 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>
|
||||
119
ui/src/components/processes/ProcessCard.svelte
Normal file
119
ui/src/components/processes/ProcessCard.svelte
Normal 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>
|
||||
72
ui/src/components/processes/ProcessDeploymentEditor.svelte
Normal file
72
ui/src/components/processes/ProcessDeploymentEditor.svelte
Normal 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>
|
||||
55
ui/src/components/processes/ProcessResource.svelte
Normal file
55
ui/src/components/processes/ProcessResource.svelte
Normal 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>
|
||||
97
ui/src/components/processes/ProcessResourceEditor.svelte
Normal file
97
ui/src/components/processes/ProcessResourceEditor.svelte
Normal 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>
|
||||
54
ui/src/components/processes/ProcessResourceView.svelte
Normal file
54
ui/src/components/processes/ProcessResourceView.svelte
Normal 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}
|
||||
72
ui/src/components/processes/ProcessScaleSelector.svelte
Normal file
72
ui/src/components/processes/ProcessScaleSelector.svelte
Normal 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>
|
||||
64
ui/src/components/processes/ProcessesOverview.svelte
Normal file
64
ui/src/components/processes/ProcessesOverview.svelte
Normal 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>
|
||||
1
ui/src/components/service-pages/redis/RedisIndex.svelte
Normal file
1
ui/src/components/service-pages/redis/RedisIndex.svelte
Normal file
@@ -0,0 +1 @@
|
||||
redis index page
|
||||
134
ui/src/components/totp/TotpSetupButton.svelte
Normal file
134
ui/src/components/totp/TotpSetupButton.svelte
Normal 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
6
ui/src/lib/api.js
Normal 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
287
ui/src/lib/apis/apps.js
Normal 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
40
ui/src/lib/apis/auth.js
Normal 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
14
ui/src/lib/apis/client.js
Normal 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
29
ui/src/lib/apis/exec.js
Normal 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
137
ui/src/lib/apis/services.js
Normal 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);
|
||||
}
|
||||
88
ui/src/lib/apis/settings.js
Normal file
88
ui/src/lib/apis/settings.js
Normal 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
60
ui/src/lib/apis/setup.js
Normal 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
95
ui/src/lib/auth.js
Normal 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
87
ui/src/lib/stores.js
Normal 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
38
ui/src/routes/+layout.js
Normal 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);
|
||||
}
|
||||
82
ui/src/routes/+layout.svelte
Normal file
82
ui/src/routes/+layout.svelte
Normal 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>
|
||||
37
ui/src/routes/+page.svelte
Normal file
37
ui/src/routes/+page.svelte
Normal 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>
|
||||
5
ui/src/routes/app/+page.svelte
Normal file
5
ui/src/routes/app/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
goto("/");
|
||||
</script>
|
||||
61
ui/src/routes/app/[name]/+layout.svelte
Normal file
61
ui/src/routes/app/[name]/+layout.svelte
Normal 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>
|
||||
74
ui/src/routes/app/[name]/+page.svelte
Normal file
74
ui/src/routes/app/[name]/+page.svelte
Normal 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>
|
||||
111
ui/src/routes/app/[name]/AppHeader.svelte
Normal file
111
ui/src/routes/app/[name]/AppHeader.svelte
Normal 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>
|
||||
21
ui/src/routes/app/[name]/AppHeaderIconButton.svelte
Normal file
21
ui/src/routes/app/[name]/AppHeaderIconButton.svelte
Normal 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>
|
||||
27
ui/src/routes/app/[name]/builds/+page.svelte
Normal file
27
ui/src/routes/app/[name]/builds/+page.svelte
Normal 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>
|
||||
88
ui/src/routes/app/[name]/builds/BuildDirectory.svelte
Normal file
88
ui/src/routes/app/[name]/builds/BuildDirectory.svelte
Normal 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}
|
||||
49
ui/src/routes/app/[name]/builds/BuilderSelection.svelte
Normal file
49
ui/src/routes/app/[name]/builds/BuilderSelection.svelte
Normal 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}
|
||||
60
ui/src/routes/app/[name]/builds/DeployChecks.svelte
Normal file
60
ui/src/routes/app/[name]/builds/DeployChecks.svelte
Normal 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>
|
||||
62
ui/src/routes/app/[name]/builds/ProcessDeployChecks.svelte
Normal file
62
ui/src/routes/app/[name]/builds/ProcessDeployChecks.svelte
Normal 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}
|
||||
54
ui/src/routes/app/[name]/builds/SetupConfig.svelte
Normal file
54
ui/src/routes/app/[name]/builds/SetupConfig.svelte
Normal 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>
|
||||
5
ui/src/routes/app/[name]/cron/+page.svelte
Normal file
5
ui/src/routes/app/[name]/cron/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import Todo from "$common/Todo.svelte";
|
||||
</script>
|
||||
|
||||
<Todo />
|
||||
75
ui/src/routes/app/[name]/domains/+page.svelte
Normal file
75
ui/src/routes/app/[name]/domains/+page.svelte
Normal 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}
|
||||
/>
|
||||
39
ui/src/routes/app/[name]/domains/AddDomainModal.svelte
Normal file
39
ui/src/routes/app/[name]/domains/AddDomainModal.svelte
Normal 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>
|
||||
49
ui/src/routes/app/[name]/domains/DomainListItem.svelte
Normal file
49
ui/src/routes/app/[name]/domains/DomainListItem.svelte
Normal 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}
|
||||
/>
|
||||
47
ui/src/routes/app/[name]/environment/+page.svelte
Normal file
47
ui/src/routes/app/[name]/environment/+page.svelte
Normal 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}
|
||||
22
ui/src/routes/app/[name]/logs/+page.svelte
Normal file
22
ui/src/routes/app/[name]/logs/+page.svelte
Normal 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>
|
||||
58
ui/src/routes/app/[name]/network/+page.svelte
Normal file
58
ui/src/routes/app/[name]/network/+page.svelte
Normal 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}
|
||||
154
ui/src/routes/app/[name]/network/AppNetworks.svelte
Normal file
154
ui/src/routes/app/[name]/network/AppNetworks.svelte
Normal 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>
|
||||
40
ui/src/routes/app/[name]/network/NetworkSelect.svelte
Normal file
40
ui/src/routes/app/[name]/network/NetworkSelect.svelte
Normal 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>
|
||||
135
ui/src/routes/app/[name]/services/+page.svelte
Normal file
135
ui/src/routes/app/[name]/services/+page.svelte
Normal 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}
|
||||
16
ui/src/routes/app/[name]/settings/+page.svelte
Normal file
16
ui/src/routes/app/[name]/settings/+page.svelte
Normal 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>
|
||||
50
ui/src/routes/app/[name]/settings/DestroyAppButton.svelte
Normal file
50
ui/src/routes/app/[name]/settings/DestroyAppButton.svelte
Normal 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}
|
||||
/>
|
||||
63
ui/src/routes/app/[name]/settings/RenameAppButton.svelte
Normal file
63
ui/src/routes/app/[name]/settings/RenameAppButton.svelte
Normal 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>
|
||||
79
ui/src/routes/app/[name]/setup/+page.svelte
Normal file
79
ui/src/routes/app/[name]/setup/+page.svelte
Normal 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} />
|
||||
62
ui/src/routes/app/[name]/setup/configs/Archive.svelte
Normal file
62
ui/src/routes/app/[name]/setup/configs/Archive.svelte
Normal 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}
|
||||
23
ui/src/routes/app/[name]/setup/configs/DockerImage.svelte
Normal file
23
ui/src/routes/app/[name]/setup/configs/DockerImage.svelte
Normal 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>
|
||||
35
ui/src/routes/app/[name]/setup/configs/GitNew.svelte
Normal file
35
ui/src/routes/app/[name]/setup/configs/GitNew.svelte
Normal 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>
|
||||
75
ui/src/routes/app/[name]/setup/configs/GitSync.svelte
Normal file
75
ui/src/routes/app/[name]/setup/configs/GitSync.svelte
Normal 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>
|
||||
30
ui/src/routes/app/[name]/setup/steps/Configure.svelte
Normal file
30
ui/src/routes/app/[name]/setup/steps/Configure.svelte
Normal 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}
|
||||
40
ui/src/routes/app/[name]/setup/steps/Confirm.svelte
Normal file
40
ui/src/routes/app/[name]/setup/steps/Confirm.svelte
Normal 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>
|
||||
26
ui/src/routes/app/[name]/setup/steps/Select.svelte
Normal file
26
ui/src/routes/app/[name]/setup/steps/Select.svelte
Normal 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>
|
||||
74
ui/src/routes/app/[name]/storage/+page.svelte
Normal file
74
ui/src/routes/app/[name]/storage/+page.svelte
Normal 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)}
|
||||
/>
|
||||
84
ui/src/routes/app/[name]/storage/CreateStorageModal.svelte
Normal file
84
ui/src/routes/app/[name]/storage/CreateStorageModal.svelte
Normal 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>
|
||||
77
ui/src/routes/app/[name]/storage/StorageMount.svelte
Normal file
77
ui/src/routes/app/[name]/storage/StorageMount.svelte
Normal 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}
|
||||
/>
|
||||
74
ui/src/routes/app/[name]/terminal/+page.svelte
Normal file
74
ui/src/routes/app/[name]/terminal/+page.svelte
Normal 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}
|
||||
55
ui/src/routes/app/new/+page.svelte
Normal file
55
ui/src/routes/app/new/+page.svelte
Normal 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>
|
||||
7
ui/src/routes/login/+layout.svelte
Normal file
7
ui/src/routes/login/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import CenterCard from "$common/CenterCard.svelte";
|
||||
</script>
|
||||
|
||||
<CenterCard>
|
||||
<slot />
|
||||
</CenterCard>
|
||||
13
ui/src/routes/login/+page.js
Normal file
13
ui/src/routes/login/+page.js
Normal 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
Reference in New Issue
Block a user