Kaynağa Gözat

feat: add stack pages

Thomas Zhang 2 ay önce
ebeveyn
işleme
d3bd981ddf

+ 3 - 0
src/lib/components/resources/stack/index.ts

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

+ 140 - 0
src/lib/components/resources/stack/panel.svelte

@@ -0,0 +1,140 @@
+<script lang="ts">
+	import { ChevronDown, ChevronUp, StickyNote } from '@lucide/svelte';
+	import type { ClassValue } from 'svelte/elements';
+	import { slide } from 'svelte/transition';
+
+	import Empty from '$lib/components/common/empty/empty.svelte';
+	import { Button } from '$lib/components/controls/button';
+	import { Switch } from '$lib/components/controls/switch';
+	import * as Table from '$lib/components/controls/table';
+	import type { StackCR } from '$lib/types/custom-resources';
+	import type { RuntimeTemplate, Stack } from '$lib/types/entities';
+
+	interface StackComponent {
+		name: string;
+		label: string;
+		description: string;
+		getSpec: (stack: Stack) => { enabled: boolean; runtime?: RuntimeTemplate };
+		getStatus: (status: StackCR['status']) => string;
+	}
+
+	let {
+		class: className = '',
+		stack,
+		status,
+		active = $bindable(false),
+		onchange,
+	}: {
+		class?: ClassValue;
+		stack: Stack;
+		status?: StackCR['status'];
+		active?: boolean;
+		onchange?: (stack: Stack) => void;
+	} = $props();
+
+	const STACK_COMPONENTS: StackComponent[] = [
+		{
+			name: 'gateway',
+			label: 'Gateway',
+			description:
+				'Routing model calls, tool calls, and agent calls. Responsible for observability and cost management.',
+			getSpec: (stack: Stack) => stack.gateway || { enabled: false },
+			getStatus: (status: StackCR['status']) => status?.componentStatus?.gateway || 'disabled',
+		},
+		{
+			name: 'vectorStore',
+			label: 'Vector Store',
+			description:
+				'Storing embeddings and performing vector searches. Required by knowledge bases.',
+			getSpec: (stack: Stack) => stack.vectorStore || { enabled: false },
+			getStatus: (status: StackCR['status']) => status?.componentStatus?.vectorStore || 'disabled',
+		},
+		{
+			name: 'graphStore',
+			label: 'Graph Store',
+			description: 'Storing and querying graph data. Optionally required by knowledge bases.',
+			getSpec: (stack: Stack) => stack.graphStore || { enabled: false },
+			getStatus: (status: StackCR['status']) => status?.componentStatus?.graphStore || 'disabled',
+		},
+		{
+			name: 'database',
+			label: 'Database',
+			description: 'Storing application data. Required by some gateway and observability features.',
+			getSpec: (stack: Stack) => stack.database || { enabled: false },
+			getStatus: (status: StackCR['status']) => status?.componentStatus?.database || 'disabled',
+		},
+		{
+			name: 'observability',
+			label: 'Observability',
+			description: 'Monitoring and tracing agent calls.',
+			getSpec: (stack: Stack) => stack.observability || { enabled: false },
+			getStatus: (status: StackCR['status']) =>
+				status?.componentStatus?.observability || 'disabled',
+		},
+	];
+
+	const onToggleComponent = (component: StackComponent) => {
+		const enabled = component.getStatus(status) !== 'disabled';
+		stack = {
+			...stack,
+			[component.name]: {
+				...component.getSpec(stack),
+				enabled: !enabled,
+			},
+		};
+		onchange?.(stack);
+	};
+</script>
+
+<div class={['overflow-hidden rounded-md border border-white/10', className]}>
+	<div class="bg-white/5">
+		<div class="flex items-center justify-between p-2">
+			<div class="p-2 font-thin">{stack.name}</div>
+			<div class="flex items-center gap-1">
+				<Button size="icon-sm" variant="ghost" onclick={() => (active = !active)}>
+					{#if active}
+						<ChevronUp size={14} />
+					{:else}
+						<ChevronDown size={14} />
+					{/if}
+				</Button>
+			</div>
+		</div>
+	</div>
+	{#if active}
+		<div class="overflow-hidden" transition:slide={{ duration: 200 }}>
+			<Table.Root>
+				<Table.Body>
+					{#each STACK_COMPONENTS as component (component.name)}
+						<Table.Row>
+							<Table.Cell class="w-12">
+								<Switch
+									checked={component.getStatus(status) !== 'disabled'}
+									onCheckedChange={() => onToggleComponent(component)}
+								/>
+							</Table.Cell>
+							<Table.Cell class="w-32 text-right text-white/75">
+								{component.label}
+							</Table.Cell>
+							<Table.Cell>
+								{@const componentStatus = component.getStatus(status)}
+								{#if componentStatus === 'disabled'}
+									<span class="text-white/50">Disabled</span>
+								{:else}
+									<span>{component.getStatus(status)}</span>
+								{/if}
+							</Table.Cell>
+							<Table.Cell class="text-xs text-white/25">{component.description}</Table.Cell>
+						</Table.Row>
+					{:else}
+						<Empty icon={StickyNote}>
+							{#snippet caption()}
+								<div>No components</div>
+							{/snippet}
+						</Empty>
+					{/each}
+				</Table.Body>
+			</Table.Root>
+		</div>
+	{/if}
+</div>

+ 13 - 0
src/routes/resources/stacks/+page.server.ts

@@ -0,0 +1,13 @@
+import * as stacksApi from '$lib/api/stacks';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, url, locals }) => {
+	const { workspaceId } = locals;
+	if (!workspaceId) return { stacks: [] };
+	const stacks = await stacksApi.list(workspaceId, fetch);
+	const selectedstackId = url.searchParams.get('stack');
+	return { stacks, selectedstackId };
+};
+
+export { load };

+ 65 - 0
src/routes/resources/stacks/+page.svelte

@@ -0,0 +1,65 @@
+<script lang="ts">
+	import { Component } from '@lucide/svelte';
+
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+	import { page } from '$app/state';
+
+	import { StackPanel } from '$lib/components/resources/stack';
+	import { stacksStore } from '$lib/stores/stacks.svelte';
+	import type { Stack } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+	let stacks = $derived<Stack[]>(stacksStore.list);
+	let selectedstackId = $derived(data.selectedstackId);
+	let status = $derived(selectedstackId ? stacksStore.getStatus(selectedstackId) : undefined);
+
+	$effect.root(() => {
+		stacksStore.hydrate(data.stacks);
+	});
+
+	$effect(() => {
+		if (selectedstackId) {
+			const currentStack = page.url.searchParams.get('stack');
+			if (currentStack !== selectedstackId) {
+				goto(resolve(`/resources/stacks?stack=${selectedstackId}`));
+			}
+			stacksStore.refreshStatus(selectedstackId);
+		}
+	});
+
+	const onUpdateStack = (stack: Stack) => {
+		stacksStore.update(stack);
+	};
+</script>
+
+<svelte:head>
+	<title>Stacks - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl p-6">
+	<div class="mb-4 flex items-center justify-between">
+		<div class="flex items-center gap-2 text-xl font-semibold text-white/90">
+			<Component opacity={0.5} size={18} strokeWidth={2} />
+			Stacks
+		</div>
+	</div>
+
+	{#each stacks as stack (stack.id)}
+		<StackPanel
+			class="mb-2"
+			{stack}
+			{status}
+			bind:active={
+				() => stack.id === selectedstackId,
+				(value) => {
+					if (value) selectedstackId = stack.id;
+					else selectedstackId = undefined;
+				}
+			}
+			onchange={(stack) => onUpdateStack(stack)}
+		/>
+	{/each}
+</div>