import { Config, ConfigComponent } from "@components/config"; import { AccountSelectorConfig, AddExtraArgMenu, ConfigItem, ConfigList, InputList, ProviderSelectorConfig, SystemCommand, } from "@components/config/util"; import { Types } from "@komodo/client"; import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks"; import { ReactNode, useState } from "react"; import { CopyGithubWebhook, ResourceLink, ResourceSelector } from "../common"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { SecretsSearch } from "@components/config/env_vars"; import { ConfirmButton, ShowHideButton } from "@components/util"; import { MonacoEditor } from "@components/monaco"; import { useToast } from "@ui/use-toast"; import { text_color_class_by_intention } from "@lib/color"; import { Ban, CirclePlus } from "lucide-react"; type StackMode = "UI Defined" | "Files On Server" | "Git Repo" | undefined; const STACK_MODES: StackMode[] = ["UI Defined", "Files On Server", "Git Repo"]; function getStackMode( update: Partial, config: Types.StackConfig ): StackMode { if (update.files_on_host ?? config.files_on_host) return "Files On Server"; if (update.repo ?? config.repo) return "Git Repo"; if (update.file_contents ?? config.file_contents) return "UI Defined"; return undefined; } export const StackConfig = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [show, setShow] = useLocalStorage(`stack-${id}-show`, { file: true, env: true, git: true, webhooks: true, }); 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>({}); const { mutateAsync } = useWrite("UpdateStack"); if (!config) return null; const disabled = global_disabled || perms !== Types.PermissionLevel.Write; const run_build = update.run_build ?? config.run_build; const mode = getStackMode(update, config); const setMode = (mode: StackMode) => { if (mode === "Files On Server") { set({ ...update, files_on_host: true }); } else if (mode === "Git Repo") { set({ ...update, files_on_host: false, repo: update.repo || config.repo || "namespace/repo", }); } else if (mode === "UI Defined") { set({ ...update, files_on_host: false, repo: "", file_contents: update.file_contents || config.file_contents || DEFAULT_STACK_FILE_CONTENTS, }); } else if (mode === undefined) { set({ ...update, files_on_host: false, repo: "", file_contents: "", }); } }; let components: Record< string, false | ConfigComponent[] | undefined > = {}; const server_component: ConfigComponent = { label: "Server", labelHidden: true, components: { server_id: (server_id, set) => { return ( Server: ) : ( "Select Server" ) } description="Select the Server to deploy on." > set({ server_id })} disabled={disabled} align="start" /> ); }, }, }; const choose_mode: ConfigComponent = { label: "Choose Mode", labelHidden: true, components: { server_id: () => { return ( ); }, }, }; const general_common: ConfigComponent[] = [ { label: "Environment", description: "Pass these variables to the compose command", actions: ( setShow({ ...show, env })} /> ), contentHidden: !show.env, components: { environment: (env, set) => (
set({ environment })} language="key_value" readOnly={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, }, }, { label: "Links", labelHidden: true, components: { links: (values, set) => ( ), }, }, ]; const advanced: ConfigComponent[] = [ { label: "Project Name", labelHidden: true, components: { project_name: { placeholder: "Compose project name", boldLabel: true, description: "Optionally set a different compose project name. If importing existing stack, this should match the compose project name on your host.", }, }, }, { label: "Pre Deploy", description: "Execute a shell command before running docker compose up. The 'path' is relative to the Run Directory", components: { pre_deploy: (value, set) => ( set({ pre_deploy: value })} disabled={disabled} /> ), }, }, { label: "Extra Args", labelHidden: true, components: { extra_args: (value, set) => ( {!disabled && ( set({ extra_args: [ ...(update.extra_args ?? config.extra_args ?? []), suggestion, ], }) } disabled={disabled} /> )} ), }, }, { label: "Ignore Services", labelHidden: true, components: { ignore_services: (values, set) => ( ), }, }, { label: "Pull Images", labelHidden: true, components: { registry_provider: (provider, set) => { return ( 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 ( set({ registry_account })} disabled={disabled} placeholder="None" /> ); }, auto_pull: { label: "Pre Pull Images", description: "Ensure 'docker compose pull' is run before redeploying the Stack. Otherwise, use 'pull_policy' in docker compose file.", }, }, }, { label: "Build Images", labelHidden: true, components: { run_build: { label: "Pre Build Images", description: "Ensure 'docker compose build' is run before redeploying the Stack. Otherwise, can use '--build' as an Extra Arg.", }, build_extra_args: (value, set) => run_build && ( {!disabled && ( set({ build_extra_args: [ ...(update.build_extra_args ?? config.build_extra_args ?? []), suggestion, ], }) } disabled={disabled} /> )} ), }, }, ]; if (mode === undefined) { components = { "": [server_component, choose_mode], }; } else if (mode === "Files On Server") { components = { "": [ server_component, { label: "Files", labelHidden: true, components: { run_directory: { label: "Run Directory", description: "Set the working directory when running the compose up command. Usually is the parent folder of the compose files.", placeholder: "/path/to/folder", }, file_paths: (value, set) => ( ), }, }, ...general_common, ], advanced, }; } else if (mode === "Git Repo") { components = { "": [ server_component, { label: "Source", contentHidden: !show.git, actions: ( setShow({ ...show, git })} /> ), components: { git_provider: (provider, set) => { const https = update.git_https ?? config.git_https; return ( set({ git_provider })} https={https} onHttpsSwitch={() => set({ git_https: !https })} /> ); }, git_account: (value, set) => { const server_id = update.server_id || config.server_id; return ( 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.", }, reclone: { description: "Delete the repo folder and clone it again, instead of using 'git pull'.", }, }, }, { label: "Files", labelHidden: true, components: { run_directory: { label: "Run Directory", description: "Set the working directory when running the compose up command, relative to the repo root.", placeholder: "./path/to/folder", }, file_paths: (value, set) => ( ), }, }, ...general_common, { label: "Webhooks", description: "Configure your repo provider to send webhooks to Komodo", actions: ( setShow({ ...show, webhooks })} /> ), contentHidden: !show.webhooks, components: { ["Guard" as any]: () => { if (update.branch ?? config.branch) { return null; } return (
Must configure Branch before webhooks will work.
); }, ["Refresh" as any]: () => (update.branch ?? config.branch) && ( ), ["Deploy" as any]: () => (update.branch ?? config.branch) && ( ), 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 ( {webhooks.deploy_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
DEPLOY
} variant="destructive" onClick={() => deleteWebhook({ stack: id, action: Types.StackWebhookAction.Deploy, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.deploy_enabled && webhooks.refresh_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
REFRESH
} variant="destructive" onClick={() => deleteWebhook({ stack: id, action: Types.StackWebhookAction.Refresh, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.deploy_enabled && !webhooks.refresh_enabled && (
Incoming webhook is{" "}
DISABLED
} onClick={() => createWebhook({ stack: id, action: Types.StackWebhookAction.Deploy, }) } loading={createPending} disabled={disabled || createPending} /> } onClick={() => createWebhook({ stack: id, action: Types.StackWebhookAction.Refresh, }) } loading={createPending} disabled={disabled || createPending} />
)}
); }, }, }, ], advanced, }; } else if (mode === "UI Defined") { components = { "": [ server_component, { label: "Compose File", description: "Manage the compose file contents here.", actions: ( setShow({ ...show, file })} /> ), contentHidden: !show.file, components: { file_contents: (file_contents, set) => { const show_default = !file_contents && update.file_contents === undefined && !(update.repo ?? config.repo); return ( set({ file_contents })} language="yaml" readOnly={disabled} /> ); }, }, }, ...general_common, ], advanced, }; } return ( { await mutateAsync({ id, config: update }); }} components={components} file_contents_language="yaml" /> ); }; const DEFAULT_STACK_FILE_CONTENTS = `## 🦎 Hello Komodo 🦎 services: hello_world: image: hello-world # networks: # - default # ports: # - 3000:3000 # volumes: # - data:/data # networks: # default: {} # volumes: # data: `;