it0/it0-web-admin/src/app/(admin)/agent-config/skills/page.tsx

554 lines
18 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Skill {
id: string;
name: string;
description: string;
category: 'inspection' | 'deployment' | 'maintenance' | 'security' | 'monitoring' | 'custom';
script: string;
tags: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
}
interface SkillFormData {
name: string;
description: string;
category: Skill['category'];
script: string;
tags: string;
enabled: boolean;
}
interface SkillsResponse {
data: Skill[];
total: number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_QUERY_KEY = ['skills'] as const;
const CATEGORIES: { label: string; value: Skill['category'] }[] = [
{ label: 'Inspection', value: 'inspection' },
{ label: 'Deployment', value: 'deployment' },
{ label: 'Maintenance', value: 'maintenance' },
{ label: 'Security', value: 'security' },
{ label: 'Monitoring', value: 'monitoring' },
{ label: 'Custom', value: 'custom' },
];
const CATEGORY_STYLES: Record<Skill['category'], string> = {
inspection: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
deployment: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
maintenance: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
security: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
monitoring: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
custom: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
};
const EMPTY_FORM: SkillFormData = {
name: '',
description: '',
category: 'custom',
script: '',
tags: '',
enabled: true,
};
// ---------------------------------------------------------------------------
// Category badge
// ---------------------------------------------------------------------------
function CategoryBadge({ category }: { category: Skill['category'] }) {
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
CATEGORY_STYLES[category],
)}
>
{category}
</span>
);
}
// ---------------------------------------------------------------------------
// Enabled / Disabled badge
// ---------------------------------------------------------------------------
function EnabledBadge({ enabled }: { enabled: boolean }) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
)}
>
{enabled ? 'Enabled' : 'Disabled'}
</span>
);
}
// ---------------------------------------------------------------------------
// Skill form dialog
// ---------------------------------------------------------------------------
function SkillDialog({
open,
title,
form,
errors,
saving,
onClose,
onChange,
onSubmit,
}: {
open: boolean;
title: string;
form: SkillFormData;
errors: Partial<Record<keyof SkillFormData, string>>;
saving: boolean;
onClose: () => void;
onChange: (field: keyof SkillFormData, value: string | boolean) => void;
onSubmit: () => void;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* dialog */}
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4">{title}</h2>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
{/* name */}
<div>
<label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => onChange('name', e.target.value)}
className={cn(
'w-full px-3 py-2 rounded-md border bg-background text-sm',
errors.name ? 'border-destructive' : 'border-input',
)}
placeholder="check-disk-usage"
/>
{errors.name && (
<p className="text-xs text-destructive mt-1">{errors.name}</p>
)}
</div>
{/* description */}
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={form.description}
onChange={(e) => onChange('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
placeholder="What does this skill do..."
/>
</div>
{/* category */}
<div>
<label className="block text-sm font-medium mb-1">Category</label>
<select
value={form.category}
onChange={(e) => onChange('category', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
>
{CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
{/* script */}
<div>
<label className="block text-sm font-medium mb-1">Script</label>
<textarea
value={form.script}
onChange={(e) => onChange('script', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm font-mono min-h-[200px] resize-y"
placeholder={"#!/bin/bash\n# Skill script content..."}
/>
</div>
{/* tags */}
<div>
<label className="block text-sm font-medium mb-1">Tags</label>
<input
type="text"
value={form.tags}
onChange={(e) => onChange('tags', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="disk, health, linux (comma-separated)"
/>
</div>
{/* enabled toggle */}
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => onChange('enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary" />
</label>
<span className="text-sm font-medium">Enabled</span>
</div>
</div>
{/* actions */}
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving}
>
Cancel
</button>
<button
type="button"
onClick={onSubmit}
disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteDialog({
open,
skillName,
deleting,
onClose,
onConfirm,
}: {
open: boolean;
skillName: string;
deleting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Skill</h2>
<p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{skillName}</strong>? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting}
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export default function SkillsPage() {
const queryClient = useQueryClient();
// State ----------------------------------------------------------------
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Skill | null>(null);
const [form, setForm] = useState<SkillFormData>({ ...EMPTY_FORM });
const [errors, setErrors] = useState<Partial<Record<keyof SkillFormData, string>>>({});
// Queries --------------------------------------------------------------
const { data, isLoading, error } = useQuery({
queryKey: SKILL_QUERY_KEY,
queryFn: () => apiClient<SkillsResponse>('/api/v1/agent/skills'),
});
const skills = data?.data ?? [];
// Mutations ------------------------------------------------------------
const createMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiClient<Skill>('/api/v1/agent/skills', { method: 'POST', body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
closeDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
apiClient<Skill>(`/api/v1/agent/skills/${id}`, { method: 'PUT', body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
closeDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiClient<void>(`/api/v1/agent/skills/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SKILL_QUERY_KEY });
setDeleteTarget(null);
},
});
// Helpers --------------------------------------------------------------
const validate = useCallback((): boolean => {
const next: Partial<Record<keyof SkillFormData, string>> = {};
if (!form.name.trim()) next.name = 'Name is required';
setErrors(next);
return Object.keys(next).length === 0;
}, [form]);
const closeDialog = useCallback(() => {
setDialogOpen(false);
setEditingSkill(null);
setForm({ ...EMPTY_FORM });
setErrors({});
}, []);
const openAdd = useCallback(() => {
setEditingSkill(null);
setForm({ ...EMPTY_FORM });
setErrors({});
setDialogOpen(true);
}, []);
const openEdit = useCallback((skill: Skill) => {
setEditingSkill(skill);
setForm({
name: skill.name,
description: skill.description ?? '',
category: skill.category,
script: skill.script ?? '',
tags: (skill.tags ?? []).join(', '),
enabled: skill.enabled,
});
setErrors({});
setDialogOpen(true);
}, []);
const handleChange = useCallback(
(field: keyof SkillFormData, value: string | boolean) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
},
[],
);
const handleSubmit = useCallback(() => {
if (!validate()) return;
const body: Record<string, unknown> = {
name: form.name.trim(),
description: form.description.trim(),
category: form.category,
script: form.script,
tags: form.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
enabled: form.enabled,
};
if (editingSkill) {
updateMutation.mutate({ id: editingSkill.id, body });
} else {
createMutation.mutate(body);
}
}, [form, editingSkill, validate, createMutation, updateMutation]);
const isSaving = createMutation.isPending || updateMutation.isPending;
// Render ---------------------------------------------------------------
return (
<div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold">Skills</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage Claude Code skills for the AI agent
</p>
</div>
<button
onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
>
Add Skill
</button>
</div>
{/* Error state */}
{error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load skills: {(error as Error).message}
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center">
Loading skills...
</div>
)}
{/* Empty state */}
{!isLoading && !error && skills.length === 0 && (
<div className="text-center py-12">
<p className="text-sm text-muted-foreground mb-4">
No skills configured yet. Add your first skill to get started.
</p>
<button
onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
>
Add Skill
</button>
</div>
)}
{/* Skills grid */}
{!isLoading && !error && skills.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map((skill) => (
<div
key={skill.id}
className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow"
>
{/* Header: name + enabled badge */}
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-sm">{skill.name}</h3>
<EnabledBadge enabled={skill.enabled} />
</div>
{/* Description */}
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
{skill.description || 'No description'}
</p>
{/* Category badge */}
<div className="mt-2">
<CategoryBadge category={skill.category} />
</div>
{/* Tags */}
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{skill.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs rounded-full bg-muted"
>
{tag}
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(skill)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
>
Edit
</button>
<button
onClick={() => setDeleteTarget(skill)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Add / Edit dialog */}
<SkillDialog
open={dialogOpen}
title={editingSkill ? 'Edit Skill' : 'Add Skill'}
form={form}
errors={errors}
saving={isSaving}
onClose={closeDialog}
onChange={handleChange}
onSubmit={handleSubmit}
/>
{/* Delete confirmation dialog */}
<DeleteDialog
open={!!deleteTarget}
skillName={deleteTarget?.name ?? ''}
deleting={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
/>
</div>
);
}