Bläddra i källkod

feat: add tool pages

Thomas Zhang 3 månader sedan
förälder
incheckning
542c4b4dd2

+ 74 - 0
src/lib/components/tools/card/card.svelte

@@ -0,0 +1,74 @@
+<script lang="ts">
+	import type { ClassValue } from 'svelte/elements';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import { Tags } from '$lib/components/common/tags';
+	import type { Tool } from '$lib/types/entities';
+
+	let {
+		tool,
+		selectedTags,
+		class: className = '',
+		onselect,
+	}: {
+		tool: Tool;
+		selectedTags?: Set<string>;
+		class?: ClassValue;
+		onselect?: () => void;
+	} = $props();
+</script>
+
+{#snippet content()}
+	<GlassPane class={['flex h-32 gap-4 overflow-hidden rounded-xl px-4 py-2', className]} reactive>
+		<!-- icon -->
+		<div class="flex shrink-0 items-center justify-center">
+			<Icon icon={tool.icon} />
+		</div>
+
+		<div class="flex flex-1 flex-col gap-1 overflow-hidden">
+			<!-- header: name + status badge -->
+			<div class="flex items-center justify-between gap-2">
+				<span class="truncate text-lg font-medium text-white/75 group-hover:text-white">
+					{tool.name}
+				</span>
+				{#if tool.status}
+					<StatusBadge class="shrink-0" status={tool.status} />
+				{/if}
+			</div>
+
+			<!-- description -->
+			{#if tool.description}
+				<div class="line-clamp-2 text-sm text-white/50">
+					{tool.description}
+				</div>
+			{:else}
+				<div class="text-xs text-white/25">&lt;no description&gt;</div>
+			{/if}
+
+			<!-- tags -->
+			<div class="mt-auto overflow-hidden">
+				<Tags activeTags={selectedTags} tags={tool.tags} />
+			</div>
+		</div>
+	</GlassPane>
+{/snippet}
+
+{#if onselect === undefined}
+	<a class="group block" href={resolve('/workloads/tools/[id]', { id: tool.id })}>
+		{@render content()}
+	</a>
+{:else}
+	<div
+		class="group block"
+		role="button"
+		tabindex="0"
+		onclick={() => onselect()}
+		onkeydown={(e) => e.key === 'Enter' && onselect()}
+	>
+		{@render content()}
+	</div>
+{/if}

+ 3 - 0
src/lib/components/tools/card/index.ts

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

+ 230 - 0
src/lib/components/tools/form/form.svelte

@@ -0,0 +1,230 @@
+<script lang="ts">
+	import { Card } from '$lib/components/common/card';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import * as ToggleGroup from '$lib/components/controls/toggle-group';
+	import { ArrayInput } from '$lib/components/forms/array-input';
+	import { AuthForm } from '$lib/components/forms/auth';
+	import { DeploymentForm } from '$lib/components/forms/deployment';
+	import { IconPicker } from '$lib/components/forms/icon-picker';
+	import { Optional } from '$lib/components/forms/optional';
+
+	import { DEFAULT_TOOL_FORM_VALUE, type ToolFormValue } from './types';
+
+	let {
+		workspaceId,
+		disabled = false,
+		submitting = false,
+		value,
+		cancelHref,
+		onsubmit,
+	}: {
+		workspaceId: string;
+		disabled?: boolean;
+		submitting?: boolean;
+		value?: ToolFormValue;
+		cancelHref: string;
+		onsubmit: (value: ToolFormValue) => void;
+	} = $props();
+
+	const isEdit = $derived(value !== undefined);
+	let form = $state<ToolFormValue>({ ...DEFAULT_TOOL_FORM_VALUE });
+	let errors = $state<{
+		name?: string;
+		endpoint?: string;
+		launchCommand?: string;
+		image?: string;
+		port?: string;
+	}>({});
+
+	const initForm = (value?: ToolFormValue) => {
+		if (!value) {
+			form = { ...DEFAULT_TOOL_FORM_VALUE };
+		} else {
+			form = {
+				...$state.snapshot(value),
+			};
+		}
+	};
+
+	const inferRuntime = (launchCommand?: string) => {
+		if (launchCommand === undefined || launchCommand.trim() === '') {
+			return;
+		}
+		if (launchCommand.startsWith('uvx')) {
+			form.runtime = 'uvx';
+		} else if (launchCommand.startsWith('npx')) {
+			form.runtime = 'npx';
+		}
+		form.command = launchCommand.split(' ');
+	};
+
+	$effect(() => initForm(value));
+	$effect(() => {
+		inferRuntime(form.launchCommand);
+	});
+
+	const handleSubmit = async () => {
+		onsubmit(form);
+	};
+</script>
+
+{#snippet panel()}
+	<div class="flex items-center gap-2">
+		<Button href={cancelHref} size="sm" variant="outline">Cancel</Button>
+		<Button disabled={submitting} size="sm" onclick={handleSubmit}>
+			{#if submitting}
+				<Spinner />
+			{/if}
+			{isEdit ? 'Save Changes' : 'Create'}
+		</Button>
+	</div>
+{/snippet}
+
+{#snippet transportField()}
+	<Field.Field>
+		<Field.Label for="transport">Transport</Field.Label>
+		<ToggleGroup.Root
+			id="transport"
+			{disabled}
+			type="single"
+			variant="outline"
+			bind:value={form.transport}
+		>
+			<ToggleGroup.Item value="http">Streamable HTTP</ToggleGroup.Item>
+			<ToggleGroup.Item value="sse">Server-side Events</ToggleGroup.Item>
+		</ToggleGroup.Root>
+	</Field.Field>
+{/snippet}
+
+<div class="mx-auto max-w-2xl p-6">
+	<!-- Page header -->
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div>
+			<h1 class="text-xl font-semibold text-white/90">
+				{isEdit ? 'Edit Tool' : 'New Tool'}
+			</h1>
+			{#if isEdit}
+				<div class="text-xs text-white/30 capitalize">{form.mode}</div>
+			{/if}
+		</div>
+		{@render panel()}
+	</div>
+
+	<!-- Basic Information panel (shared) -->
+	<Card class="mb-4" title="Basic Information">
+		<div class="mb-4 flex gap-8">
+			<!-- Icon -->
+			<div class="shrink-0">
+				<IconPicker bind:value={form.icon} />
+			</div>
+			<!-- Name, Mode -->
+			<div class="min-w-0 flex-1 space-y-4">
+				<Field.Set>
+					<Field.Field>
+						<Field.Label for="name">Name</Field.Label>
+						<Input id="name" {disabled} maxlength={64} bind:value={form.name} />
+						<Field.Error>{errors.name}</Field.Error>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="description">Description</Field.Label>
+						<Textarea
+							id="description"
+							{disabled}
+							placeholder="What does this tool do?"
+							rows={3}
+							bind:value={form.description}
+						/>
+						<Field.Description>
+							Provide a brief description that can also help AI assistant to understand the tool's
+							functionality.
+						</Field.Description>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="mode">Mode</Field.Label>
+						<ToggleGroup.Root
+							id="mode"
+							disabled={isEdit || disabled}
+							type="single"
+							variant="outline"
+							bind:value={form.mode}
+						>
+							<ToggleGroup.Item value="external">External</ToggleGroup.Item>
+							<ToggleGroup.Item value="managed">Managed</ToggleGroup.Item>
+						</ToggleGroup.Root>
+					</Field.Field>
+				</Field.Set>
+			</div>
+		</div>
+		<Optional title="Tags">
+			<Field.Field>
+				<ArrayInput {disabled} bind:value={form.tags} />
+				<Field.Description>
+					Tags can be used for filtering and searching in the list view.
+				</Field.Description>
+			</Field.Field>
+		</Optional>
+	</Card>
+
+	<!-- External: Endpoint panel -->
+	{#if form.mode === 'external'}
+		<Card class="mb-4" title="Endpoint">
+			<Field.Set>
+				{@render transportField()}
+				<Field.Field>
+					<Field.Label for="endpoint">URL</Field.Label>
+					<Input
+						id="endpoint"
+						{disabled}
+						placeholder="e.g. https://my-tool.internal/mcp"
+						bind:value={form.endpoint}
+					/>
+					<Field.Error>{errors.endpoint}</Field.Error>
+				</Field.Field>
+				<AuthForm {disabled} {workspaceId} bind:form />
+			</Field.Set>
+		</Card>
+	{/if}
+
+	<!-- Managed: Image panel -->
+	{#if form.mode === 'managed'}
+		<Card class="mb-4" title="Deployment">
+			<Field.Set>
+				{@render transportField()}
+				<Field.Field>
+					<Field.Label for="launchCommand">Launch Command</Field.Label>
+					<Input
+						id="launchCommand"
+						{disabled}
+						placeholder="e.g. uvx tool"
+						bind:value={form.launchCommand}
+					/>
+					<Field.Description>
+						The command to launch the mcp server.<br />
+						The runtime will be inferred based on the command.<br />
+						(uvx: Python, npx: Node.js)
+					</Field.Description>
+					<Field.Error>{errors.launchCommand}</Field.Error>
+				</Field.Field>
+				<Optional title="Runtime Settings">
+					<DeploymentForm
+						{disabled}
+						runtimes={[
+							{ value: 'uvx', label: 'Python (uvx)', versions: [] },
+							{ value: 'npx', label: 'Node.js (npx)', versions: [] },
+						]}
+						bind:form
+					/>
+				</Optional>
+			</Field.Set>
+		</Card>
+	{/if}
+
+	<!-- Footer actions -->
+	<div class="flex justify-end">
+		{@render panel()}
+	</div>
+</div>

+ 3 - 0
src/lib/components/tools/form/index.ts

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

+ 97 - 0
src/lib/components/tools/form/types.ts

@@ -0,0 +1,97 @@
+import type { AuthFormValue } from '$lib/components/forms/auth';
+import { fromAuthFormValue, toAuthFormValue } from '$lib/components/forms/auth/types';
+import type { DeploymentFormValue } from '$lib/components/forms/deployment';
+import {
+	fromDeploymentFormValue,
+	toDeploymentFormValue,
+} from '$lib/components/forms/deployment/types';
+import type { ExternalTool, ManagedTool, Tool, ToolBase } from '$lib/types/entities';
+
+type ToolFormValue = {
+	icon?: string;
+	name: string;
+	description: string;
+	tags: string[];
+	mode: 'external' | 'managed';
+	transport: 'streamable-http' | 'sse';
+	endpoint?: string;
+	launchCommand?: string;
+	volumes?: { name: string; size: string; mountPath: string }[];
+} & AuthFormValue &
+	DeploymentFormValue;
+
+const DEFAULT_TOOL_FORM_VALUE: ToolFormValue = {
+	icon: 'tool',
+	name: '',
+	description: '',
+	tags: [],
+	mode: 'external',
+	transport: 'streamable-http',
+	runtime: 'uvx',
+};
+
+const toToolFormValue = (tool: Tool): ToolFormValue => {
+	let value = {
+		...DEFAULT_TOOL_FORM_VALUE,
+		icon: tool.icon,
+		name: tool.name,
+		description: tool.description,
+		tags: tool.tags,
+		mode: tool.mode,
+		transport: tool.transport,
+	};
+	if (tool.mode === 'external') {
+		const externalTool = tool as ExternalTool;
+		value = {
+			...value,
+			endpoint: externalTool.endpoint,
+			...toAuthFormValue(externalTool.auth),
+		};
+	} else if (tool.mode === 'managed') {
+		const managedTool = tool as ManagedTool;
+		const launchCommand = managedTool.runtime.command ? managedTool.runtime.command.join(' ') : '';
+		value = {
+			...value,
+			...toDeploymentFormValue(managedTool),
+			launchCommand,
+		};
+	}
+	return value;
+};
+
+const fromToolFormValue = (value: ToolFormValue): Tool => {
+	const tool: ToolBase = {
+		id: '',
+		stack: 'default',
+		name: value.name,
+		icon: value.icon,
+		description: value.description,
+		tags: value.tags,
+		transport: value.transport,
+	};
+	if (value.mode === 'external') {
+		return {
+			...tool,
+			mode: 'external',
+			endpoint: value.endpoint!,
+			auth: fromAuthFormValue(value),
+		} as ExternalTool;
+	} else {
+		return {
+			...tool,
+			mode: 'managed',
+			runtime: fromDeploymentFormValue(value),
+		} as ManagedTool;
+	}
+};
+
+const validateToolFormValue = (value: ToolFormValue): { tool: Tool; errors: string[] } => {
+	const errors: string[] = [];
+	if (!value.name.trim()) {
+		errors.push('Name is required');
+	}
+	return { tool: fromToolFormValue(value), errors };
+};
+
+export type { ToolFormValue };
+export { DEFAULT_TOOL_FORM_VALUE, toToolFormValue, validateToolFormValue };

+ 96 - 0
src/lib/components/tools/select/dialog.svelte

@@ -0,0 +1,96 @@
+<script lang="ts">
+	import { Toolbox } from '@lucide/svelte';
+	import { untrack } from 'svelte';
+
+	import * as toolsApi from '$lib/api/tools';
+	import { Empty } from '$lib/components/common/empty';
+	import { SelectDialog } from '$lib/components/common/select-dialog';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { ToolCard } from '$lib/components/tools/card';
+	import type { Tool } from '$lib/types/entities';
+
+	let {
+		workspaceId,
+		multiple = true,
+		open = $bindable(false),
+		value = $bindable([]),
+		onselect,
+	}: {
+		workspaceId: string;
+		multiple?: boolean;
+		open?: boolean;
+		value?: Tool[];
+		onselect?: (model: Tool[]) => Tool[];
+	} = $props();
+
+	let query = $state('');
+	let loading = $state(false);
+	let tools = $state<Tool[]>([]);
+
+	const loadTools = async () => {
+		if (loading || tools.length > 0) {
+			return;
+		}
+		loading = true;
+		try {
+			tools = await toolsApi.list(workspaceId);
+		} catch (error) {
+			console.error('Failed to load tools', error);
+		}
+		loading = false;
+	};
+
+	$effect(() => {
+		if (open) {
+			untrack(() => {
+				loadTools();
+			});
+		}
+	});
+
+	const select = (tool: Tool) => {
+		if (value.includes(tool)) {
+			value = value.filter((t) => t.id !== tool.id);
+		} else {
+			value = [...value, tool];
+		}
+		if (onselect) {
+			value = onselect(value);
+		}
+	};
+</script>
+
+<SelectDialog
+	selected={value.map((tool) => tool.name)}
+	title="Select Tools"
+	bind:open
+	bind:query
+	onsave={multiple ? () => (open = false) : undefined}
+>
+	{#snippet content()}
+		{#if loading}
+			<div class="flex h-full w-full items-center justify-center gap-2">
+				<Spinner />
+				<div class="text-sm text-white/50">Loading Tools...</div>
+			</div>
+		{:else if tools.length === 0}
+			<Empty icon={Toolbox}>
+				{#snippet caption()}
+					<div>No tools found</div>
+				{/snippet}
+			</Empty>
+		{:else}
+			<div class="grid grid-cols-2 gap-2">
+				{#each tools as tool (tool.id)}
+					<ToolCard
+						class={value.includes(tool)
+							? 'border-primary/50 bg-primary/25 hover:border-primary/75 hover:bg-primary/50'
+							: ''}
+						{tool}
+						onselect={() => select(tool)}
+					/>
+				{/each}
+			</div>
+		{/if}
+	{/snippet}
+</SelectDialog>

+ 3 - 0
src/lib/components/tools/select/index.ts

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

+ 9 - 0
src/routes/workloads/tools/+page.server.ts

@@ -0,0 +1,9 @@
+import * as toolsApi from '$lib/api/tools';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, locals }) => ({
+	tools: await toolsApi.list(locals.workspaceId!, fetch),
+});
+
+export { load };

+ 91 - 0
src/routes/workloads/tools/+page.svelte

@@ -0,0 +1,91 @@
+<script lang="ts">
+	import { Toolbox } from '@lucide/svelte';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	import { resolve } from '$app/paths';
+
+	import { FilterList } from '$lib/components/common/filter-list';
+	import * as Filters from '$lib/components/common/filters';
+	import { ToolCard } from '$lib/components/tools/card';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { toolsStore } from '$lib/stores/tools.svelte';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+	let query = $state('');
+	const allTags = $derived([...new Set(toolsStore.list.flatMap((m) => m.tags ?? []))].sort());
+	const selectedTags = new SvelteSet<string>();
+
+	let filters: Filters.FilterRow[] = $derived([
+		{
+			id: 'mode',
+			type: 'single',
+			label: 'Mode',
+			options: [
+				{ value: 'all', label: 'All' },
+				{ value: 'external', label: 'External' },
+				{ value: 'managed', label: 'Managed' },
+			],
+			selected: 'all',
+			default: 'all',
+		},
+		...(allTags.length > 0
+			? [
+					{
+						id: 'tags',
+						type: 'multi' as const,
+						label: 'Tags',
+						options: allTags.map((t) => ({ value: t, label: t })),
+						selected: selectedTags,
+					},
+				]
+			: []),
+	]);
+
+	const filtered = $derived.by(() => {
+		let list = toolsStore.list;
+		const q = query.trim().toLowerCase();
+		if (q) list = list.filter((t) => t.name.toLowerCase().includes(q));
+		for (const filter of filters) {
+			if (filter.id === 'mode') {
+				list = list.filter((t) => (filter.selected === 'all' ? true : t.mode === filter.selected));
+			} else if (filter.id === 'tags' && filter.type === 'multi') {
+				if (filter.selected.size > 0)
+					list = list.filter((t) => t.tags?.some((tag) => filter.selected.has(tag)));
+			}
+		}
+		return list;
+	});
+
+	$effect.root(() => {
+		toolsStore.hydrate(data.tools);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({ module: 'Tools', pages: [] });
+	});
+</script>
+
+<svelte:head>
+	<title>Tools - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-400">
+	<FilterList
+		creationLink={resolve('/workloads/tools/new')}
+		itemCount={toolsStore.list.length}
+		itemIcon={Toolbox}
+		itemKey={(m) => m.id}
+		itemName="tool"
+		itemNamePlural="tools"
+		items={filtered}
+		bind:query
+		bind:filters
+	>
+		{#snippet itemRenderer(tool)}
+			<ToolCard {selectedTags} {tool} />
+		{/snippet}
+	</FilterList>
+</div>

+ 13 - 0
src/routes/workloads/tools/[id]/+page.server.ts

@@ -0,0 +1,13 @@
+import { error } from '@sveltejs/kit';
+
+import * as toolsApi from '$lib/api/tools';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const tool = await toolsApi.get(locals.workspaceId!, params.id, fetch).catch(() => null);
+	if (!tool) error(404);
+	return { tool };
+};
+
+export { load };

+ 177 - 0
src/routes/workloads/tools/[id]/+page.svelte

@@ -0,0 +1,177 @@
+<script lang="ts">
+	import { Send, SquarePen } from '@lucide/svelte';
+
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import * as toolsApi from '$lib/api/tools';
+	import { Card } from '$lib/components/common/card';
+	import { ConfirmDialog } from '$lib/components/common/confirmation';
+	import { Icon } from '$lib/components/common/icon';
+	import { MoreMenu } from '$lib/components/common/more-menu';
+	import {
+		PropertyTable,
+		toAuthProps,
+		toDeploymentProps,
+	} from '$lib/components/common/property-table';
+	import { toInfoProps } from '$lib/components/common/property-table/types';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import { Button } from '$lib/components/controls/button';
+	import * as DropdownMenu from '$lib/components/controls/dropdown-menu';
+	import { ManagementTabs } from '$lib/components/management';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { toolsStore } from '$lib/stores/tools.svelte';
+	import { toExternalToolCR, toManagedToolCR } from '$lib/types/custom-resources';
+	import type { ExternalTool, ManagedTool } from '$lib/types/entities';
+	import type { Changes, CoreResources } from '$lib/types/workspace';
+	import * as yaml from '$lib/yaml';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	$effect.root(() => {
+		toolsStore.upsert(data.tool);
+		toolsStore.refreshStatus(data.tool.id);
+	});
+
+	const tool = $derived(toolsStore.get(data.tool.id) ?? data.tool);
+	const status = $derived(toolsStore.getStatus(tool.id));
+
+	const infoProps = $derived(toInfoProps(tool));
+	const configProps = $derived.by(() => {
+		const props: Record<string, string | number | string[]> = {};
+		if (tool.mode === 'external') {
+			const ext = tool as ExternalTool;
+			props['URL'] = ext.endpoint;
+			Object.assign(props, toAuthProps(ext.auth));
+		}
+		props['Transport'] = { sse: 'Server-Side Events', http: 'Streamable HTTP' }[tool.transport];
+		return props;
+	});
+	const deploymentProps = $derived.by(() => {
+		if (tool.mode !== 'managed') return {};
+		const man = tool as ManagedTool;
+		return toDeploymentProps(man.runtime);
+	});
+	const cr = $derived.by(() => {
+		if (tool.mode === 'managed') return yaml.stringify(toManagedToolCR(tool as ManagedTool));
+		else if (tool.mode === 'external')
+			return yaml.stringify(toExternalToolCR(tool as ExternalTool));
+		else return undefined;
+	});
+	let deleteOpen = $state(false);
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Tools',
+			pages: [{ name: tool.name, href: resolve('/workloads/tools/[id]', { id: tool.id }) }],
+		});
+	});
+
+	const onTest = () => {
+		goto(resolve(`/utilities/testing/tool?tool=${tool.id}`));
+	};
+
+	const onDeploy = () => {
+		toolsStore.update(tool);
+	};
+
+	const onDelete = async () => {
+		await toolsStore.remove(tool.id);
+		goto(resolve('/workloads/tools'));
+	};
+
+	const fetchResources = async (): Promise<CoreResources> => {
+		return toolsApi.getResources(data.workspaceId!, tool.id);
+	};
+
+	const fetchChanges = async (): Promise<Changes> => {
+		return toolsApi.getChanges(data.workspaceId!, tool.id);
+	};
+</script>
+
+<svelte:head>
+	<title>{tool.name} - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-2xl p-6">
+	<!-- Page header -->
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div class="flex items-center gap-4">
+			<Icon icon={tool.icon} />
+			<div class="flex flex-1 flex-col overflow-hidden">
+				<div class="truncate text-lg font-medium text-white/75">{tool.name}</div>
+				<div class="text-xs text-white/50">
+					{tool.mode === 'external' ? 'External' : 'Managed'}
+				</div>
+			</div>
+		</div>
+		<div class="flex items-center gap-2">
+			{#if tool.status}
+				<StatusBadge status={tool.status || 'not-deployed'} />
+			{/if}
+			<Button
+				href={resolve('/workloads/tools/[id]/edit', { id: tool.id })}
+				size="sm"
+				variant="outline"
+			>
+				<SquarePen size={14} strokeWidth={2} />
+				Edit
+			</Button>
+			<MoreMenu ondelete={() => (deleteOpen = true)}>
+				{#snippet menu()}
+					{#if tool.status || tool.mode === 'external'}
+						<DropdownMenu.Item onclick={() => onTest()}>
+							<Send size={14} strokeWidth={2} />
+							Test
+						</DropdownMenu.Item>
+					{:else}
+						<DropdownMenu.Item onclick={() => onDeploy()}>
+							<Send size={14} strokeWidth={2} />
+							Deploy
+						</DropdownMenu.Item>
+					{/if}
+				{/snippet}
+			</MoreMenu>
+		</div>
+	</div>
+
+	<Card class="mb-4" title="Basic Information">
+		<PropertyTable properties={infoProps} />
+	</Card>
+
+	{#if tool.mode === 'external'}
+		<Card class="mb-8" title="Configuration">
+			<PropertyTable properties={configProps} />
+		</Card>
+	{/if}
+
+	{#if tool.mode === 'managed'}
+		<Card class="mb-8" title="Deployment">
+			<PropertyTable properties={deploymentProps} />
+		</Card>
+	{/if}
+
+	<ManagementTabs
+		conditions={status?.conditions}
+		{cr}
+		onloadresources={tool.mode === 'managed' ? fetchResources : undefined}
+		onloadchanges={fetchChanges}
+	/>
+</div>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting…"
+	title="Delete tool?"
+	variant="destructive"
+	onconfirm={() => onDelete()}
+	bind:open={deleteOpen}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{tool.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>

+ 13 - 0
src/routes/workloads/tools/[id]/edit/+page.server.ts

@@ -0,0 +1,13 @@
+import { error } from '@sveltejs/kit';
+
+import * as toolsApi from '$lib/api/tools';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const tool = await toolsApi.get(locals.workspaceId!, params.id, fetch).catch(() => null);
+	if (!tool) error(404);
+	return { tool };
+};
+
+export { load };

+ 52 - 0
src/routes/workloads/tools/[id]/edit/+page.svelte

@@ -0,0 +1,52 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import { ToolForm } from '$lib/components/tools/form';
+	import {
+		type ToolFormValue,
+		toToolFormValue,
+		validateToolFormValue,
+	} from '$lib/components/tools/form/types';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { toolsStore } from '$lib/stores/tools.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	$effect.root(() => {
+		toolsStore.upsert(data.tool);
+	});
+
+	const tool = $derived(toolsStore.get(data.tool.id) ?? data.tool);
+	const value = $derived(toToolFormValue(tool));
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Tools',
+			pages: [{ name: tool.name, href: resolve('/workloads/tools/[id]/edit', { id: tool.id }) }],
+		});
+	});
+
+	const onSave = async (formValue: ToolFormValue) => {
+		const { tool, errors } = validateToolFormValue(formValue);
+		if (errors.length > 0 || tool === undefined) {
+			return;
+		}
+		await toolsStore.update({ ...tool, id: data.tool.id });
+		goto(resolve('/workloads/tools/[id]', { id: data.tool.id }));
+	};
+</script>
+
+<svelte:head>
+	<title>Edit {tool.name} - LocoStack</title>
+</svelte:head>
+
+<ToolForm
+	cancelHref={resolve('/workloads/tools/[id]', { id: data.tool.id })}
+	{value}
+	workspaceId={data.workspaceId!}
+	onsubmit={(formValue) => onSave(formValue)}
+/>

+ 40 - 0
src/routes/workloads/tools/new/+page.svelte

@@ -0,0 +1,40 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import { ToolForm } from '$lib/components/tools/form';
+	import { type ToolFormValue, validateToolFormValue } from '$lib/components/tools/form/types';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { toolsStore } from '$lib/stores/tools.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Tools',
+			pages: [{ name: '[New Tool]', href: resolve('/workloads/tools/new') }],
+		});
+	});
+
+	const onSubmit = async (formValue: ToolFormValue) => {
+		const { tool, errors } = validateToolFormValue(formValue);
+		if (errors.length > 0 || tool === undefined) {
+			return;
+		}
+		await toolsStore.create(tool);
+		goto(resolve('/workloads/tools'));
+	};
+</script>
+
+<svelte:head>
+	<title>New Tool - LocoStack</title>
+</svelte:head>
+
+<ToolForm
+	cancelHref={resolve('/workloads/tools')}
+	workspaceId={data.workspaceId!}
+	onsubmit={(formValue) => onSubmit(formValue)}
+/>