refactor: Componentize McpServerCard
This commit is contained in:
parent
835c06e0d1
commit
dfd3031b17
|
|
@ -1,307 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Cable,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
X,
|
||||
ExternalLink
|
||||
} from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||
import {
|
||||
mcpGetHealthCheckState,
|
||||
mcpHasHealthCheck,
|
||||
mcpRunHealthCheck,
|
||||
type HealthCheckState
|
||||
} from '$lib/stores/mcp.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Props {
|
||||
server: MCPServerSettingsEntry;
|
||||
displayName: string;
|
||||
faviconUrl: string | null;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { server, displayName, faviconUrl, onToggle, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let healthState = $derived<HealthCheckState>(mcpGetHealthCheckState(server.id));
|
||||
let isHealthChecking = $derived(healthState.status === 'loading');
|
||||
let isConnected = $derived(healthState.status === 'success');
|
||||
let isError = $derived(healthState.status === 'error');
|
||||
let errorMessage = $derived(healthState.status === 'error' ? healthState.message : undefined);
|
||||
let tools = $derived(healthState.status === 'success' ? healthState.tools : []);
|
||||
let toolsCount = $derived(tools.length);
|
||||
|
||||
let isExpanded = $state(false);
|
||||
|
||||
let showDeleteDialog = $state(false);
|
||||
|
||||
let isEditing = $state(!server.url.trim());
|
||||
let editUrl = $state(server.url);
|
||||
let editHeaders = $state(server.headers || '');
|
||||
|
||||
let urlError = $derived.by(() => {
|
||||
if (!editUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(editUrl);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
let canSave = $derived(!urlError);
|
||||
|
||||
onMount(() => {
|
||||
if (!mcpHasHealthCheck(server.id) && server.enabled && server.url.trim()) {
|
||||
mcpRunHealthCheck(server);
|
||||
}
|
||||
});
|
||||
|
||||
function handleHealthCheck() {
|
||||
mcpRunHealthCheck(server);
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
editUrl = server.url;
|
||||
editHeaders = server.headers || '';
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (server.url.trim()) {
|
||||
editUrl = server.url;
|
||||
editHeaders = server.headers || '';
|
||||
isEditing = false;
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditing() {
|
||||
if (!canSave) return;
|
||||
onUpdate({
|
||||
url: editUrl.trim(),
|
||||
headers: editHeaders.trim() || undefined
|
||||
});
|
||||
isEditing = false;
|
||||
|
||||
if (server.enabled && editUrl.trim()) {
|
||||
setTimeout(() => mcpRunHealthCheck({ ...server, url: editUrl.trim() }), 100);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="!gap-4 bg-muted/30 p-4">
|
||||
{#if isEditing}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">Configure Server</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={cancelEditing}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<McpServerForm
|
||||
url={editUrl}
|
||||
headers={editHeaders}
|
||||
onUrlChange={(v) => (editUrl = v)}
|
||||
onHeadersChange={(v) => (editHeaders = v)}
|
||||
urlError={editUrl ? urlError : null}
|
||||
id={server.id}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={saveEditing}
|
||||
disabled={!canSave}
|
||||
aria-label="Save"
|
||||
>
|
||||
{server.url.trim() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="h-5 w-5 shrink-0 rounded"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<Cable class="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<p class="truncate font-medium">{displayName}</p>
|
||||
{#if server.url}
|
||||
<a
|
||||
href={server.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Open server URL"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if isHealthChecking}
|
||||
<span class="shrink-0 text-xs text-muted-foreground">Checking...</span>
|
||||
{:else if isConnected}
|
||||
<span
|
||||
class="shrink-0 rounded bg-green-500/15 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-500"
|
||||
>Connected</span
|
||||
>
|
||||
{:else if isError}
|
||||
<span class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>Error</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Switch checked={server.enabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isError && errorMessage}
|
||||
<p class="mt-3 text-xs text-destructive">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#if tools.length === 0 && server.url.trim()}
|
||||
<div class="mt-3 flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={startEditing}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={handleHealthCheck}
|
||||
disabled={isHealthChecking}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
|
||||
onclick={() => (showDeleteDialog = true)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tools.length > 0}
|
||||
<Collapsible.Root bind:open={isExpanded}>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Collapsible.Trigger
|
||||
class="flex flex-1 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
<span>{toolsCount} tools available · Show details</span>
|
||||
</Collapsible.Trigger>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={startEditing}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={handleHealthCheck}
|
||||
disabled={isHealthChecking}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
|
||||
onclick={() => (showDeleteDialog = true)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible.Content class="mt-2">
|
||||
<div class="max-h-64 space-y-3 overflow-y-auto">
|
||||
{#each tools as tool (tool.name)}
|
||||
<div>
|
||||
<Badge variant="secondary">{tool.name}</Badge>
|
||||
{#if tool.description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showDeleteDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Server</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
|
||||
undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
|
||||
onclick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
|
||||
import {
|
||||
mcpGetHealthCheckState,
|
||||
mcpHasHealthCheck,
|
||||
mcpRunHealthCheck,
|
||||
type HealthCheckState
|
||||
} from '$lib/stores/mcp.svelte';
|
||||
import McpServerCardHeader from './McpServerCardHeader.svelte';
|
||||
import McpServerCardActions from './McpServerCardActions.svelte';
|
||||
import McpServerCardToolsList from './McpServerCardToolsList.svelte';
|
||||
import McpServerCardEditForm from './McpServerCardEditForm.svelte';
|
||||
import McpServerCardDeleteDialog from './McpServerCardDeleteDialog.svelte';
|
||||
|
||||
interface Props {
|
||||
server: MCPServerSettingsEntry;
|
||||
displayName: string;
|
||||
faviconUrl: string | null;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { server, displayName, faviconUrl, onToggle, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let healthState = $derived<HealthCheckState>(mcpGetHealthCheckState(server.id));
|
||||
let isHealthChecking = $derived(healthState.status === 'loading');
|
||||
let isConnected = $derived(healthState.status === 'success');
|
||||
let isError = $derived(healthState.status === 'error');
|
||||
let errorMessage = $derived(healthState.status === 'error' ? healthState.message : undefined);
|
||||
let tools = $derived(healthState.status === 'success' ? healthState.tools : []);
|
||||
|
||||
let isEditing = $state(!server.url.trim());
|
||||
let showDeleteDialog = $state(false);
|
||||
let editFormRef: McpServerCardEditForm | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
if (!mcpHasHealthCheck(server.id) && server.enabled && server.url.trim()) {
|
||||
mcpRunHealthCheck(server);
|
||||
}
|
||||
});
|
||||
|
||||
function handleHealthCheck() {
|
||||
mcpRunHealthCheck(server);
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
editFormRef?.setInitialValues(server.url, server.headers || '');
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (server.url.trim()) {
|
||||
isEditing = false;
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditing(url: string, headers: string) {
|
||||
onUpdate({
|
||||
url: url,
|
||||
headers: headers || undefined
|
||||
});
|
||||
isEditing = false;
|
||||
|
||||
if (server.enabled && url) {
|
||||
setTimeout(() => mcpRunHealthCheck({ ...server, url }), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="!gap-4 bg-muted/30 p-4">
|
||||
{#if isEditing}
|
||||
<McpServerCardEditForm
|
||||
bind:this={editFormRef}
|
||||
serverId={server.id}
|
||||
serverUrl={server.url}
|
||||
onSave={saveEditing}
|
||||
onCancel={cancelEditing}
|
||||
/>
|
||||
{:else}
|
||||
<McpServerCardHeader
|
||||
{displayName}
|
||||
{faviconUrl}
|
||||
serverUrl={server.url}
|
||||
enabled={server.enabled}
|
||||
{isHealthChecking}
|
||||
{isConnected}
|
||||
{isError}
|
||||
{onToggle}
|
||||
/>
|
||||
|
||||
{#if isError && errorMessage}
|
||||
<p class="mt-3 text-xs text-destructive">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#if tools.length === 0 && server.url.trim()}
|
||||
<div class="mt-3 flex items-center justify-end gap-1">
|
||||
<McpServerCardActions
|
||||
{isHealthChecking}
|
||||
onEdit={startEditing}
|
||||
onRefresh={handleHealthCheck}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tools.length > 0}
|
||||
<McpServerCardToolsList
|
||||
{tools}
|
||||
{isHealthChecking}
|
||||
onEdit={startEditing}
|
||||
onRefresh={handleHealthCheck}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<McpServerCardDeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
{displayName}
|
||||
onOpenChange={(open) => (showDeleteDialog = open)}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
isHealthChecking: boolean;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={onRefresh}
|
||||
disabled={isHealthChecking}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
|
||||
onclick={onDelete}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
displayName: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open {onOpenChange}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Server</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
|
||||
undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
|
||||
onclick={onConfirm}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
serverUrl: string;
|
||||
onSave: (url: string, headers: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { serverId, serverUrl, onSave, onCancel }: Props = $props();
|
||||
|
||||
let editUrl = $state(serverUrl);
|
||||
let editHeaders = $state('');
|
||||
|
||||
let urlError = $derived.by(() => {
|
||||
if (!editUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(editUrl);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
let canSave = $derived(!urlError);
|
||||
|
||||
function handleSave() {
|
||||
if (!canSave) return;
|
||||
onSave(editUrl.trim(), editHeaders.trim());
|
||||
}
|
||||
|
||||
export function setInitialValues(url: string, headers: string) {
|
||||
editUrl = url;
|
||||
editHeaders = headers;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium">Configure Server</p>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onCancel} aria-label="Cancel">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<McpServerForm
|
||||
url={editUrl}
|
||||
headers={editHeaders}
|
||||
onUrlChange={(v) => (editUrl = v)}
|
||||
onHeadersChange={(v) => (editHeaders = v)}
|
||||
urlError={editUrl ? urlError : null}
|
||||
id={serverId}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button variant="default" size="sm" onclick={handleSave} disabled={!canSave} aria-label="Save">
|
||||
{serverUrl.trim() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { Cable, ExternalLink } from '@lucide/svelte';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
interface Props {
|
||||
displayName: string;
|
||||
faviconUrl: string | null;
|
||||
serverUrl: string;
|
||||
enabled: boolean;
|
||||
isHealthChecking: boolean;
|
||||
isConnected: boolean;
|
||||
isError: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
displayName,
|
||||
faviconUrl,
|
||||
serverUrl,
|
||||
enabled,
|
||||
isHealthChecking,
|
||||
isConnected,
|
||||
isError,
|
||||
onToggle
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="h-5 w-5 shrink-0 rounded"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<Cable class="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<p class="truncate font-medium">{displayName}</p>
|
||||
{#if serverUrl}
|
||||
<a
|
||||
href={serverUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Open server URL"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if isHealthChecking}
|
||||
<span class="shrink-0 text-xs text-muted-foreground">Checking...</span>
|
||||
{:else if isConnected}
|
||||
<span
|
||||
class="shrink-0 rounded bg-green-500/15 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-500"
|
||||
>Connected</span
|
||||
>
|
||||
{:else if isError}
|
||||
<span class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>Error</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import McpServerCardActions from './McpServerCardActions.svelte';
|
||||
|
||||
interface Tool {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tools: Tool[];
|
||||
isHealthChecking: boolean;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { tools, isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
|
||||
|
||||
let isExpanded = $state(false);
|
||||
let toolsCount = $derived(tools.length);
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={isExpanded}>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Collapsible.Trigger
|
||||
class="flex flex-1 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
<span>{toolsCount} tools available · Show details</span>
|
||||
</Collapsible.Trigger>
|
||||
<McpServerCardActions {isHealthChecking} {onEdit} {onRefresh} {onDelete} />
|
||||
</div>
|
||||
<Collapsible.Content class="mt-2">
|
||||
<div class="max-h-64 space-y-3 overflow-y-auto">
|
||||
{#each tools as tool (tool.name)}
|
||||
<div>
|
||||
<Badge variant="secondary">{tool.name}</Badge>
|
||||
{#if tool.description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { default as McpServerCard } from './McpServerCard.svelte';
|
||||
export { default as McpServerCardHeader } from './McpServerCardHeader.svelte';
|
||||
export { default as McpServerCardActions } from './McpServerCardActions.svelte';
|
||||
export { default as McpServerCardToolsList } from './McpServerCardToolsList.svelte';
|
||||
export { default as McpServerCardEditForm } from './McpServerCardEditForm.svelte';
|
||||
export { default as McpServerCardDeleteDialog } from './McpServerCardDeleteDialog.svelte';
|
||||
Loading…
Reference in New Issue