ソースを参照

feat: add form components

Thomas Zhang 3 ヶ月 前
コミット
f1cf0629a6

+ 3 - 0
src/lib/components/forms/array-input/index.ts

@@ -0,0 +1,3 @@
+import ArrayInput from './input.svelte';
+
+export { ArrayInput };

+ 71 - 0
src/lib/components/forms/array-input/input.svelte

@@ -0,0 +1,71 @@
+<script lang="ts">
+	import { X } from '@lucide/svelte';
+	import { flip } from 'svelte/animate';
+	import type { ClassValue } from 'svelte/elements';
+
+	import { Button } from '$lib/components/controls/button';
+	import { Input } from '$lib/components/controls/input';
+
+	let {
+		disabled = false,
+		value = $bindable(),
+		placeholder,
+		inputClass = '',
+		class: className = '',
+	}: {
+		disabled?: boolean;
+		value?: string[];
+		placeholder?: string;
+		inputClass?: ClassValue;
+		class?: ClassValue;
+	} = $props();
+
+	let inputText = $state('');
+
+	const addTag = () => {
+		const v = inputText.trim();
+		if (!v) return;
+		if (value === undefined) value = [v];
+		else if (!value.includes(v)) value.push(v);
+		inputText = '';
+	};
+</script>
+
+<div class={className}>
+	{#if value !== undefined && value.length > 0}
+		<div class="mb-2 flex flex-wrap gap-1">
+			{#each value as item, i (item)}
+				<span
+					class="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-1 text-xs text-white/75"
+					animate:flip={{ duration: 100 }}
+				>
+					{item}
+					<button
+						class="opacity-25 hover:opacity-75"
+						type="button"
+						onclick={() => {
+							if (value !== undefined) value.splice(i, 1);
+						}}
+					>
+						<X size={12} strokeWidth={2} />
+					</button>
+				</span>
+			{/each}
+		</div>
+	{/if}
+	<div class="flex w-full items-center gap-2">
+		<Input
+			class={inputClass}
+			{disabled}
+			{placeholder}
+			bind:value={inputText}
+			onkeydown={(e) => {
+				if (e.key === 'Enter') {
+					e.preventDefault();
+					addTag();
+				}
+			}}
+		/>
+		<Button type="button" variant="outline" onclick={() => addTag()}>Add</Button>
+	</div>
+</div>

+ 75 - 0
src/lib/components/forms/auth/form.svelte

@@ -0,0 +1,75 @@
+<script lang="ts">
+	import * as secretsApi from '$lib/api/secrets';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import * as Select from '$lib/components/controls/select';
+	import * as ToggleGroup from '$lib/components/controls/toggle-group';
+	import { AUTH_TYPES, type Secret } from '$lib/types/entities';
+
+	import type { AuthFormValue } from './types';
+
+	let {
+		workspaceId,
+		disabled = false,
+		form = $bindable(),
+	}: {
+		workspaceId: string;
+		disabled?: boolean;
+		form: AuthFormValue;
+	} = $props();
+	let secrets = $state<Secret[]>([]);
+	let secretItems = $derived(secrets.find((s) => s.id === form.secretName)?.items || []);
+
+	const loadSecrets = async () => {
+		secrets = await secretsApi.list(workspaceId, fetch);
+	};
+
+	$effect(() => {
+		loadSecrets();
+	});
+</script>
+
+<Field.Field>
+	<Field.Label>Auth Type</Field.Label>
+	<ToggleGroup.Root {disabled} type="single" variant="outline" bind:value={form.authType}>
+		{#each AUTH_TYPES as a (a.value)}
+			<ToggleGroup.Item value={a.value}>{a.label}</ToggleGroup.Item>
+		{/each}
+	</ToggleGroup.Root>
+</Field.Field>
+{#if form.authType === 'bearer' || form.authType === 'api-key'}
+	<div class="grid grid-cols-2 gap-4">
+		<Field.Field>
+			<Field.Label for="secretName">Secret Name</Field.Label>
+			<Select.Root type="single" bind:value={form.secretName}>
+				<Select.Trigger id="secretName">
+					{form.secretName || 'Select secret...'}
+				</Select.Trigger>
+				<Select.Content>
+					{#each secrets as secret (secret.id)}
+						<Select.Item value={secret.id}>{secret.name}</Select.Item>
+					{/each}
+				</Select.Content>
+			</Select.Root>
+		</Field.Field>
+		<Field.Field>
+			<Field.Label for="secretKey">Secret Key</Field.Label>
+			<Select.Root type="single" bind:value={form.secretKey}>
+				<Select.Trigger id="secretKey">
+					{form.secretKey || 'Select secret key...'}
+				</Select.Trigger>
+				<Select.Content>
+					{#each secretItems as item (item.key)}
+						<Select.Item value={item.key}>{item.key}</Select.Item>
+					{/each}
+				</Select.Content>
+			</Select.Root>
+		</Field.Field>
+	</div>
+	{#if form.authType === 'api-key'}
+		<Field.Field>
+			<Field.Label for="authAPIKeyHeader">API Key Header</Field.Label>
+			<Input id="authAPIKeyHeader" {disabled} placeholder="Default" bind:value={form.headerName} />
+		</Field.Field>
+	{/if}
+{/if}

+ 4 - 0
src/lib/components/forms/auth/index.ts

@@ -0,0 +1,4 @@
+import AuthForm from './form.svelte';
+
+export { AuthForm };
+export type { AuthFormValue } from './types';

+ 57 - 0
src/lib/components/forms/auth/types.ts

@@ -0,0 +1,57 @@
+import type { AuthConfig } from '$lib/types/entities';
+
+type AuthFormValue = {
+	authType?: 'none' | 'bearer' | 'api-key';
+	secretName?: string;
+	secretKey?: string;
+	headerName?: string;
+	headers?: { name: string; value: string }[];
+};
+
+const toAuthFormValue = (auth: AuthConfig): AuthFormValue => {
+	if (auth.type === 'none') {
+		return { authType: 'none' };
+	} else if (auth.type === 'bearer') {
+		return {
+			authType: 'bearer',
+			secretName: auth.secretName,
+			secretKey: auth.secretKey,
+		};
+	} else if (auth.type === 'api-key') {
+		return {
+			authType: 'api-key',
+			secretName: auth.secretName,
+			secretKey: auth.secretKey,
+			headerName: auth.headerName,
+			headers: auth.headers,
+		};
+	}
+	return {};
+};
+
+const fromAuthFormValue = (value: AuthFormValue | undefined): AuthConfig => {
+	if (value === undefined) {
+		return { type: 'none' };
+	}
+	if (value.authType === 'none') {
+		return { type: 'none' };
+	} else if (value.authType === 'bearer') {
+		return {
+			type: 'bearer',
+			secretName: value.secretName!,
+			secretKey: value.secretKey!,
+		};
+	} else if (value.authType === 'api-key') {
+		return {
+			type: 'api-key',
+			secretName: value.secretName!,
+			secretKey: value.secretKey!,
+			headerName: value.headerName!,
+			headers: value.headers,
+		};
+	}
+	return { type: 'none' };
+};
+
+export type { AuthFormValue };
+export { fromAuthFormValue, toAuthFormValue };

+ 257 - 0
src/lib/components/forms/deployment/form.svelte

@@ -0,0 +1,257 @@
+<script lang="ts">
+	import { Plus, X } from '@lucide/svelte';
+
+	import * as secretsApi from '$lib/api/secrets';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import * as Select from '$lib/components/controls/select';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import { type Secret } from '$lib/types/entities';
+
+	import type { DeploymentFormValue } from './types';
+
+	interface Runtime {
+		value: string;
+		label: string;
+		versions: {
+			value: string;
+			label: string;
+		}[];
+	}
+
+	let {
+		workspaceId,
+		disabled = false,
+		form = $bindable(),
+		runtimes = [],
+		imageRequired = false,
+	}: {
+		workspaceId: string;
+		disabled?: boolean;
+		form: DeploymentFormValue;
+		runtimes?: Runtime[];
+		imageRequired?: boolean;
+	} = $props();
+
+	let secrets = $state<Secret[]>([]);
+	let secretItems = $derived.by<Record<string, string[]>>(() => {
+		const items: Record<string, string[]> = {};
+		for (const secret of secrets) {
+			items[secret.id] = secret.items.map((item) => item.key);
+		}
+		return items;
+	});
+	const selectedRuntime = $derived(
+		form.runtime === ''
+			? { value: '', label: '<Custom>', versions: [{ value: '', label: '<Custom>' }] }
+			: runtimes.find((r) => r.value === form.runtime),
+	);
+	const selectedRuntimeVersion = $derived(
+		selectedRuntime?.versions.find((v) => v.value === form.runtimeVersion) ?? {
+			value: '',
+			label: '<Default>',
+		},
+	);
+
+	const loadSecrets = async () => {
+		secrets = await secretsApi.list(workspaceId, fetch);
+	};
+
+	$effect(() => {
+		loadSecrets();
+	});
+
+	const onSelectSecret = (idx: number, secretId: string) => {
+		if (!form.env) return;
+		const currentSecret = form.env[idx].value.split('/', 2)[0] || '';
+		if (currentSecret === secretId) return;
+		form.env[idx].value = `${secretId}`;
+	};
+
+	const onSelectSecretKey = (idx: number, secretKey: string) => {
+		if (!form.env) return;
+		const currentSecret = form.env[idx].value.split('/', 2)[0] || '';
+		form.env[idx].value = `${currentSecret}/${secretKey}`;
+	};
+
+	const onRemoveEnvVar = (idx: number) => {
+		if (!form.env) return;
+		form.env.splice(idx, 1);
+		form.env = [...form.env];
+	};
+
+	const onAddEnvVar = () => {
+		if (!form.env) {
+			form.env = [];
+		}
+		form.env.push({ name: '', type: 'text', value: '' });
+	};
+</script>
+
+<Field.Field>
+	<Field.Label for="runtime">Runtime</Field.Label>
+	<Select.Root {disabled} type="single" bind:value={form.runtime}>
+		<Select.Trigger id="runtime" class="w-full"
+			>{selectedRuntime?.label ?? form.runtime}</Select.Trigger
+		>
+		<Select.Content>
+			<Select.Item label="<Custom>" value="" />
+			{#each runtimes as r (r.value)}
+				<Select.Item label={r.label} value={r.value} />
+			{/each}
+		</Select.Content>
+	</Select.Root>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="runtimeVersion">Runtime Version</Field.Label>
+	<Select.Root {disabled} type="single" bind:value={form.runtimeVersion}>
+		<Select.Trigger id="runtimeVersion" class="w-full">
+			{selectedRuntimeVersion?.label ?? form.runtimeVersion}
+		</Select.Trigger>
+		<Select.Content>
+			<Select.Item label="<Default>" value="" />
+			{#each selectedRuntime?.versions ?? [] as v (v.value)}
+				<Select.Item label={v.label} value={v.value} />
+			{/each}
+		</Select.Content>
+	</Select.Root>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="replicas">Replicas</Field.Label>
+	<Input
+		id="replicas"
+		{disabled}
+		min={1}
+		placeholder="1"
+		type="number"
+		bind:value={form.replicas}
+	/>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="image">Container Image</Field.Label>
+	<Input
+		id="image"
+		{disabled}
+		placeholder={imageRequired ? undefined : 'Default'}
+		required={imageRequired}
+		type="text"
+		bind:value={form.image}
+	/>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="command">Container Command</Field.Label>
+	<Textarea
+		id="command"
+		class="break-all"
+		{disabled}
+		placeholder="Runtime default"
+		rows={4}
+		bind:value={() => form.command?.join('\n') || '', (v) => (form.command = v.split('\n'))}
+	/>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="args">Container Args</Field.Label>
+	<Textarea
+		id="args"
+		class="break-all"
+		{disabled}
+		placeholder="Runtime default"
+		rows={4}
+		bind:value={() => form.args?.join('\n') || '', (v) => (form.args = v.split('\n'))}
+	/>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="args">Container Extra Args</Field.Label>
+	<Textarea
+		id="extraArgs"
+		class="break-all"
+		{disabled}
+		rows={4}
+		bind:value={() => form.extraArgs?.join('\n') || '', (v) => (form.extraArgs = v.split('\n'))}
+	/>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="env">Environment Variables</Field.Label>
+	{#each form.env || [] as entry, idx (idx)}
+		<div class="flex items-center gap-2">
+			<Select.Root {disabled} type="single" bind:value={entry.type}>
+				<Select.Trigger class="w-24 flex-none capitalize">{entry.type}</Select.Trigger>
+				<Select.Content>
+					<Select.Item label="Text" value="text" />
+					<Select.Item label="Secret" value="secret" />
+				</Select.Content>
+			</Select.Root>
+			<Input class="flex-1" {disabled} placeholder="KEY" bind:value={entry.name} />
+			{#if entry.type === 'text'}
+				<Input class="flex-1" {disabled} placeholder="VALUE" bind:value={entry.value} />
+			{:else}
+				{@const secretPath = entry.value.split('/', 2)}
+				<Select.Root
+					onValueChange={(v) => onSelectSecret(idx, v)}
+					type="single"
+					value={secretPath[0]}
+				>
+					<Select.Trigger class="flex-1">
+						{secretPath[0] || 'Select secret...'}
+					</Select.Trigger>
+					<Select.Content>
+						{#each secrets as secret (secret.id)}
+							<Select.Item value={secret.id}>{secret.name}</Select.Item>
+						{/each}
+					</Select.Content>
+				</Select.Root>
+				<Select.Root
+					onValueChange={(v) => onSelectSecretKey(idx, v)}
+					type="single"
+					value={secretPath[1]}
+				>
+					<Select.Trigger class="flex-1">
+						{secretPath[1] || 'Select key...'}
+					</Select.Trigger>
+					<Select.Content>
+						{#each secretItems[secretPath[0]] as secretKey (secretKey)}
+							<Select.Item value={secretKey}>{secretKey}</Select.Item>
+						{/each}
+					</Select.Content>
+				</Select.Root>
+			{/if}
+			<button class="opacity-25 hover:opacity-75" type="button" onclick={() => onRemoveEnvVar(idx)}>
+				<X size={12} strokeWidth={2} />
+			</button>
+		</div>
+	{/each}
+	<Button variant="outline" onclick={() => onAddEnvVar()}>
+		<Plus size={14} strokeWidth={2} />
+		Add Environment Variable
+	</Button>
+</Field.Field>
+<Field.Field>
+	<Field.Label for="port">Port</Field.Label>
+	<Input
+		id="port"
+		{disabled}
+		min={0}
+		placeholder="Runtime default"
+		type="number"
+		bind:value={form.port}
+	/>
+</Field.Field>
+<div class="grid grid-cols-2 gap-4">
+	<Field.Field>
+		<Field.Label for="cpu">CPU</Field.Label>
+		<Input id="cpu" {disabled} placeholder="Unbound" bind:value={form.cpu} />
+	</Field.Field>
+	<Field.Field>
+		<Field.Label for="memory">Memory</Field.Label>
+		<Input id="memory" {disabled} placeholder="Unbound" bind:value={form.memory} />
+	</Field.Field>
+	<Field.Field>
+		<Field.Label for="gpuModel">GPU Model</Field.Label>
+		<Input id="gpuModel" {disabled} placeholder="Any" bind:value={form.gpuModel} />
+	</Field.Field>
+	<Field.Field>
+		<Field.Label for="gpu">GPU</Field.Label>
+		<Input id="gpu" {disabled} placeholder="0" bind:value={form.gpu} />
+	</Field.Field>
+</div>

+ 4 - 0
src/lib/components/forms/deployment/index.ts

@@ -0,0 +1,4 @@
+import DeploymentForm from './form.svelte';
+
+export { DeploymentForm };
+export type { DeploymentFormValue } from './types';

+ 101 - 0
src/lib/components/forms/deployment/types.ts

@@ -0,0 +1,101 @@
+import type { RuntimeTemplate } from '$lib/types/entities';
+
+interface DeploymentFormValue {
+	runtime: string;
+	runtimeVersion?: string;
+	replicas?: number;
+	image?: string;
+	command?: string[];
+	args?: string[];
+	extraArgs?: string[];
+	env?: { name: string; type: string; value: string }[];
+	port?: number;
+	cpu?: string;
+	memory?: string;
+	gpuModel?: string;
+	gpu?: string;
+}
+
+const toDeploymentFormValue = (template: RuntimeTemplate): DeploymentFormValue => {
+	const gpuModel = template.node?.['locostack.com/gpu-model'];
+	const env: { name: string; type: string; value: string }[] = [];
+	for (const entry of template.env ?? []) {
+		if (entry.valueFrom?.secretKeyRef) {
+			env.push({
+				name: entry.name,
+				type: 'secret',
+				value: `${entry.valueFrom.secretKeyRef.name}/${entry.valueFrom.secretKeyRef.key}`,
+			});
+		} else {
+			env.push({
+				name: entry.name,
+				type: 'text',
+				value: entry.value ?? '',
+			});
+		}
+	}
+	return {
+		runtime: template.name,
+		runtimeVersion: template.version,
+		replicas: template.replicas ?? 1,
+		image: template.image,
+		command: template.command,
+		args: template.args,
+		extraArgs: template.extraArgs,
+		env: env.length > 0 ? env : undefined,
+		port: template.port,
+		cpu: template.cpu,
+		memory: template.memory,
+		gpuModel: gpuModel !== undefined && gpuModel !== '' ? gpuModel : undefined,
+		gpu: template.gpu,
+	};
+};
+
+const fromDeploymentFormValue = (value: DeploymentFormValue): RuntimeTemplate => {
+	const env: {
+		name: string;
+		value?: string;
+		valueFrom?: { secretKeyRef: { name: string; key: string } };
+	}[] = [];
+	for (const entry of value.env ?? []) {
+		if (entry.type === 'secret') {
+			const [secretName, secretKey] = entry.value.split('/', 2);
+			if (entry.name && secretName && secretKey) {
+				env.push({
+					name: entry.name,
+					valueFrom: { secretKeyRef: { name: secretName, key: secretKey } },
+				});
+			}
+		} else if (entry.type === 'text') {
+			if (entry.name && entry.value) {
+				env.push({
+					name: entry.name,
+					value: entry.value,
+				});
+			}
+		}
+	}
+	return {
+		name: value.runtime,
+		version: value.runtimeVersion,
+		replicas: value.replicas ?? 1,
+		image: value.image,
+		command: value.command,
+		args: value.args,
+		extraArgs: value.extraArgs,
+		env: env.length > 0 ? env : undefined,
+		port: value.port,
+		node:
+			value.gpuModel !== undefined
+				? {
+						'locostack.com/gpu-model': value.gpuModel,
+					}
+				: {},
+		cpu: value.cpu,
+		memory: value.memory,
+		gpu: value.gpu,
+	};
+};
+
+export type { DeploymentFormValue };
+export { fromDeploymentFormValue, toDeploymentFormValue };

+ 36 - 0
src/lib/components/forms/icon-picker/icon-picker.svelte

@@ -0,0 +1,36 @@
+<script lang="ts">
+	import { Icon, ICONS } from '$lib/components/common/icon';
+	import * as DropdownMenu from '$lib/components/controls/dropdown-menu';
+
+	let {
+		disabled = false,
+		value = $bindable(),
+	}: {
+		disabled?: boolean;
+		value?: string;
+	} = $props();
+</script>
+
+<DropdownMenu.Root>
+	<DropdownMenu.Trigger
+		class="flex h-18 w-18 cursor-pointer items-center justify-center rounded-lg border border-white/10 transition-colors hover:bg-white/5"
+		{disabled}
+	>
+		<Icon icon={value} size={36} />
+	</DropdownMenu.Trigger>
+	<DropdownMenu.Content class="w-auto p-1">
+		<div class="grid grid-cols-4 gap-1">
+			{#each Object.entries(ICONS) as [id, IconComp] (id)}
+				<DropdownMenu.Item
+					class={[
+						'justify-center p-2',
+						value === id ? 'border border-primary bg-primary/25 text-primary' : '',
+					]}
+					onclick={() => (value = id)}
+				>
+					<IconComp size={24} strokeWidth={2} />
+				</DropdownMenu.Item>
+			{/each}
+		</div>
+	</DropdownMenu.Content>
+</DropdownMenu.Root>

+ 3 - 0
src/lib/components/forms/icon-picker/index.ts

@@ -0,0 +1,3 @@
+import IconPicker from './icon-picker.svelte';
+
+export { IconPicker };

+ 40 - 0
src/lib/components/forms/optional/collapse.svelte

@@ -0,0 +1,40 @@
+<script lang="ts">
+	import { ChevronDown, ChevronRight } from '@lucide/svelte';
+	import type { Snippet } from 'svelte';
+	import type { ClassValue } from 'svelte/elements';
+	import { slide } from 'svelte/transition';
+
+	let {
+		title,
+		class: className,
+		children,
+	}: {
+		title: string;
+		class?: ClassValue;
+		children: Snippet;
+	} = $props();
+
+	let open = $state(false);
+</script>
+
+<div class={['border-t border-white/10 pt-4', className]}>
+	<button
+		class="flex w-full items-center gap-2 text-xs font-medium text-white/50 transition-colors hover:text-white/75"
+		type="button"
+		onclick={() => (open = !open)}
+	>
+		{#if open}
+			<ChevronDown size={14} strokeWidth={2} />
+		{:else}
+			<ChevronRight size={14} strokeWidth={2} />
+		{/if}
+		{title}
+		<span class="ml-1 font-normal text-white/25">Optional</span>
+	</button>
+
+	{#if open}
+		<div class="mt-4 space-y-4" transition:slide={{ duration: 200 }}>
+			{@render children()}
+		</div>
+	{/if}
+</div>

+ 3 - 0
src/lib/components/forms/optional/index.ts

@@ -0,0 +1,3 @@
+import Optional from './collapse.svelte';
+
+export { Optional };