Files
komodo/frontend/src/components/resources/stack/config.tsx
2024-08-15 03:24:49 -04:00

575 lines
20 KiB
TypeScript

import { Config } from "@components/config";
import {
AccountSelectorConfig,
AddExtraArgMenu,
ConfigItem,
InputList,
ProviderSelectorConfig,
SecretsForEnvironment,
} from "@components/config/util";
import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import { Types } from "@monitor/client";
import { createRef, ReactNode, useState } from "react";
import { CopyGithubWebhook, ServerSelector } from "../common";
import { useToast } from "@ui/use-toast";
import { text_color_class_by_intention } from "@lib/color";
import { ConfirmButton } from "@components/util";
import {
Ban,
ChevronDown,
ChevronUp,
CirclePlus,
PlusCircle,
} from "lucide-react";
import { env_to_text } from "@lib/utils";
import { Textarea } from "@ui/textarea";
import { Button } from "@ui/button";
export const StackConfig = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const perms = useRead("GetPermissionLevel", {
target: { type: "Stack", id },
}).data;
const config = useRead("GetStack", { stack: id }).data?.config;
const webhooks = useRead("GetStackWebhooksEnabled", { stack: id }).data;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
const [update, set] = useState<Partial<Types.StackConfig>>({});
const { mutateAsync } = useWrite("UpdateStack");
const [fileContentsOpen, setFileContentsOpen] = useState(false);
const fileContentsRef = createRef<HTMLTextAreaElement>();
if (!config) return null;
const disabled = global_disabled || perms !== Types.PermissionLevel.Write;
return (
<Config
titleOther={titleOther}
disabled={disabled}
config={config}
update={update}
set={set}
onSave={async () => {
await mutateAsync({ id, config: update });
}}
components={{
general: [
{
label: "Server Id",
labelHidden: true,
components: {
server_id: (value, set) => (
<ServerSelector
selected={value}
set={set}
disabled={disabled}
/>
),
},
},
{
label: "Project Name",
labelHidden: true,
components: {
project_name: {
boldLabel: true,
placeholder: "Compose project name",
description:
"Optionally override the compose project name. Can import stacks by matching the existing project name on your host.",
},
},
},
{
label: "Compose File",
description:
"Paste the file contents here, or configure a git repo.",
actions: (
<Button
size="sm"
variant="outline"
className="gap-4"
onClick={() => setFileContentsOpen(!fileContentsOpen)}
>
{fileContentsOpen ? "Hide" : "Show"}
{fileContentsOpen ? (
<ChevronUp className="w-4" />
) : (
<ChevronDown className="w-4" />
)}
</Button>
),
contentHidden: !fileContentsOpen,
components: {
file_contents: (file_contents, set) => {
return (
<Textarea
ref={fileContentsRef}
value={file_contents}
onChange={(e) => set({ file_contents: e.target.value })}
className="min-h-[300px] h-fit"
placeholder="Paste compose file contents"
spellCheck={false}
onKeyDown={(e) => {
if (e.key === "Tab") {
e.preventDefault();
if (!fileContentsRef.current) return;
const start = fileContentsRef.current.selectionStart;
const end = fileContentsRef.current.selectionEnd;
const SPACE_COUNT = 4;
// set textarea value to: text before caret + tab + text after caret
fileContentsRef.current.value =
fileContentsRef.current.value.substring(0, start) +
// Use four spaces for indent
" ".repeat(SPACE_COUNT) +
fileContentsRef.current.value.substring(end);
// put caret at right position again
fileContentsRef.current.selectionStart =
fileContentsRef.current.selectionEnd =
start + SPACE_COUNT;
}
}}
/>
);
},
},
},
{
label: "Extra Args",
description: "Add extra args inserted after 'docker compose up -d'",
contentHidden:
((update.extra_args ?? config.extra_args)?.length ?? 0) === 0,
actions: !disabled && (
<AddExtraArgMenu
type="Stack"
onSelect={(suggestion) =>
set((update) => ({
...update,
extra_args: [
...(update.extra_args ?? config.extra_args ?? []),
suggestion,
],
}))
}
disabled={disabled}
/>
),
components: {
extra_args: (value, set) => (
<InputList
field="extra_args"
values={value ?? []}
set={set}
disabled={disabled}
placeholder="--extra-arg=value"
/>
),
},
},
{
label: "Image Registry",
description:
"Optional. Login to an image registry to pull private images",
components: {
registry_provider: (provider, set) => {
return (
<ProviderSelectorConfig
account_type="docker"
selected={provider}
disabled={disabled}
onSelect={(registry_provider) => set({ registry_provider })}
/>
);
},
registry_account: (value, set) => {
const server_id = update.server_id || config.server_id;
const provider =
update.registry_provider ?? config.registry_provider;
if (!provider) {
return null;
}
return (
<AccountSelectorConfig
id={server_id}
type={server_id ? "Server" : "None"}
account_type="docker"
provider={provider}
selected={value}
onSelect={(registry_account) => set({ registry_account })}
disabled={disabled}
placeholder="None"
/>
);
},
},
},
{
label: "Ignore Services",
description:
"If your compose file has init services that exit early, ignore them here so your stack will report the correct health.",
contentHidden:
((update.extra_args ?? config.extra_args)?.length ?? 0) === 0,
actions: !disabled && (
<Button
variant="secondary"
onClick={() =>
set((update) => ({
...update,
ignore_services: [
...(update.ignore_services ??
config.ignore_services ??
[]),
"",
],
}))
}
className="flex items-center gap-2 w-[200px]"
>
<PlusCircle className="w-4 h-4" />
Add Service
</Button>
),
components: {
ignore_services: (values, set) => (
<InputList
field="ignore_services"
values={values ?? []}
set={set}
disabled={disabled}
placeholder="Input service name"
/>
),
},
},
],
"Git Repo": [
{
label: "Git",
description:
"Provide config for repo-based compose files. Not necessary if file contents are configured in UI.",
components: {
git_provider: (provider, set) => {
const https = update.git_https ?? config.git_https;
return (
<ProviderSelectorConfig
account_type="git"
selected={provider}
disabled={disabled}
onSelect={(git_provider) => set({ git_provider })}
https={https}
onHttpsSwitch={() => set({ git_https: !https })}
/>
);
},
git_account: (value, set) => {
const server_id = update.server_id || config.server_id;
return (
<AccountSelectorConfig
id={server_id}
type={server_id ? "Server" : "None"}
account_type="git"
provider={update.git_provider ?? config.git_provider}
selected={value}
onSelect={(git_account) => set({ git_account })}
disabled={disabled}
placeholder="None"
/>
);
},
repo: {
placeholder: "Enter repo",
description:
"The repo path on the provider. {namespace}/{repo_name}",
},
branch: {
placeholder: "Enter branch",
description: "Select a custom branch, or default to 'main'.",
},
commit: {
placeholder: "Enter a specific commit hash. Optional.",
description:
"Switch to a specific hash after cloning the branch.",
},
},
},
{
label: "Run Path",
labelHidden: true,
components: {
run_directory: {
placeholder: "Eg. './'",
description:
"Set the cwd when running compose up command. Relative to the repo root.",
boldLabel: true,
},
},
},
{
label: "File Paths",
description:
"Add files to include using 'docker compose -f'. If empty, uses 'compose.yaml'.",
contentHidden:
(update.file_paths ?? config.file_paths)?.length === 0,
actions: !disabled && (
<Button
variant="secondary"
onClick={() =>
set((update) => ({
...update,
file_paths: [
...(update.file_paths ?? config.file_paths ?? []),
"",
],
}))
}
className="flex items-center gap-2 w-[200px]"
>
<PlusCircle className="w-4 h-4" />
Add File
</Button>
),
components: {
file_paths: (value, set) => (
<InputList
field="file_paths"
values={value ?? []}
set={set}
disabled={disabled}
placeholder="compose.yaml"
/>
),
},
},
{
label: "Git Webhooks",
description:
"Configure your repo provider to send webhooks to Monitor",
components: {
["Guard" as any]: () => {
if (update.branch ?? config.branch) {
return null;
}
return (
<ConfigItem label="Configure Branch">
<div>Must configure Branch before webhooks will work.</div>
</ConfigItem>
);
},
["Refresh" as any]: () =>
(update.branch ?? config.branch) && (
<ConfigItem label="Refresh Cache">
<CopyGithubWebhook path={`/stack/${id}/refresh`} />
</ConfigItem>
),
["Deploy" as any]: () =>
(update.branch ?? config.branch) && (
<ConfigItem label="Auto Redeploy">
<CopyGithubWebhook path={`/stack/${id}/deploy`} />
</ConfigItem>
),
webhook_enabled:
!!(update.branch ?? config.branch) &&
webhooks !== undefined &&
!webhooks.managed,
webhook_secret: {
description:
"Provide a custom webhook secret for this resource, or use the global default.",
placeholder: "Input custom secret",
},
["managed" as any]: () => {
const inv = useInvalidate();
const { toast } = useToast();
const { mutate: createWebhook, isPending: createPending } =
useWrite("CreateStackWebhook", {
onSuccess: () => {
toast({ title: "Webhook Created" });
inv(["GetStackWebhooksEnabled", { stack: id }]);
},
});
const { mutate: deleteWebhook, isPending: deletePending } =
useWrite("DeleteStackWebhook", {
onSuccess: () => {
toast({ title: "Webhook Deleted" });
inv(["GetStackWebhooksEnabled", { stack: id }]);
},
});
if (
!(update.branch ?? config.branch) ||
!webhooks ||
!webhooks.managed
) {
return null;
}
return (
<ConfigItem label="Manage Webhook">
{webhooks.deploy_enabled && (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
Incoming webhook is{" "}
<div
className={text_color_class_by_intention("Good")}
>
ENABLED
</div>
and will trigger
<div
className={text_color_class_by_intention("Neutral")}
>
DEPLOY
</div>
</div>
<ConfirmButton
title="Disable"
icon={<Ban className="w-4 h-4" />}
variant="destructive"
onClick={() =>
deleteWebhook({
stack: id,
action: Types.StackWebhookAction.Deploy,
})
}
loading={deletePending}
disabled={disabled || deletePending}
/>
</div>
)}
{!webhooks.deploy_enabled && webhooks.refresh_enabled && (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
Incoming webhook is{" "}
<div
className={text_color_class_by_intention("Good")}
>
ENABLED
</div>
and will trigger
<div
className={text_color_class_by_intention("Neutral")}
>
REFRESH
</div>
</div>
<ConfirmButton
title="Disable"
icon={<Ban className="w-4 h-4" />}
variant="destructive"
onClick={() =>
deleteWebhook({
stack: id,
action: Types.StackWebhookAction.Refresh,
})
}
loading={deletePending}
disabled={disabled || deletePending}
/>
</div>
)}
{!webhooks.deploy_enabled && !webhooks.refresh_enabled && (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
Incoming webhook is{" "}
<div
className={text_color_class_by_intention(
"Critical"
)}
>
DISABLED
</div>
</div>
<ConfirmButton
title="Enable Deploy"
icon={<CirclePlus className="w-4 h-4" />}
onClick={() =>
createWebhook({
stack: id,
action: Types.StackWebhookAction.Deploy,
})
}
loading={createPending}
disabled={disabled || createPending}
/>
<ConfirmButton
title="Enable Refresh"
icon={<CirclePlus className="w-4 h-4" />}
onClick={() =>
createWebhook({
stack: id,
action: Types.StackWebhookAction.Refresh,
})
}
loading={createPending}
disabled={disabled || createPending}
/>
</div>
)}
</ConfigItem>
);
},
},
},
],
environment: [
{
label: "Environment",
description: "Pass these variables to the compose command",
components: {
environment: (env, set) => {
const _env = typeof env === "object" ? env_to_text(env) : env;
return (
<Environment env={_env ?? ""} set={set} disabled={disabled} />
);
},
env_file_path: {
description:
"The path to write the file to, relative to the root of the repo.",
placeholder: ".env",
},
skip_secret_interp: true,
},
},
],
}}
/>
);
};
const Environment = ({
env,
set,
disabled,
}: {
env: string;
set: (input: Partial<Types.StackConfig>) => void;
disabled: boolean;
}) => {
const ref = createRef<HTMLTextAreaElement>();
const setEnv = (environment: string) => set({ environment });
return (
<ConfigItem className="flex-col gap-4 items-start">
{!disabled && (
<SecretsForEnvironment env={env} setEnv={setEnv} envRef={ref} />
)}
<Textarea
ref={ref}
className="min-h-[400px]"
placeholder="VARIABLE=value"
value={env}
onChange={(e) => setEnv(e.target.value)}
disabled={disabled}
spellCheck={false}
/>
</ConfigItem>
);
};