Files
kandev/apps/web/components/settings/settings-app-sidebar.tsx
T
2026-01-13 00:45:10 +00:00

465 lines
18 KiB
TypeScript

'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
IconSettings,
IconFolder,
IconServer,
IconRobot,
IconChevronRight,
IconCpu,
IconPlug,
IconPuzzle,
} from '@tabler/icons-react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarMenuAction,
SidebarHeader,
} from '@kandev/ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@kandev/ui/collapsible';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import { listEnvironmentsAction } from '@/app/actions/environments';
import { listExecutorsAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Environment, Executor } from '@/lib/types/http';
import { getBackendConfig } from '@/lib/config';
import type { Agent } from '@/lib/types/http';
import { useAppStore } from '@/components/state-provider';
export function SettingsAppSidebar() {
const pathname = usePathname();
const workspaces = useAppStore((state) => state.workspaces.items);
const agentProfilesVersion = useAppStore((state) => state.agentProfiles.version);
const fallbackEnvironments = React.useMemo<Environment[]>(
() =>
SETTINGS_DATA.environments.map((env) => ({
id: env.id,
name: env.name,
kind: env.kind,
worktree_root: env.worktreeRoot,
image_tag: env.imageTag,
dockerfile: env.dockerfile,
build_config: env.buildConfig
? {
base_image: env.buildConfig.baseImage,
install_agents: env.buildConfig.installAgents.join(','),
}
: undefined,
created_at: '',
updated_at: '',
})),
[]
);
const fallbackExecutors = React.useMemo<Executor[]>(
() =>
SETTINGS_DATA.executors.map((executor) => ({
id: executor.id,
name: executor.name,
type: executor.type,
status: executor.status,
is_system: executor.isSystem,
config: executor.config,
created_at: '',
updated_at: '',
})),
[]
);
const [environments, setEnvironments] = React.useState<Environment[]>(fallbackEnvironments);
const [executors, setExecutors] = React.useState<Executor[]>(fallbackExecutors);
const [agents, setAgents] = React.useState<Agent[]>([]);
React.useEffect(() => {
const client = getWebSocketClient();
const envFallback = () => fallbackEnvironments;
const execFallback = () => fallbackExecutors;
if (client) {
client
.request<{ environments: Environment[] }>('environment.list', {})
.then((resp) => setEnvironments(resp.environments))
.catch(() => setEnvironments(envFallback()));
client
.request<{ executors: Executor[] }>('executor.list', {})
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors(execFallback()));
return;
}
listEnvironmentsAction()
.then((resp) => setEnvironments(resp.environments))
.catch(() => setEnvironments(envFallback()));
listExecutorsAction()
.then((resp) => setExecutors(resp.executors))
.catch(() => setExecutors(execFallback()));
}, [fallbackEnvironments, fallbackExecutors]);
React.useEffect(() => {
const client = getWebSocketClient();
if (!client) {
return;
}
const unsubscribeEnvironmentCreated = client.on('environment.created', (message) => {
setEnvironments((prev) => {
const exists = prev.some((env) => env.id === message.payload.id);
const next = {
id: message.payload.id,
name: message.payload.name,
kind: message.payload.kind,
worktree_root: message.payload.worktree_root,
image_tag: message.payload.image_tag,
dockerfile: message.payload.dockerfile,
build_config: message.payload.build_config,
created_at: message.payload.created_at ?? '',
updated_at: message.payload.updated_at ?? '',
};
return exists ? prev.map((env) => (env.id === next.id ? next : env)) : [next, ...prev];
});
});
const unsubscribeEnvironmentUpdated = client.on('environment.updated', (message) => {
setEnvironments((prev) =>
prev.map((env) =>
env.id === message.payload.id
? {
...env,
name: message.payload.name,
kind: message.payload.kind,
worktree_root: message.payload.worktree_root,
image_tag: message.payload.image_tag,
dockerfile: message.payload.dockerfile,
build_config: message.payload.build_config,
updated_at: message.payload.updated_at ?? env.updated_at,
}
: env
)
);
});
const unsubscribeEnvironmentDeleted = client.on('environment.deleted', (message) => {
setEnvironments((prev) => prev.filter((env) => env.id !== message.payload.id));
});
const unsubscribeExecutorCreated = client.on('executor.created', (message) => {
setExecutors((prev) => {
const exists = prev.some((executor) => executor.id === message.payload.id);
const next = {
id: message.payload.id,
name: message.payload.name,
type: message.payload.type,
status: message.payload.status,
is_system: message.payload.is_system,
config: message.payload.config,
created_at: message.payload.created_at ?? '',
updated_at: message.payload.updated_at ?? '',
};
return exists
? prev.map((executor) => (executor.id === next.id ? next : executor))
: [next, ...prev];
});
});
const unsubscribeExecutorUpdated = client.on('executor.updated', (message) => {
setExecutors((prev) =>
prev.map((executor) =>
executor.id === message.payload.id
? {
...executor,
name: message.payload.name,
type: message.payload.type,
status: message.payload.status,
config: message.payload.config,
updated_at: message.payload.updated_at ?? executor.updated_at,
}
: executor
)
);
});
const unsubscribeExecutorDeleted = client.on('executor.deleted', (message) => {
setExecutors((prev) => prev.filter((executor) => executor.id !== message.payload.id));
});
return () => {
unsubscribeEnvironmentCreated();
unsubscribeEnvironmentUpdated();
unsubscribeEnvironmentDeleted();
unsubscribeExecutorCreated();
unsubscribeExecutorUpdated();
unsubscribeExecutorDeleted();
};
}, []);
React.useEffect(() => {
const { apiBaseUrl } = getBackendConfig();
const controller = new AbortController();
fetch(`${apiBaseUrl}/api/v1/agents`, {
cache: 'no-store',
signal: controller.signal,
})
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (data?.agents) {
setAgents(data.agents);
}
})
.catch(() => undefined);
return () => controller.abort();
}, [agentProfilesVersion, pathname]);
return (
<Sidebar variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg font-bold text-sm">
K
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">KanDev.ai</span>
<span className="truncate text-xs">Settings</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{/* General */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/general'}>
<Link href="/settings/general">
<IconSettings className="h-4 w-4" />
<span>General</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Workspaces */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Workspaces">
<Link href="/settings/workspace">
<IconFolder className="h-4 w-4" />
<span>Workspaces</span>
</Link>
</SidebarMenuButton>
{workspaces.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{workspaces.map((workspace) => {
const workspacePath = `/settings/workspace/${workspace.id}`;
const boardsPath = `${workspacePath}/boards`;
const repositoriesPath = `${workspacePath}/repositories`;
return (
<SidebarMenuSubItem key={workspace.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === workspacePath}
>
<Link href={workspacePath}>
<span>{workspace.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuSub className="ml-3">
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === repositoriesPath}
>
<Link href={repositoriesPath}>
<span>Repositories</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === boardsPath}
>
<Link href={boardsPath}>
<span>Boards</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Environments */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Environments">
<Link href="/settings/environments">
<IconServer className="h-4 w-4" />
<span>Environments</span>
</Link>
</SidebarMenuButton>
{environments.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{environments.map((env) => (
<SidebarMenuSubItem key={env.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/environment/${env.id}`}
>
<Link href={`/settings/environment/${env.id}`}>
<span>{env.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Executors */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Executors">
<Link href="/settings/executors">
<IconCpu className="h-4 w-4" />
<span>Executors</span>
</Link>
</SidebarMenuButton>
{executors.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{executors.map((executor) => (
<SidebarMenuSubItem key={executor.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/executor/${executor.id}`}
>
<Link href={`/settings/executor/${executor.id}`}>
<span>{executor.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Agents */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Agents">
<Link href="/settings/agents">
<IconRobot className="h-4 w-4" />
<span>Agents</span>
</Link>
</SidebarMenuButton>
{agents.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{agents.flatMap((agent) =>
agent.profiles.map((profile) => {
const encodedAgent = encodeURIComponent(agent.name);
const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`;
return (
<SidebarMenuSubItem key={profile.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === profilePath}
>
<Link href={profilePath}>
<span>{profile.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})
)}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Integrations */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/integrations'}>
<Link href="/settings/integrations">
<IconPlug className="h-4 w-4" />
<span>Integrations</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Plugins */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/plugins'}>
<Link href="/settings/plugins">
<IconPuzzle className="h-4 w-4" />
<span>Plugins</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}