resources
This commit is contained in:
+5
-1
@@ -6,13 +6,14 @@ import { useClient } from 'hooks/useClient';
|
||||
type GoogleStatus = {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
picture: string | null;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
export const GoogleAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, configured: false });
|
||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
|
||||
|
||||
const fetchStatus = () => {
|
||||
client
|
||||
@@ -76,6 +77,9 @@ export const GoogleAccount = () => {
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
||||
</div>
|
||||
{status.picture && (
|
||||
<img src={status.picture} alt="" className="h-9 w-9 rounded-full shrink-0" referrerPolicy="no-referrer" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Officer has access to your Google Calendar, Gmail, and other enabled services.
|
||||
|
||||
+105
-96
@@ -1,116 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { Circle, Server, Wrench } from 'lucide-react';
|
||||
import { Server, Plus, Check, X, Search } from 'lucide-react';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useResources, getResourceCategory, type Resource } from 'state/useResources';
|
||||
|
||||
type ResourceItemProps = {
|
||||
resource: Resource;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) => (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'}`}
|
||||
>
|
||||
{r.port ? (
|
||||
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{r.name}</div>
|
||||
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
||||
</div>
|
||||
<Circle className="h-2 w-2 shrink-0 mt-1.5 fill-green-500 text-green-500" />
|
||||
</button>
|
||||
);
|
||||
import { useResources, type ResourceSummary } from 'state/useResources';
|
||||
|
||||
export const ResourceSidebar = () => {
|
||||
const { resources, isLoading } = useResources();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||
const [showCatalog, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
|
||||
const { resources, isLoading, createResource } = useResources();
|
||||
const [selectedName, setSelectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const installed = resources?.filter((r: Resource) => r.installed) ?? [];
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const query = search.toLowerCase();
|
||||
const filtered = query
|
||||
? installed.filter(
|
||||
(r: Resource) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query),
|
||||
)
|
||||
: installed;
|
||||
const filtered = resources?.filter(
|
||||
(r: ResourceSummary) =>
|
||||
!query || r.name.toLowerCase().includes(query) || r.description?.toLowerCase().includes(query),
|
||||
) ?? [];
|
||||
|
||||
const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based');
|
||||
const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli');
|
||||
|
||||
const handleCatalog = () => {
|
||||
setSelectedId(null);
|
||||
setShowCatalog(true);
|
||||
const handleSelect = (dirName: string) => {
|
||||
setSelectedName(dirName);
|
||||
};
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setShowCatalog(false);
|
||||
setSelectedId(id);
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const result = await createResource(name);
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setSelectedName(result.dirName);
|
||||
} catch {
|
||||
// error handled by client
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="shrink-0 px-3 pt-3 pb-2 flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-duck-dark px-1">Resources</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCatalog}
|
||||
className="w-full text-xs cursor-pointer bg-duck-teal text-white hover:bg-duck-teal/90"
|
||||
>
|
||||
Catalog
|
||||
</Button>
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-duck-dark/70">Resources</span>
|
||||
{!creating && (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-duck-dark/50" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||
{isLoading && <p className="text-xs text-duck-dark/50 px-3 py-2">Loading...</p>}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 px-3 py-2">{search ? 'No matches' : 'No active resources'}</p>
|
||||
)}
|
||||
{apiBased.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<Server className="h-3 w-3 text-duck-teal" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
|
||||
{creating && (
|
||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(ev) => setNewName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
}
|
||||
}}
|
||||
placeholder="Resource name..."
|
||||
className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim()}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setCreating(false); setNewName(''); }}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search resources..."
|
||||
className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && <p className="text-xs text-duck-dark/50 px-4 py-2">Loading...</p>}
|
||||
{filtered.map((r: ResourceSummary) => (
|
||||
<button
|
||||
key={r.dirName}
|
||||
onClick={() => handleSelect(r.dirName)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
||||
selectedName === r.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0" />
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{r.name}</span>
|
||||
<span
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
r.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
|
||||
}`}
|
||||
>
|
||||
{r.scope}
|
||||
</span>
|
||||
</div>
|
||||
{apiBased.map((r: Resource) => (
|
||||
<ResourceItem
|
||||
key={r.id}
|
||||
resource={r}
|
||||
isActive={!showCatalog && selectedId === r.id}
|
||||
onSelect={() => handleSelect(r.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{r.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{r.description}</p>}
|
||||
</button>
|
||||
))}
|
||||
{!isLoading && resources && resources.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No resources found</p>
|
||||
)}
|
||||
{localCli.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<Wrench className="h-3 w-3 text-duck-dark/40" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">Local CLI</span>
|
||||
</div>
|
||||
{localCli.map((r: Resource) => (
|
||||
<ResourceItem
|
||||
key={r.id}
|
||||
resource={r}
|
||||
isActive={!showCatalog && selectedId === r.id}
|
||||
onSelect={() => handleSelect(r.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
{!isLoading && resources && resources.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { Server, Wrench, Loader2, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { toast } from 'sonner';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Pencil, Trash2, Plus, X, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useResources, getResourceCategory, type Resource, type PingResult } from 'state/useResources';
|
||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePiChat, EmbeddableChat } from 'officerdev';
|
||||
import { useResources, type ResourceDetail, type PingResult } from 'state/useResources';
|
||||
import { FrontmatterBlock } from '../../CapabilityPage';
|
||||
|
||||
type ConnectionSectionProps = {
|
||||
resource: Resource;
|
||||
const isSensitiveKey = (key: string) => /key|secret|password|token/i.test(key);
|
||||
|
||||
type ConfigEditorProps = {
|
||||
resourceName: string;
|
||||
config: Record<string, string>;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
|
||||
const { saveConnectionConfig, pingResource } = useResources();
|
||||
const [url, setUrl] = useState(resource.connectionConfig?.url ?? '');
|
||||
const [apiKey, setApiKey] = useState(resource.connectionConfig?.credentials?.apiKey ?? '');
|
||||
const [username, setUsername] = useState(resource.connectionConfig?.credentials?.username ?? '');
|
||||
const [password, setPassword] = useState(resource.connectionConfig?.credentials?.password ?? '');
|
||||
const ConfigEditor = ({ resourceName, config, onSaved }: ConfigEditorProps) => {
|
||||
const { saveConfig, pingResource } = useResources();
|
||||
const configEntries = Object.entries(config);
|
||||
const [fields, setFields] = useState<[string, string][]>(configEntries);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [pinging, setPinging] = useState(false);
|
||||
const [pingResult, setPingResult] = useState<PingResult | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFields(Object.entries(config));
|
||||
}, [config]);
|
||||
|
||||
const hasUrl = fields.some(([k, v]) => k === 'url' && v);
|
||||
|
||||
const handleAddField = () => {
|
||||
const key = newKey.trim();
|
||||
if (!key || fields.some(([k]) => k === key)) return;
|
||||
setFields([...fields, [key, newValue]]);
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
};
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
setFields(fields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleFieldValue = (index: number, value: string) => {
|
||||
setFields(fields.map((f, i) => (i === index ? [f[0]!, value] : f)));
|
||||
};
|
||||
|
||||
const handlePing = async () => {
|
||||
const url = fields.find(([k]) => k === 'url')?.[1];
|
||||
if (!url) return;
|
||||
setPinging(true);
|
||||
setPingResult(null);
|
||||
try {
|
||||
const result = await pingResource(resource.id, url);
|
||||
const result = await pingResource(resourceName, url);
|
||||
setPingResult(result);
|
||||
} catch {
|
||||
setPingResult({ reachable: false, latencyMs: null });
|
||||
@@ -48,74 +72,83 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const credentials =
|
||||
apiKey || username || password
|
||||
? { apiKey: apiKey || undefined, username: username || undefined, password: password || undefined }
|
||||
: undefined;
|
||||
await saveConnectionConfig(resource.id, { url, credentials });
|
||||
const oldKeys = Object.keys(config);
|
||||
const newKeys = new Set(fields.map(([k]) => k));
|
||||
const patch: Record<string, string | null> = {};
|
||||
for (const [key, value] of fields) {
|
||||
patch[key] = value;
|
||||
}
|
||||
for (const key of oldKeys) {
|
||||
if (!newKeys.has(key)) patch[key] = null;
|
||||
}
|
||||
await saveConfig(resourceName, patch);
|
||||
onSaved();
|
||||
toast.success('Configuration saved');
|
||||
} catch {
|
||||
toast.error('Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasCredentials = !!(
|
||||
resource.connectionConfig?.credentials?.apiKey || resource.connectionConfig?.credentials?.username
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-duck-dark mb-3">Connection</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-duck-dark/50 mb-1 block">Base URL</label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(ev) => setUrl(ev.target.value)}
|
||||
placeholder="http://127.0.0.1:64202"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{(hasCredentials || apiKey) && (
|
||||
<div>
|
||||
<label className="text-xs text-duck-dark/50 mb-1 block">API Key</label>
|
||||
<h3 className="text-sm font-semibold text-duck-dark mb-3">Configuration</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map(([key, value], index) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="text-xs text-duck-dark/50 w-28 shrink-0 truncate" title={key}>
|
||||
{key}
|
||||
</label>
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(ev) => setApiKey(ev.target.value)}
|
||||
placeholder="Optional"
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
value={value}
|
||||
onChange={(ev) => handleFieldValue(index, ev.target.value)}
|
||||
type={isSensitiveKey(key) ? 'password' : 'text'}
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-red-500 cursor-pointer"
|
||||
onClick={() => handleRemoveField(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(hasCredentials || username || password) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-duck-dark/50 mb-1 block">Username</label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(ev) => setUsername(ev.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-duck-dark/50 mb-1 block">Password</label>
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
placeholder="Optional"
|
||||
type="password"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging || !url} className="text-xs">
|
||||
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Test Connection
|
||||
<Input
|
||||
value={newKey}
|
||||
onChange={(ev) => setNewKey(ev.target.value)}
|
||||
placeholder="key"
|
||||
className="h-8 text-xs w-28 shrink-0"
|
||||
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
|
||||
/>
|
||||
<Input
|
||||
value={newValue}
|
||||
onChange={(ev) => setNewValue(ev.target.value)}
|
||||
placeholder="value"
|
||||
className="h-8 text-xs flex-1"
|
||||
onKeyDown={(ev) => ev.key === 'Enter' && handleAddField()}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 shrink-0 text-duck-dark/30 hover:text-duck-teal cursor-pointer"
|
||||
onClick={handleAddField}
|
||||
disabled={!newKey.trim()}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !url} className="text-xs">
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{hasUrl && (
|
||||
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging} className="text-xs cursor-pointer">
|
||||
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave} disabled={saving} className="text-xs cursor-pointer">
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
@@ -130,318 +163,187 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type LocalAvailabilitySectionProps = {
|
||||
resource: Resource;
|
||||
onRun: (command: string) => void;
|
||||
type ResourceChatProps = {
|
||||
detail: ResourceDetail;
|
||||
isNew?: boolean;
|
||||
onResponseEnd: () => void;
|
||||
};
|
||||
|
||||
type ResourceAction = 'install' | 'uninstall' | 'verify' | 'update' | 'manage';
|
||||
const ResourceChat = ({ detail, isNew, onResponseEnd }: ResourceChatProps) => {
|
||||
const promptFrontmatter = `<frontmatter>\nconfig file: ${detail.configPath}\nresource file: ${detail.filePath}\nguide: ${detail.guidePath}\n\nYou are helping configure a resource. Read the GUIDE.md for instructions on how to help. Read the RESOURCE.md for context about what this resource is. Write config values to the config.json file.\n</frontmatter>`;
|
||||
const defaultInput = isNew
|
||||
? 'Help me set up this new resource'
|
||||
: 'Help me configure this resource';
|
||||
|
||||
const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => {
|
||||
const { runCommand } = useResources();
|
||||
const [runningAction, setRunningAction] = useState<ResourceAction | null>(null);
|
||||
const pi = usePiChat(undefined, undefined, { replaceUrl: false });
|
||||
|
||||
const isSudo = (cmd: string) => cmd.trimStart().startsWith('sudo');
|
||||
const onResponseEndRef = useRef(onResponseEnd);
|
||||
onResponseEndRef.current = onResponseEnd;
|
||||
|
||||
const handleAction = async (action: ResourceAction, command: string) => {
|
||||
if (isSudo(command)) {
|
||||
onRun(command);
|
||||
return;
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !pi.isGenerating) {
|
||||
onResponseEndRef.current();
|
||||
}
|
||||
setRunningAction(action);
|
||||
try {
|
||||
const result = await runCommand(resource.id, action);
|
||||
if (result.exitCode === 0) {
|
||||
toast.success('Command completed successfully');
|
||||
} else {
|
||||
toast.error(result.output || `Command failed (exit code ${result.exitCode})`, { duration: 8000 });
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to run command');
|
||||
} finally {
|
||||
setRunningAction(null);
|
||||
}
|
||||
};
|
||||
wasGenerating.current = pi.isGenerating;
|
||||
}, [pi.isGenerating]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{resource.installed ? (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">Installed</span>
|
||||
</div>
|
||||
{resource.version && (
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.updateCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('update', resource.updateCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${runningAction === 'update' ? 'animate-spin' : ''}`} />
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">{resource.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!resource.version && resource.updateCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('update', resource.updateCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${runningAction === 'update' ? 'animate-spin' : ''}`} />
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{resource.verifyCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('verify', resource.verifyCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
{runningAction === 'verify' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
{resource.manageCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('manage', resource.manageCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
{runningAction === 'manage' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{resource.uninstallCommand && (
|
||||
<div className="flex items-center mt-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer text-red-500 hover:text-red-600 border-red-500/30 hover:border-red-500/50 hover:bg-red-500/5"
|
||||
onClick={() => handleAction('uninstall', resource.uninstallCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
{runningAction === 'uninstall' ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
|
||||
{resource.installCommand && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
onClick={() => handleAction('install', resource.installCommand!)}
|
||||
disabled={runningAction === 'install'}
|
||||
>
|
||||
{runningAction === 'install' ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CatalogCardProps = {
|
||||
resource: Resource;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
const CatalogCard = ({ resource: r, onSelect }: CatalogCardProps) => {
|
||||
const category = getResourceCategory(r);
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(r.id)}
|
||||
className="flex flex-col gap-2 p-4 rounded-lg border border-duck-dark/10 text-left cursor-pointer transition-colors hover:border-duck-teal/30 hover:bg-duck-teal/5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.port ? (
|
||||
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0" />
|
||||
) : (
|
||||
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-duck-dark truncate">{r.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/40 line-clamp-1">{r.subtitle}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] bg-duck-dark/5 text-duck-dark/40 rounded-full px-1.5 py-0.5">{r.type}</span>
|
||||
<span
|
||||
className={`text-[10px] rounded-full px-1.5 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/40'}`}
|
||||
>
|
||||
{category === 'api-based' ? 'API' : 'CLI'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceCatalog = () => {
|
||||
const { resources, isLoading } = useResources();
|
||||
const [, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||
const [, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const query = search.toLowerCase();
|
||||
const filtered =
|
||||
resources?.filter(
|
||||
(r: Resource) =>
|
||||
!r.installed &&
|
||||
(r.name.toLowerCase().includes(query) ||
|
||||
r.subtitle.toLowerCase().includes(query) ||
|
||||
r.description.toLowerCase().includes(query)),
|
||||
) ?? [];
|
||||
|
||||
const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based');
|
||||
const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli');
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
setShowCatalog(false);
|
||||
setSelectedId(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<h2 className="text-lg font-bold text-duck-dark mb-1">Resource Catalog</h2>
|
||||
<p className="text-sm text-duck-dark/40 mb-4">All available resources. Select one to configure.</p>
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-8 text-xs mb-4 max-w-xs"
|
||||
/>
|
||||
{isLoading && <p className="text-xs text-duck-dark/50">Loading...</p>}
|
||||
{apiBased.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Server className="h-3.5 w-3.5 text-duck-teal" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{apiBased.map((r: Resource) => (
|
||||
<CatalogCard key={r.id} resource={r} onSelect={handleSelect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{localCli.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wrench className="h-3.5 w-3.5 text-duck-dark/40" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-duck-dark/40">Local CLI</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{localCli.map((r: Resource) => (
|
||||
<CatalogCard key={r.id} resource={r} onSelect={handleSelect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceDetail = ({ resource }: { resource: Resource }) => {
|
||||
const category = getResourceCategory(resource);
|
||||
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
|
||||
const [confirmCommand, setConfirmCommand] = useState<string | null>(null);
|
||||
|
||||
const handleRun = (command: string) => {
|
||||
const isSudo = command.trimStart().startsWith('sudo');
|
||||
if (isSudo) {
|
||||
setConfirmCommand(command);
|
||||
} else {
|
||||
setRunCommand({ command });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{resource.port ? (
|
||||
<Server className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
) : (
|
||||
<Wrench className="h-4 w-4 text-duck-dark/40 shrink-0" />
|
||||
)}
|
||||
<h2 className="text-lg font-bold text-duck-dark">{resource.name}</h2>
|
||||
<span className="text-sm text-duck-dark/40">{resource.subtitle}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/50 rounded-full px-2 py-0.5">{resource.type}</span>
|
||||
<span
|
||||
className={`text-xs rounded-full px-2 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/50'}`}
|
||||
>
|
||||
{category === 'api-based' ? 'API Based' : 'Local CLI'}
|
||||
</span>
|
||||
{resource.port && (
|
||||
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
|
||||
|
||||
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
|
||||
<LocalAvailabilitySection resource={resource} onRun={handleRun} />
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
For this operation the script must be run with elevated privileges (sudo) on the host machine.
|
||||
<br />
|
||||
Not to worry, though, we wrote it and battle tested it ourselves.
|
||||
</p>
|
||||
<code className="text-sm font-mono bg-[#1a1a2e] text-[#e0e0e0] rounded-lg px-3 py-2 break-all">{confirmCommand}</code>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
|
||||
onClick={() => {
|
||||
if (confirmCommand) setRunCommand({ command: confirmCommand });
|
||||
setConfirmCommand(null);
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
<EmbeddableChat
|
||||
chat={pi}
|
||||
defaultInput={defaultInput}
|
||||
promptPrefix={promptFrontmatter}
|
||||
className="h-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Resources = () => {
|
||||
const { resources } = useResources();
|
||||
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||
const [showCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const [selectedName] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
const resource = selectedId ? resources?.find((r: Resource) => r.id === selectedId) : null;
|
||||
const { data: detail, refetch } = useQuery<ResourceDetail>({
|
||||
queryKey: ['RESOURCES', selectedName],
|
||||
queryFn: () => client.get<ResourceDetail>(`/server-settings/resources/${selectedName}`),
|
||||
enabled: !!selectedName,
|
||||
});
|
||||
|
||||
if (showCatalog || !resource) return <ResourceCatalog />;
|
||||
return <ResourceDetail resource={resource} />;
|
||||
useEffect(() => {
|
||||
if (selectedName) {
|
||||
setShowDetail(true);
|
||||
setEditing(false);
|
||||
}
|
||||
}, [selectedName]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedName) return;
|
||||
try {
|
||||
await client.delete(`/server-settings/resources/${selectedName}`);
|
||||
setDeleteConfirm(false);
|
||||
setEditing(false);
|
||||
await qc.invalidateQueries({ queryKey: ['RESOURCES'] });
|
||||
} catch {
|
||||
toast.error('Failed to delete resource');
|
||||
}
|
||||
};
|
||||
|
||||
const canDelete = detail && detail.scope === 'global' && !detail.filePath.includes('/seed/');
|
||||
|
||||
if (!selectedName || !detail) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/40">Select a resource to view its configuration</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Detail panel */}
|
||||
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowDetail(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-duck-dark/70 flex-1">{detail.name}</span>
|
||||
<span
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
detail.scope === 'global' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
|
||||
}`}
|
||||
>
|
||||
{detail.scope}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
className={`p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
||||
>
|
||||
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50'}`} />
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
{detail.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
||||
{detail.body && (
|
||||
<article className="skill-md mb-6">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{detail.body}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
)}
|
||||
<ConfigEditor
|
||||
key={selectedName}
|
||||
resourceName={selectedName}
|
||||
config={detail.config}
|
||||
onSaved={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat panel */}
|
||||
{editing && (
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail.name} — Chat</span>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
||||
</button>
|
||||
</div>
|
||||
<ResourceChat
|
||||
key={detail.filePath}
|
||||
detail={detail}
|
||||
isNew={isNew}
|
||||
onResponseEnd={() => {
|
||||
refetch();
|
||||
qc.invalidateQueries({ queryKey: ['RESOURCES'] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
||||
<DialogContent className="sm:max-w-md z-[700]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Resource</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{detail.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout, TerminalView, FileViewerView } from 'officerdev';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
|
||||
import { Resources } from './Resources';
|
||||
import { ResourceSidebar } from './ResourceSidebar';
|
||||
import {
|
||||
RUN_COMMAND_CHANNEL,
|
||||
ERROR_LOG_CHANNEL,
|
||||
type RunCommandState,
|
||||
type ErrorLogState,
|
||||
} from './run-command-channel';
|
||||
|
||||
const baseLayout: LayoutNode = {
|
||||
const layout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'resources-root',
|
||||
direction: 'horizontal',
|
||||
@@ -26,147 +15,11 @@ const baseLayout: LayoutNode = {
|
||||
],
|
||||
};
|
||||
|
||||
const splitLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'resources-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'resources-right-group',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'resources-terminal', appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const errorLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'resources-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'resources-right-group',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'resources-error-log', appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ResourceTerminalPanel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useClient();
|
||||
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
|
||||
const [, setErrorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
|
||||
const [session, setSession] = useState<{ id: string; command: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state && (!session || session.command !== state.command)) {
|
||||
setSession({ id: `res-cmd-${Date.now()}`, command: state.command });
|
||||
} else if (!state) {
|
||||
setSession(null);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const close = () => setState(null);
|
||||
|
||||
const onCommandDone = (exitCode: number, output: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['RESOURCES'] });
|
||||
if (exitCode === 0) {
|
||||
toast.success('Command completed successfully');
|
||||
setTimeout(() => setState(null), 2000);
|
||||
} else {
|
||||
const command = session?.command ?? 'unknown';
|
||||
const md = [
|
||||
`# Command Failed (exit code ${exitCode})`,
|
||||
'',
|
||||
'```',
|
||||
command,
|
||||
'```',
|
||||
'',
|
||||
'## Output',
|
||||
'',
|
||||
'```',
|
||||
output,
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
client.post('/server-settings/resources/error-log', { command, output, exitCode }).catch(() => {});
|
||||
|
||||
setState(null);
|
||||
setErrorLog({ content: md, fileName: 'error.md' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!state || !session) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
|
||||
<button
|
||||
onClick={close}
|
||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||
</button>
|
||||
</div>
|
||||
<TerminalView
|
||||
className="flex-1"
|
||||
sandboxed={false}
|
||||
command={session.command}
|
||||
sessionId={session.id}
|
||||
onCommandDone={onCommandDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorLogPanel = () => {
|
||||
const [errorLog, setErrorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
|
||||
|
||||
if (!errorLog) return null;
|
||||
|
||||
return (
|
||||
<FileViewerView
|
||||
filePath=""
|
||||
fileName={errorLog.fileName}
|
||||
content={errorLog.content}
|
||||
onClose={() => setErrorLog(null)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResourceSettings = () => {
|
||||
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
|
||||
const [errorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
|
||||
|
||||
const layout = useMemo(
|
||||
() => (errorLog ? errorLayout : runCommand ? splitLayout : baseLayout),
|
||||
[errorLog, runCommand],
|
||||
);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'resources-left': ResourceSidebar,
|
||||
'resources-right': Resources,
|
||||
'resources-terminal': ResourceTerminalPanel,
|
||||
'resources-error-log': ErrorLogPanel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
export type RunCommandState = {
|
||||
command: string;
|
||||
} | null;
|
||||
|
||||
export const RUN_COMMAND_CHANNEL = 'resource-settings:run-command';
|
||||
|
||||
export type ErrorLogState = {
|
||||
content: string;
|
||||
fileName: string;
|
||||
} | null;
|
||||
|
||||
export const ERROR_LOG_CHANNEL = 'resource-settings:error-log';
|
||||
Reference in New Issue
Block a user