init from gitlab
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user