mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
latest changes
This commit is contained in:
14 files changed
+1273
-181
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { useParams } from 'common'
|
||||
import { useCreateAgentMutation } from 'data/advisors/agents-query'
|
||||
import { useCreateAgentMutation, useCreateAgentTaskMutation } from 'data/advisors/agents-query'
|
||||
import { Bot, ShieldCheck, Zap, AlertTriangle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge, Button, Card, CardContent } from 'ui'
|
||||
import { getTaskPresetsForAgent } from './task-presets'
|
||||
|
||||
export interface AgentPreset {
|
||||
id: string
|
||||
@@ -69,6 +70,7 @@ Prioritize speed and clarity. Use bullet points and action items.`,
|
||||
export function AgentPresetsCards() {
|
||||
const { ref: projectRef } = useParams()
|
||||
const createMutation = useCreateAgentMutation(projectRef)
|
||||
const createTaskMutation = useCreateAgentTaskMutation(projectRef)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -77,7 +79,12 @@ export function AgentPresetsCards() {
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{AGENT_PRESETS.map((preset) => (
|
||||
<PresetCard key={preset.id} preset={preset} createMutation={createMutation} />
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
createMutation={createMutation}
|
||||
createTaskMutation={createTaskMutation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,9 +94,11 @@ export function AgentPresetsCards() {
|
||||
function PresetCard({
|
||||
preset,
|
||||
createMutation,
|
||||
createTaskMutation,
|
||||
}: {
|
||||
preset: AgentPreset
|
||||
createMutation: ReturnType<typeof useCreateAgentMutation>
|
||||
createTaskMutation: ReturnType<typeof useCreateAgentTaskMutation>
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const Icon = preset.icon
|
||||
@@ -97,14 +106,33 @@ function PresetCard({
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
const agent = await createMutation.mutateAsync({
|
||||
name: preset.name,
|
||||
summary: preset.summary,
|
||||
system_prompt: preset.system_prompt,
|
||||
tools: preset.tools,
|
||||
})
|
||||
toast.success(`"${preset.name}" agent created`)
|
||||
} catch (err) {
|
||||
|
||||
if (agent?.id) {
|
||||
const taskPresets = getTaskPresetsForAgent(preset.id)
|
||||
await Promise.all(
|
||||
taskPresets.map((tp) =>
|
||||
createTaskMutation.mutateAsync({
|
||||
agent_id: agent.id,
|
||||
name: tp.name,
|
||||
description: tp.description,
|
||||
schedule: tp.schedule,
|
||||
is_unique: tp.is_unique,
|
||||
enabled: tp.enabled,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const taskCount = getTaskPresetsForAgent(preset.id).length
|
||||
const taskMsg = taskCount > 0 ? ` with ${taskCount} scheduled task${taskCount !== 1 ? 's' : ''}` : ''
|
||||
toast.success(`"${preset.name}" agent created${taskMsg}`)
|
||||
} catch {
|
||||
toast.error('Failed to create agent')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
@@ -125,6 +153,9 @@ function PresetCard({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-auto pt-2">
|
||||
<Badge variant="default">{preset.tools.length} tools</Badge>
|
||||
<Badge variant="outline">
|
||||
{getTaskPresetsForAgent(preset.id).length} tasks
|
||||
</Badge>
|
||||
<Button type="default" size="tiny" loading={creating} onClick={handleCreate}>
|
||||
Create agent
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
useAdvisorAgentTasksQuery,
|
||||
useCreateAgentTaskMutation,
|
||||
useDeleteAgentTaskMutation,
|
||||
useExecuteAgentTaskMutation,
|
||||
useTaskConversationsQuery,
|
||||
useUpdateAgentTaskMutation,
|
||||
} from 'data/advisors/agents-query'
|
||||
import type { AdvisorAgentTask } from 'data/advisors/types'
|
||||
import {
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
Play,
|
||||
Plus,
|
||||
Trash,
|
||||
Edit,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
SheetTitle,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import * as z from 'zod'
|
||||
|
||||
const TaskFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
schedule: z.string().min(1, 'Schedule (cron) is required'),
|
||||
is_unique: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
interface AgentTasksListProps {
|
||||
agentId: string
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
export function AgentTasksList({ agentId, projectRef }: AgentTasksListProps) {
|
||||
const { data: allTasks } = useAdvisorAgentTasksQuery(projectRef)
|
||||
const tasks = (allTasks ?? []).filter((t) => t.agent_id === agentId)
|
||||
|
||||
const [showSheet, setShowSheet] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<AdvisorAgentTask | null>(null)
|
||||
const [deletingTask, setDeletingTask] = useState<AdvisorAgentTask | null>(null)
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null)
|
||||
|
||||
const updateMutation = useUpdateAgentTaskMutation(projectRef)
|
||||
const deleteMutation = useDeleteAgentTaskMutation(projectRef)
|
||||
const executeMutation = useExecuteAgentTaskMutation(projectRef)
|
||||
|
||||
return (
|
||||
<div className="bg-surface-100 border-t border-default px-6 py-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground-lighter uppercase tracking-wide">
|
||||
Scheduled Tasks
|
||||
</p>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={() => {
|
||||
setEditingTask(null)
|
||||
setShowSheet(true)
|
||||
}}
|
||||
>
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-xs text-foreground-muted py-2">
|
||||
No scheduled tasks yet. Add a task to automate this agent on a cron schedule.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded border border-default divide-y divide-default bg-surface-200">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id}>
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-foreground-lighter hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
setExpandedTaskId(expandedTaskId === task.id ? null : task.id)
|
||||
}
|
||||
>
|
||||
{expandedTaskId === task.id ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-foreground">{task.name}</span>
|
||||
{task.description && (
|
||||
<p className="text-xs text-foreground-lighter truncate max-w-md">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="default" className="font-mono text-xs shrink-0">
|
||||
<Calendar size={10} className="mr-1" />
|
||||
{task.schedule}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={task.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateMutation.mutate(
|
||||
{ taskId: task.id, enabled: checked },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
`Task ${checked ? 'enabled' : 'disabled'}`
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="text" className="px-1" icon={<MoreVertical size={14} />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setEditingTask(task)
|
||||
setShowSheet(true)
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<span>Edit task</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() =>
|
||||
executeMutation.mutate(task.id, {
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
'Task triggered — check run history for results'
|
||||
),
|
||||
onError: () => toast.error('Failed to execute task'),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Play size={12} />
|
||||
<span>Run now</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setDeletingTask(task)}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<span>Delete task</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{expandedTaskId === task.id && (
|
||||
<TaskRunHistory projectRef={projectRef} taskId={task.id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TaskEditorSheet
|
||||
visible={showSheet}
|
||||
task={editingTask}
|
||||
agentId={agentId}
|
||||
projectRef={projectRef}
|
||||
onClose={() => {
|
||||
setShowSheet(false)
|
||||
setEditingTask(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
variant="destructive"
|
||||
visible={!!deletingTask}
|
||||
title={`Delete task "${deletingTask?.name}"`}
|
||||
confirmLabel="Delete"
|
||||
loading={deleteMutation.isPending}
|
||||
onCancel={() => setDeletingTask(null)}
|
||||
onConfirm={() => {
|
||||
if (!deletingTask) return
|
||||
deleteMutation.mutate(deletingTask.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Task deleted')
|
||||
setDeletingTask(null)
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Are you sure you want to delete this task? Any scheduled cron job will also be removed.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskRunHistory({ projectRef, taskId }: { projectRef: string; taskId: string }) {
|
||||
const { data: conversations, isLoading } = useTaskConversationsQuery(projectRef, taskId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-6 py-3 border-t border-default">
|
||||
<p className="text-xs text-foreground-muted animate-pulse">Loading run history…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!conversations || conversations.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-3 border-t border-default">
|
||||
<p className="text-xs text-foreground-muted">
|
||||
No runs yet. This task will produce results on its next scheduled run or when triggered
|
||||
manually.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-default">
|
||||
<div className="px-6 py-2">
|
||||
<p className="text-xs font-medium text-foreground-lighter uppercase tracking-wide mb-2">
|
||||
Run History
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{conversations.map((conv) => {
|
||||
const messageCount = (conv as unknown as { message_count?: number }).message_count ?? 0
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
className="flex items-center gap-3 py-1.5 text-xs"
|
||||
>
|
||||
<Clock size={12} className="text-foreground-muted shrink-0" />
|
||||
<span className="text-foreground-lighter">
|
||||
{new Date(conv.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{messageCount} message{messageCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{conv.title && (
|
||||
<span className="text-foreground-lighter truncate max-w-xs">{conv.title}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskEditorSheet({
|
||||
visible,
|
||||
task,
|
||||
agentId,
|
||||
projectRef,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean
|
||||
task: AdvisorAgentTask | null
|
||||
agentId: string
|
||||
projectRef: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const createMutation = useCreateAgentTaskMutation(projectRef)
|
||||
const updateMutation = useUpdateAgentTaskMutation(projectRef)
|
||||
const isEditing = !!task
|
||||
const formId = 'task-editor-form'
|
||||
|
||||
const form = useForm<z.infer<typeof TaskFormSchema>>({
|
||||
resolver: zodResolver(TaskFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
schedule: '0 8 * * *',
|
||||
is_unique: false,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const handleOpen = () => {
|
||||
if (task) {
|
||||
form.reset({
|
||||
name: task.name,
|
||||
description: task.description ?? '',
|
||||
schedule: task.schedule,
|
||||
is_unique: task.is_unique,
|
||||
enabled: task.enabled,
|
||||
})
|
||||
} else {
|
||||
form.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof TaskFormSchema>) => {
|
||||
const payload = {
|
||||
agent_id: agentId,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
schedule: values.schedule,
|
||||
is_unique: values.is_unique,
|
||||
enabled: values.enabled,
|
||||
}
|
||||
|
||||
if (isEditing && task) {
|
||||
await updateMutation.mutateAsync({ taskId: task.id, ...payload })
|
||||
toast.success('Task updated')
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload)
|
||||
toast.success('Task created')
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={visible} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent size="default" className="flex flex-col gap-0" onOpenAutoFocus={handleOpen}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEditing ? 'Edit Task' : 'Add Task'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="overflow-auto flex-grow px-0"
|
||||
>
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="horizontal" label="Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Daily Security Review" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="horizontal" label="Description">
|
||||
<FormControl_Shadcn_>
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={3}
|
||||
placeholder="What this task should do…"
|
||||
className="resize-none"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
name="schedule"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Schedule (cron)"
|
||||
description="Standard cron expression, e.g. 0 8 * * * for daily at 8 AM"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="0 8 * * *" className="font-mono" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
name="is_unique"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Unique"
|
||||
description="When enabled, only one conversation per task is maintained (overwrites previous runs)"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
name="enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="horizontal"
|
||||
label="Enabled"
|
||||
description="When disabled, the cron job will not run"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
<SheetFooter>
|
||||
<Button type="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
form={formId}
|
||||
htmlType="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{isEditing ? 'Save changes' : 'Add task'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
useUpdateAgentMutation,
|
||||
} from 'data/advisors/agents-query'
|
||||
import type { AdvisorAgent } from 'data/advisors/types'
|
||||
import { Bot, Edit, Info, MoreVertical, Plus, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronRight, Edit, Info, MoreVertical, Plus, Trash } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -57,7 +57,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { AgentPresetsCards } from './AgentPresets'
|
||||
import { TOOL_CATALOG, TOOL_NAMES } from './tool-catalog'
|
||||
import { AgentTasksList } from './AgentTasksList'
|
||||
import { TOOL_CATALOG } from './tool-catalog'
|
||||
|
||||
const AgentFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
@@ -73,6 +74,7 @@ export function AgentsList() {
|
||||
const [showSheet, setShowSheet] = useState(false)
|
||||
const [editingAgent, setEditingAgent] = useState<AdvisorAgent | null>(null)
|
||||
const [deletingAgent, setDeletingAgent] = useState<AdvisorAgent | null>(null)
|
||||
const [expandedAgentId, setExpandedAgentId] = useState<string | null>(null)
|
||||
|
||||
const deleteMutation = useDeleteAgentMutation(projectRef)
|
||||
|
||||
@@ -128,78 +130,99 @@ export function AgentsList() {
|
||||
<TableBody>
|
||||
{(agents ?? []).map((agent) => {
|
||||
const agentTasks = (tasks ?? []).filter((t) => t.agent_id === agent.id)
|
||||
const isExpanded = expandedAgentId === agent.id
|
||||
return (
|
||||
<TableRow key={agent.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{agent.name}</span>
|
||||
{agent.summary && (
|
||||
<p className="text-xs text-foreground-lighter truncate max-w-xs">
|
||||
{agent.summary}
|
||||
</p>
|
||||
<React.Fragment key={agent.id}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{agent.name}</span>
|
||||
{agent.summary && (
|
||||
<p className="text-xs text-foreground-lighter truncate max-w-xs">
|
||||
{agent.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.tools.length > 0 ? (
|
||||
<TooltipProvider>
|
||||
{agent.tools.slice(0, 3).map((tool) => {
|
||||
const def = TOOL_CATALOG.find((t) => t.name === tool)
|
||||
return (
|
||||
<Tooltip key={tool}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="default">{tool}</Badge>
|
||||
</TooltipTrigger>
|
||||
{def && <TooltipContent>{def.description}</TooltipContent>}
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-muted">None</span>
|
||||
)}
|
||||
{agent.tools.length > 3 && (
|
||||
<Badge variant="default">+{agent.tools.length - 3}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.tools.length > 0 ? (
|
||||
<TooltipProvider>
|
||||
{agent.tools.slice(0, 3).map((tool) => {
|
||||
const def = TOOL_CATALOG.find((t) => t.name === tool)
|
||||
return (
|
||||
<Tooltip key={tool}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="default">{tool}</Badge>
|
||||
</TooltipTrigger>
|
||||
{def && <TooltipContent>{def.description}</TooltipContent>}
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-muted">None</span>
|
||||
)}
|
||||
{agent.tools.length > 3 && (
|
||||
<Badge variant="default">+{agent.tools.length - 3}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-foreground-lighter">
|
||||
{agentTasks.length} task{agentTasks.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setEditingAgent(agent)
|
||||
setShowSheet(true)
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<p>Edit agent</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setDeletingAgent(agent)}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete agent</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-sm text-foreground-lighter hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
setExpandedAgentId(isExpanded ? null : agent.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
{agentTasks.length} task{agentTasks.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
setEditingAgent(agent)
|
||||
setShowSheet(true)
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<p>Edit agent</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setDeletingAgent(agent)}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete agent</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-0">
|
||||
<AgentTasksList agentId={agent.id} projectRef={projectRef!} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
@@ -297,7 +320,7 @@ function AgentEditorSheet({
|
||||
|
||||
return (
|
||||
<Sheet open={visible} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent size="default" className="flex flex-col gap-0" onOpenAutoFocus={handleOpen}>
|
||||
<SheetContent size="lg" className="flex flex-col gap-0" onOpenAutoFocus={handleOpen}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEditing ? 'Edit Agent' : 'Create Agent'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface TaskPreset {
|
||||
name: string
|
||||
description: string
|
||||
schedule: string
|
||||
is_unique: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AgentTaskPresets {
|
||||
agentPresetId: string
|
||||
tasks: TaskPreset[]
|
||||
}
|
||||
|
||||
export const TASK_PRESETS_BY_AGENT: AgentTaskPresets[] = [
|
||||
{
|
||||
agentPresetId: 'security-advisor',
|
||||
tasks: [
|
||||
{
|
||||
name: 'Daily Security Review',
|
||||
description:
|
||||
'Review all open critical security issues and summarize the most urgent ones with recommended fixes.',
|
||||
schedule: '0 8 * * *',
|
||||
is_unique: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Weekly RLS Audit',
|
||||
description:
|
||||
'Audit all tables exposed via the API for missing or misconfigured RLS policies.',
|
||||
schedule: '0 9 * * 1',
|
||||
is_unique: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
agentPresetId: 'performance-optimizer',
|
||||
tasks: [
|
||||
{
|
||||
name: 'Daily Performance Check',
|
||||
description:
|
||||
'Analyze open performance issues, check for new missing indexes and table bloat.',
|
||||
schedule: '0 7 * * *',
|
||||
is_unique: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Weekly Index Review',
|
||||
description: 'Review unused and duplicate indexes and recommend cleanup.',
|
||||
schedule: '0 10 * * 1',
|
||||
is_unique: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
agentPresetId: 'incident-responder',
|
||||
tasks: [
|
||||
{
|
||||
name: 'Hourly Critical Scan',
|
||||
description:
|
||||
'Check for any new critical issues in the last hour and provide a triage summary.',
|
||||
schedule: '0 * * * *',
|
||||
is_unique: false,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getTaskPresetsForAgent(agentPresetId: string): TaskPreset[] {
|
||||
return TASK_PRESETS_BY_AGENT.find((p) => p.agentPresetId === agentPresetId)?.tasks ?? []
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useParams } from 'common'
|
||||
import { useUpdateIssueMutation } from 'data/advisors/issues-query'
|
||||
import type { AdvisorIssue, SuggestedAction } from 'data/advisors/types'
|
||||
import type { AdvisorIssue, AdvisorRule, SuggestedAction } from 'data/advisors/types'
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Code,
|
||||
ExternalLink,
|
||||
@@ -11,9 +13,12 @@ import {
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Badge, Button } from 'ui'
|
||||
|
||||
import { getKnownIssueInfo, type KnownIssueInfo } from './IssueDetail.utils'
|
||||
|
||||
interface RemediationConfig {
|
||||
icon: typeof ShieldCheck
|
||||
color: string
|
||||
@@ -65,11 +70,11 @@ function RemediationActionCard({
|
||||
href={action.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-default hover:bg-surface-100 transition-colors"
|
||||
className="flex items-center gap-2 p-3 rounded-lg border border-default hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{action.label}</p>
|
||||
<p className="text-sm">{action.label}</p>
|
||||
<p className="text-xs text-foreground-muted truncate">{action.url}</p>
|
||||
</div>
|
||||
<Badge variant="default" className="text-xs shrink-0">
|
||||
@@ -82,10 +87,10 @@ function RemediationActionCard({
|
||||
if (action.type === 'sql' && action.sql) {
|
||||
return (
|
||||
<div className="rounded-lg border border-default p-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{action.label}</p>
|
||||
<p className="text-sm">{action.label}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
@@ -116,24 +121,61 @@ function RemediationActionCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-default hover:bg-surface-100 transition-colors">
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg border border-default hover:bg-surface-100 transition-colors">
|
||||
<Info className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<p className="text-sm flex-1">{action.label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KnownIssueActionsCard({ info }: { info: KnownIssueInfo }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs text-foreground-lighter">Quick Actions</h4>
|
||||
<Link
|
||||
href={info.navLink}
|
||||
className="flex items-center gap-2 p-3 rounded-lg border border-default hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<p className="text-sm flex-1">{info.navLinkText}</p>
|
||||
<Badge variant="default" className="text-xs shrink-0">
|
||||
Go
|
||||
</Badge>
|
||||
</Link>
|
||||
<a
|
||||
href={info.docsLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg border border-default hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 text-foreground-lighter shrink-0" />
|
||||
<p className="text-sm flex-1">View Documentation</p>
|
||||
<Badge variant="default" className="text-xs shrink-0">
|
||||
Docs
|
||||
</Badge>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function IssueActions({
|
||||
issue,
|
||||
rule,
|
||||
onActionApplied,
|
||||
}: {
|
||||
issue: AdvisorIssue
|
||||
rule?: AdvisorRule
|
||||
onActionApplied?: () => void
|
||||
}) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const updateMutation = useUpdateIssueMutation(projectRef)
|
||||
const hasActions = issue.suggested_actions.length > 0
|
||||
|
||||
const knownInfo = useMemo<KnownIssueInfo | null>(() => {
|
||||
if (!rule?.name || !projectRef) return null
|
||||
return getKnownIssueInfo(rule.name, projectRef, issue.metadata)
|
||||
}, [rule?.name, projectRef, issue.metadata])
|
||||
|
||||
const handleApply = async (action: SuggestedAction) => {
|
||||
const newActionsTaken = [
|
||||
...issue.actions_taken,
|
||||
@@ -148,34 +190,36 @@ export function IssueActions({
|
||||
onActionApplied?.()
|
||||
}
|
||||
|
||||
if (!hasActions) return null
|
||||
if (!hasActions && !knownInfo) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<h3 className="text-sm flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" />
|
||||
Recommended Actions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{issue.suggested_actions.map((action, i) => (
|
||||
<RemediationActionCard
|
||||
key={i}
|
||||
action={action}
|
||||
issue={issue}
|
||||
onApply={handleApply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{knownInfo && <KnownIssueActionsCard info={knownInfo} />}
|
||||
|
||||
{hasActions && (
|
||||
<div className="space-y-2">
|
||||
{issue.suggested_actions.map((action, i) => (
|
||||
<RemediationActionCard key={i} action={action} issue={issue} onApply={handleApply} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issue.actions_taken.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-medium text-foreground-lighter mb-2">Actions Taken</h4>
|
||||
<h4 className="text-xs text-foreground-lighter mb-2">Actions Taken</h4>
|
||||
<div className="space-y-1">
|
||||
{issue.actions_taken.map((action, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-foreground-lighter">
|
||||
<CheckCircle2 className="h-3 w-3 text-brand" />
|
||||
<span>{action.label}</span>
|
||||
<span className="text-foreground-muted">· {new Date(action.taken_at).toLocaleDateString()}</span>
|
||||
<span className="text-foreground-muted">
|
||||
· {new Date(action.taken_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useParams } from 'common'
|
||||
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
|
||||
import { useAdvisorIssueDetailQuery, useUpdateIssueMutation } from 'data/advisors/issues-query'
|
||||
import { useAdvisorRulesQuery } from 'data/advisors/rules-query'
|
||||
import type { AdvisorAlert, AdvisorRule, AdvisorSeverity, IssueStatus } from 'data/advisors/types'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import type { AdvisorAlert, AdvisorSeverity, IssueStatus } from 'data/advisors/types'
|
||||
import { useTrack } from 'lib/telemetry/track'
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
@@ -13,24 +16,47 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Play,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
|
||||
import { Badge, Button, Card, CardContent } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import { IssueActions } from './IssueActions'
|
||||
import { createIssueSummaryPrompt } from './IssueDetail.utils'
|
||||
|
||||
const severityConfig: Record<
|
||||
AdvisorSeverity,
|
||||
{ icon: typeof AlertOctagon; color: string; bg: string; badgeVariant: 'destructive' | 'warning' | 'default' }
|
||||
{
|
||||
icon: typeof AlertOctagon
|
||||
color: string
|
||||
bg: string
|
||||
badgeVariant: 'destructive' | 'warning' | 'default'
|
||||
}
|
||||
> = {
|
||||
critical: { icon: AlertOctagon, color: 'text-destructive-600', bg: 'bg-destructive-200', badgeVariant: 'destructive' },
|
||||
warning: { icon: AlertTriangle, color: 'text-warning-600', bg: 'bg-warning-200', badgeVariant: 'warning' },
|
||||
info: { icon: Info, color: 'text-foreground-lighter', bg: 'bg-surface-200', badgeVariant: 'default' },
|
||||
critical: {
|
||||
icon: AlertOctagon,
|
||||
color: 'text-destructive-600',
|
||||
bg: 'bg-destructive-200',
|
||||
badgeVariant: 'destructive',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: 'text-warning-600',
|
||||
bg: 'bg-warning-200',
|
||||
badgeVariant: 'warning',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: 'text-foreground-lighter',
|
||||
bg: 'bg-surface-200',
|
||||
badgeVariant: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
function AlertTimelineItem({ alert }: { alert: AdvisorAlert }) {
|
||||
@@ -43,7 +69,7 @@ function AlertTimelineItem({ alert }: { alert: AdvisorAlert }) {
|
||||
<SeverityIcon className={`h-3 w-3 ${sev.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{alert.title}</p>
|
||||
<p className="text-sm text-foreground">{alert.title}</p>
|
||||
{alert.description && (
|
||||
<p className="mt-1 text-xs text-foreground-lighter whitespace-pre-wrap line-clamp-3">
|
||||
{alert.description}
|
||||
@@ -98,16 +124,16 @@ function RerunCheckButton({ ruleId, projectRef }: { ruleId: string | null; proje
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Verify Fix</p>
|
||||
<p className="text-sm text-foreground">Verify Fix</p>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Re-run the rule's SQL check to confirm the issue is resolved.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
size="tiny"
|
||||
loading={checking}
|
||||
onClick={handleRerun}
|
||||
icon={<RefreshCw className="h-3 w-3" />}
|
||||
@@ -118,13 +144,13 @@ function RerunCheckButton({ ruleId, projectRef }: { ruleId: string | null; proje
|
||||
{result === 'pass' && (
|
||||
<div className="mt-3 flex items-center gap-2 text-brand">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Passed — no issues found</span>
|
||||
<span className="text-sm ">Passed — no issues found</span>
|
||||
</div>
|
||||
)}
|
||||
{result === 'fail' && (
|
||||
<div className="mt-3 flex items-center gap-2 text-destructive-600">
|
||||
<AlertOctagon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Still detected — issue persists</span>
|
||||
<span className="text-sm ">Still detected — issue persists</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -144,15 +170,15 @@ function ResolutionSteps({ isActive }: { isActive: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs font-medium text-foreground-lighter mb-3">Resolution Steps</p>
|
||||
<p className="text-xs text-foreground-lighter mb-3">Resolution Steps</p>
|
||||
<div className="flex gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.num} className="flex items-start gap-2 flex-1">
|
||||
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-surface-200 text-foreground-lighter text-xs font-medium shrink-0">
|
||||
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-foreground text-background text-xs shrink-0">
|
||||
{step.num}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-foreground">{step.label}</p>
|
||||
<p className="text-xs text-foreground">{step.label}</p>
|
||||
<p className="text-xs text-foreground-lighter">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +192,11 @@ function ResolutionSteps({ isActive }: { isActive: boolean }) {
|
||||
export function IssueDetail() {
|
||||
const { ref: projectRef, id: issueId } = useParams()
|
||||
const { data: issue, isLoading } = useAdvisorIssueDetailQuery(projectRef, issueId)
|
||||
const { data: rules } = useAdvisorRulesQuery(projectRef)
|
||||
const updateMutation = useUpdateIssueMutation(projectRef)
|
||||
const track = useTrack()
|
||||
const snap = useAiAssistantStateSnapshot()
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
|
||||
const handleStatusChange = (status: IssueStatus) => {
|
||||
if (!issueId) return
|
||||
@@ -192,6 +222,23 @@ export function IssueDetail() {
|
||||
|
||||
const firstAlert = issue.alerts?.[0]
|
||||
const ruleId = firstAlert?.rule_id ?? null
|
||||
const rule: AdvisorRule | undefined = rules?.find((r) => r.id === ruleId)
|
||||
|
||||
const handleAskAssistant = () => {
|
||||
track('advisor_assistant_button_clicked', {
|
||||
origin: 'issue_detail',
|
||||
advisorCategory: issue.category,
|
||||
advisorType: rule?.name ?? 'unknown',
|
||||
advisorLevel: issue.severity,
|
||||
})
|
||||
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
|
||||
snap.newChat({
|
||||
name: 'Analyze issue',
|
||||
initialMessage: createIssueSummaryPrompt(issue, rule),
|
||||
})
|
||||
}
|
||||
|
||||
const buildPromptForCopy = () => createIssueSummaryPrompt(issue, rule)
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl flex flex-col gap-y-6 px-5 py-6">
|
||||
@@ -213,34 +260,67 @@ export function IssueDetail() {
|
||||
<Badge variant="default">{issue.category}</Badge>
|
||||
<span className="capitalize">{issue.status}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.alert_count} alert{issue.alert_count !== 1 ? 's' : ''}</span>
|
||||
<span>
|
||||
{issue.alert_count} alert{issue.alert_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<AiAssistantDropdown
|
||||
label="Ask Assistant"
|
||||
buildPrompt={buildPromptForCopy}
|
||||
onOpenAssistant={handleAskAssistant}
|
||||
telemetrySource="issue_detail"
|
||||
/>
|
||||
{isActive && issue.status !== 'acknowledged' && (
|
||||
<Button type="outline" size="tiny" icon={<Eye className="h-3 w-3" />} onClick={() => handleStatusChange('acknowledged')}>
|
||||
<Button
|
||||
type="outline"
|
||||
size="tiny"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
onClick={() => handleStatusChange('acknowledged')}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
{isActive && (
|
||||
<Button type="outline" size="tiny" icon={<Clock className="h-3 w-3" />} onClick={() => handleStatusChange('snoozed')}>
|
||||
<Button
|
||||
type="outline"
|
||||
size="tiny"
|
||||
icon={<Clock className="h-3 w-3" />}
|
||||
onClick={() => handleStatusChange('snoozed')}
|
||||
>
|
||||
Snooze
|
||||
</Button>
|
||||
)}
|
||||
{isActive && (
|
||||
<Button type="default" size="tiny" icon={<CheckCircle2 className="h-3 w-3" />} onClick={() => handleStatusChange('resolved')}>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<CheckCircle2 className="h-3 w-3" />}
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
{!isActive && issue.status === 'resolved' && (
|
||||
<Button type="outline" size="tiny" icon={<AlertTriangle className="h-3 w-3" />} onClick={() => handleStatusChange('open')}>
|
||||
<Button
|
||||
type="outline"
|
||||
size="tiny"
|
||||
icon={<AlertTriangle className="h-3 w-3" />}
|
||||
onClick={() => handleStatusChange('open')}
|
||||
>
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
{isActive && (
|
||||
<Button type="text" size="tiny" icon={<EyeOff className="h-3 w-3" />} onClick={() => handleStatusChange('dismissed')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<EyeOff className="h-3 w-3" />}
|
||||
onClick={() => handleStatusChange('dismissed')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
@@ -249,14 +329,14 @@ export function IssueDetail() {
|
||||
|
||||
<ResolutionSteps isActive={isActive} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="px-6 py-3 border-b border-default">
|
||||
<h3 className="text-sm font-medium text-foreground">Alert Timeline</h3>
|
||||
<div className="px-4 py-3 border-b border-default">
|
||||
<h3 className="text-sm text-foreground">Alert Timeline</h3>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<div className="px-4">
|
||||
{issue.alerts && issue.alerts.length > 0 ? (
|
||||
<div className="divide-y divide-default">
|
||||
{issue.alerts.map((alert) => (
|
||||
@@ -272,14 +352,14 @@ export function IssueDetail() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<IssueActions issue={issue} />
|
||||
<IssueActions issue={issue} rule={rule} />
|
||||
|
||||
<RerunCheckButton ruleId={ruleId} projectRef={projectRef!} />
|
||||
|
||||
{issue.description && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Description</h3>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm text-foreground mb-2">Description</h3>
|
||||
<p className="text-sm text-foreground-lighter whitespace-pre-wrap">
|
||||
{issue.description}
|
||||
</p>
|
||||
@@ -288,38 +368,50 @@ export function IssueDetail() {
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm text-foreground mb-3">Details</h3>
|
||||
<dl className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-foreground-lighter">Status</dt>
|
||||
<dd className="text-foreground font-medium capitalize">{issue.status}</dd>
|
||||
<dd className="text-foreground capitalize">{issue.status}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-foreground-lighter">Category</dt>
|
||||
<dd className="text-foreground font-medium capitalize">{issue.category}</dd>
|
||||
<dd className="text-foreground capitalize">{issue.category}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-foreground-lighter">Alerts</dt>
|
||||
<dd className="text-foreground font-medium">{issue.alert_count}</dd>
|
||||
<dd className="text-foreground ">{issue.alert_count}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-foreground-lighter">First seen</dt>
|
||||
<dd className="text-foreground font-medium">
|
||||
<TimestampInfo utcTimestamp={issue.first_triggered_at} labelFormat="D MMM YYYY" className="text-sm" />
|
||||
<dd className="text-foreground ">
|
||||
<TimestampInfo
|
||||
utcTimestamp={issue.first_triggered_at}
|
||||
labelFormat="D MMM YYYY HH:mm"
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-foreground-lighter">Last seen</dt>
|
||||
<dd className="text-foreground font-medium">
|
||||
<TimestampInfo utcTimestamp={issue.last_triggered_at} labelFormat="D MMM YYYY" className="text-sm" />
|
||||
<dd className="text-foreground">
|
||||
<TimestampInfo
|
||||
utcTimestamp={issue.last_triggered_at}
|
||||
labelFormat="D MMM YYYY HH:mm"
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
{issue.resolved_at && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-foreground-lighter">Resolved</dt>
|
||||
<dd className="text-foreground font-medium">
|
||||
<TimestampInfo utcTimestamp={issue.resolved_at} labelFormat="D MMM YYYY" className="text-sm" />
|
||||
<dd className="text-foreground">
|
||||
<TimestampInfo
|
||||
utcTimestamp={issue.resolved_at}
|
||||
labelFormat="D MMM YYYY"
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { LintInfo } from '@/components/interfaces/Linter/Linter.constants'
|
||||
import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
|
||||
import type { AdvisorIssue, AdvisorRule } from 'data/advisors/types'
|
||||
|
||||
export function createIssueSummaryPrompt(issue: AdvisorIssue, rule?: AdvisorRule | null): string {
|
||||
const entity =
|
||||
issue.metadata?.schema && issue.metadata?.name
|
||||
? `${issue.metadata.schema}.${issue.metadata.name}`
|
||||
: (issue.metadata?.entity as string) ?? 'N/A'
|
||||
|
||||
const schema = (issue.metadata?.schema as string) ?? 'N/A'
|
||||
|
||||
const suggestedActionsSummary =
|
||||
issue.suggested_actions.length > 0
|
||||
? issue.suggested_actions.map((a) => `- [${a.type}] ${a.label}`).join('\n')
|
||||
: 'None'
|
||||
|
||||
const parts = [
|
||||
`Analyze this database issue and suggest specific fixes:`,
|
||||
`Title: ${issue.title}`,
|
||||
`Severity: ${issue.severity}`,
|
||||
`Category: ${issue.category}`,
|
||||
`Entity: ${entity}`,
|
||||
`Schema: ${schema}`,
|
||||
]
|
||||
|
||||
if (issue.description) {
|
||||
parts.push(`Description: ${issue.description}`)
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
parts.push(`Rule: ${rule.name}`)
|
||||
if (rule.remediation) {
|
||||
parts.push(`Documentation: ${rule.remediation}`)
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(`Suggested Actions:\n${suggestedActionsSummary}`)
|
||||
|
||||
if (issue.metadata && Object.keys(issue.metadata).length > 0) {
|
||||
parts.push(`Metadata: ${JSON.stringify(issue.metadata)}`)
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
export interface KnownIssueInfo {
|
||||
lintInfo: LintInfo
|
||||
docsLink: string
|
||||
navLink: string
|
||||
navLinkText: string
|
||||
}
|
||||
|
||||
export function getKnownIssueInfo(
|
||||
ruleName: string,
|
||||
projectRef: string,
|
||||
metadata: Record<string, unknown>
|
||||
): KnownIssueInfo | null {
|
||||
const lintInfo = lintInfoMap.find((item) => item.name === ruleName)
|
||||
if (!lintInfo) return null
|
||||
|
||||
return {
|
||||
lintInfo,
|
||||
docsLink: lintInfo.docsLink,
|
||||
navLink: lintInfo.link({ projectRef, metadata }),
|
||||
navLinkText: lintInfo.linkText,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useParams } from 'common'
|
||||
import { useIsAdvisorsV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
|
||||
import { lintInfoMap } from 'components/interfaces/Linter/Linter.utils'
|
||||
import { useParams } from 'common'
|
||||
import { useAdvisorIssuesQuery } from 'data/advisors/issues-query'
|
||||
import { useAdvisorRulesQuery } from 'data/advisors/rules-query'
|
||||
import { useAdvisorAgentsQuery } from 'data/advisors/agents-query'
|
||||
import { useAdvisorChannelsQuery } from 'data/advisors/channels-query'
|
||||
import { useAdvisorIssuesQuery } from 'data/advisors/issues-query'
|
||||
import { useAdvisorRulesQuery } from 'data/advisors/rules-query'
|
||||
import type { AdvisorIssue } from 'data/advisors/types'
|
||||
import { Lint, useProjectLintsQuery } from 'data/lint/lint-query'
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import Link from 'next/link'
|
||||
import { Badge, Button, Card, CardContent } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { QuickSetupCard } from './QuickSetupCard'
|
||||
|
||||
function HealthScoreBanner({
|
||||
@@ -62,8 +63,7 @@ function HealthScoreBanner({
|
||||
const parts: string[] = []
|
||||
if (critical.length > 0)
|
||||
parts.push(`${critical.length} critical issue${critical.length !== 1 ? 's' : ''}`)
|
||||
if (lintErrors > 0)
|
||||
parts.push(`${lintErrors} lint error${lintErrors !== 1 ? 's' : ''}`)
|
||||
if (lintErrors > 0) parts.push(`${lintErrors} lint error${lintErrors !== 1 ? 's' : ''}`)
|
||||
|
||||
const extras: string[] = []
|
||||
if (warnings.length > 0)
|
||||
@@ -82,9 +82,7 @@ function HealthScoreBanner({
|
||||
{parts.join(' and ')} need attention
|
||||
</p>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
{extras.length > 0
|
||||
? `Plus ${extras.join(' and ')}. `
|
||||
: ''}
|
||||
{extras.length > 0 ? `Plus ${extras.join(' and ')}. ` : ''}
|
||||
Review the Security and Performance advisors to keep your project healthy.
|
||||
</p>
|
||||
</div>
|
||||
@@ -92,7 +90,7 @@ function HealthScoreBanner({
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="small"
|
||||
size="tiny"
|
||||
iconRight={<ArrowRight className="h-3 w-3" />}
|
||||
>
|
||||
<Link href={`/project/${projectRef}/advisors/issues`}>View issues</Link>
|
||||
@@ -127,7 +125,7 @@ function HealthScoreBanner({
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="small"
|
||||
size="tiny"
|
||||
iconRight={<ArrowRight className="h-3 w-3" />}
|
||||
>
|
||||
<Link href={`/project/${projectRef}/advisors/issues`}>View issues</Link>
|
||||
@@ -390,11 +388,7 @@ function LintFindingsSummary({
|
||||
{warnings.length} warning{warnings.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{infos.length > 0 && (
|
||||
<Badge variant="default">
|
||||
{infos.length} info
|
||||
</Badge>
|
||||
)}
|
||||
{infos.length > 0 && <Badge variant="default">{infos.length} info</Badge>}
|
||||
</span>
|
||||
</h3>
|
||||
<Button
|
||||
@@ -478,9 +472,7 @@ function QuickLinks({ projectRef }: { projectRef: string }) {
|
||||
<Zap className="h-5 w-5 text-foreground-lighter shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Performance Advisor</p>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Identify performance bottlenecks
|
||||
</p>
|
||||
<p className="text-xs text-foreground-lighter">Identify performance bottlenecks</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -504,10 +496,7 @@ export function AdvisorsOverview() {
|
||||
isPending: lintsLoading,
|
||||
isRefetching: lintsRefetching,
|
||||
refetch: refetchLints,
|
||||
} = useProjectLintsQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !!isV2 }
|
||||
)
|
||||
} = useProjectLintsQuery({ projectRef }, { enabled: !!isV2 })
|
||||
|
||||
const isLoading = issuesLoading || rulesLoading || (isV2 && lintsLoading)
|
||||
|
||||
@@ -561,8 +550,8 @@ export function AdvisorsOverview() {
|
||||
{isV2 && allLints.length > 0 && (
|
||||
<div id="lint-findings">
|
||||
<p className="text-xs text-foreground-lighter mb-3">
|
||||
Lint findings are real-time checks from the Security and Performance Advisors.
|
||||
Click a finding to see full details and remediation steps.
|
||||
Lint findings are real-time checks from the Security and Performance Advisors. Click a
|
||||
finding to see full details and remediation steps.
|
||||
</p>
|
||||
<LintFindingsSummary
|
||||
lints={allLints}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useParams } from 'common'
|
||||
import { useCreateRuleMutation } from 'data/advisors/rules-query'
|
||||
import { DollarSign, ShieldCheck, Zap } from 'lucide-react'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { DollarSign, ExternalLink, LayoutTemplate, ShieldCheck, Zap } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge, Button, Card, CardContent } from 'ui'
|
||||
@@ -13,17 +15,45 @@ const categoryIcons = {
|
||||
cost: DollarSign,
|
||||
} as const
|
||||
|
||||
const ADVISORS_DOCS_URL = `${DOCS_URL}/guides/database/database-advisors`
|
||||
|
||||
export function RuleTemplates({ existingRuleNames }: { existingRuleNames: string[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{ALL_TEMPLATES.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
existingRuleNames={existingRuleNames}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-surface-200 p-2">
|
||||
<LayoutTemplate className="h-4 w-4 text-foreground-lighter" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Rule Templates</h3>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Quick-start rule packs you can apply. Customize after adding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={ADVISORS_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-brand hover:text-brand-600 transition-colors shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View more templates
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{ALL_TEMPLATES.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
existingRuleNames={existingRuleNames}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,14 +87,19 @@ function TemplateCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<Card className="flex flex-col border-surface-200">
|
||||
<CardContent className="flex flex-col gap-3 p-4 flex-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-surface-200 p-2 shrink-0">
|
||||
<Icon className="h-4 w-4 text-foreground-lighter" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{template.name}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-foreground">{template.name}</p>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-normal">
|
||||
Template
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-lighter mt-0.5">{template.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +124,8 @@ export function RulesList() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<RuleTemplates existingRuleNames={(rules ?? []).map((r) => r.name)} />
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2">
|
||||
<Input
|
||||
placeholder="Search rules"
|
||||
@@ -144,8 +146,6 @@ export function RulesList() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RuleTemplates existingRuleNames={(rules ?? []).map((r) => r.name)} />
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function AdvisorsSidebarMenu({ page }: AdvisorsSidebarMenuProps) {
|
||||
|
||||
return (
|
||||
<div className="pb-12 relative">
|
||||
<FeaturePreviewSidebarPanel
|
||||
{/* <FeaturePreviewSidebarPanel
|
||||
className="mx-4 mt-4"
|
||||
title="Moving to the toolbar"
|
||||
description="Advisors are now available in the top toolbar for quicker access across the dashboard."
|
||||
@@ -33,7 +33,7 @@ export function AdvisorsSidebarMenu({ page }: AdvisorsSidebarMenuProps) {
|
||||
Try it now
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<ProductMenu page={page} menu={generateAdvisorsMenu(project, { advisorsV2 })} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { advisorKeys } from './keys'
|
||||
import { S, literal } from './advisors-fetch'
|
||||
import type { AdvisorAgent, AdvisorAgentTask } from './types'
|
||||
import type { AdvisorAgent, AdvisorAgentTask, AdvisorConversation } from './types'
|
||||
|
||||
export function useAdvisorAgentsQuery(
|
||||
projectRef?: string,
|
||||
@@ -223,3 +223,59 @@ export function useDeleteAgentTaskMutation(projectRef?: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Task Conversations (run history)
|
||||
|
||||
export function useTaskConversationsQuery(
|
||||
projectRef?: string,
|
||||
taskId?: string,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const connectionString = project?.connectionString
|
||||
|
||||
return useQuery<AdvisorConversation[]>({
|
||||
queryKey: advisorKeys.taskConversations(projectRef, taskId),
|
||||
queryFn: async ({ signal }) => {
|
||||
const { result } = await executeSql<AdvisorConversation[]>(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `
|
||||
SELECT c.*,
|
||||
(SELECT count(*)::int FROM ${S}.conversation_messages WHERE conversation_id = c.id) as message_count
|
||||
FROM ${S}.conversations c
|
||||
WHERE c.task_id = ${literal(taskId)}
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
queryKey: advisorKeys.taskConversations(projectRef, taskId),
|
||||
},
|
||||
signal
|
||||
)
|
||||
return result ?? []
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!projectRef && !!connectionString && !!taskId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useExecuteAgentTaskMutation(projectRef?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const connectionString = project?.connectionString
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `SELECT ${S}.execute_agent_task(${literal(taskId)}::uuid)`,
|
||||
queryKey: ['advisor-execute-agent-task'],
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, taskId) => {
|
||||
queryClient.invalidateQueries({ queryKey: advisorKeys.taskConversations(projectRef, taskId) })
|
||||
queryClient.invalidateQueries({ queryKey: advisorKeys.agentTasks(projectRef) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -17,4 +17,6 @@ export const advisorKeys = {
|
||||
['projects', projectRef, 'advisor-channels'] as const,
|
||||
conversations: (projectRef: string | undefined, issueId?: string) =>
|
||||
['projects', projectRef, 'advisor-conversations', issueId] as const,
|
||||
taskConversations: (projectRef: string | undefined, taskId?: string) =>
|
||||
['projects', projectRef, 'advisor-task-conversations', taskId] as const,
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
-- =============================================================================
|
||||
-- Enhanced Notification Dispatcher
|
||||
-- Richer payloads for Slack (action buttons, docs link, context block),
|
||||
-- webhooks (actions array), and email (structured metadata).
|
||||
-- Replaces the function from 20260304120002.
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function _supabase_advisors.dispatch_notifications()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_channel record;
|
||||
v_payload jsonb;
|
||||
v_has_pg_net boolean;
|
||||
v_rule record;
|
||||
v_docs_url text;
|
||||
v_supabase_url text;
|
||||
v_issue_path text;
|
||||
v_actions jsonb;
|
||||
begin
|
||||
if TG_OP = 'INSERT' or (
|
||||
TG_OP = 'UPDATE'
|
||||
and OLD.severity != NEW.severity
|
||||
and NEW.severity = 'critical'
|
||||
) then
|
||||
|
||||
select exists(select 1 from pg_extension where extname = 'pg_net') into v_has_pg_net;
|
||||
|
||||
-- Look up the originating rule for docs/remediation info
|
||||
select r.* into v_rule
|
||||
from _supabase_advisors.alerts a
|
||||
join _supabase_advisors.rules r on r.id = a.rule_id
|
||||
where a.issue_id = NEW.id
|
||||
order by a.triggered_at desc
|
||||
limit 1;
|
||||
|
||||
v_docs_url := coalesce(v_rule.remediation, null);
|
||||
|
||||
-- Try to get the Supabase URL for building dashboard links
|
||||
begin
|
||||
select decrypted_secret into v_supabase_url
|
||||
from vault.decrypted_secrets where name = 'supabase_url' limit 1;
|
||||
exception when others then
|
||||
v_supabase_url := null;
|
||||
end;
|
||||
|
||||
v_issue_path := '/project/_/advisors/issues/' || NEW.id::text;
|
||||
|
||||
-- Build actions array for structured payloads
|
||||
v_actions := jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'type', 'status_update',
|
||||
'label', 'Acknowledge',
|
||||
'target_status', 'acknowledged'
|
||||
),
|
||||
jsonb_build_object(
|
||||
'type', 'status_update',
|
||||
'label', 'Resolve',
|
||||
'target_status', 'resolved'
|
||||
)
|
||||
);
|
||||
|
||||
if v_docs_url is not null then
|
||||
v_actions := v_actions || jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'type', 'link',
|
||||
'label', 'View Documentation',
|
||||
'url', v_docs_url
|
||||
)
|
||||
);
|
||||
end if;
|
||||
|
||||
for v_channel in
|
||||
select * from _supabase_advisors.channels
|
||||
where is_enabled = true
|
||||
and NEW.severity = any(severity_filter)
|
||||
and (category_filter is null or NEW.category = any(category_filter))
|
||||
loop
|
||||
v_payload := jsonb_build_object(
|
||||
'issue_id', NEW.id,
|
||||
'title', NEW.title,
|
||||
'severity', NEW.severity,
|
||||
'category', NEW.category,
|
||||
'status', NEW.status,
|
||||
'alert_count', NEW.alert_count,
|
||||
'description', NEW.description,
|
||||
'suggested_actions', NEW.suggested_actions,
|
||||
'actions', v_actions,
|
||||
'docs_url', v_docs_url,
|
||||
'issue_path', v_issue_path,
|
||||
'rule_name', coalesce(v_rule.name, null),
|
||||
'rule_title', coalesce(v_rule.title, null)
|
||||
);
|
||||
|
||||
insert into _supabase_advisors.notifications (
|
||||
issue_id, channel_id, channel_type, recipient, payload, status
|
||||
) values (
|
||||
NEW.id,
|
||||
v_channel.id,
|
||||
v_channel.type,
|
||||
case v_channel.type
|
||||
when 'email' then coalesce((v_channel.config->>'email')::text, 'unknown')
|
||||
when 'slack' then coalesce((v_channel.config->>'channel')::text, 'unknown')
|
||||
when 'webhook' then coalesce((v_channel.config->>'url')::text, 'unknown')
|
||||
else 'unknown'
|
||||
end,
|
||||
v_payload,
|
||||
case when v_has_pg_net then 'pending' else 'failed' end
|
||||
);
|
||||
|
||||
if v_has_pg_net and v_channel.type in ('webhook', 'slack') then
|
||||
declare
|
||||
v_url text;
|
||||
v_slack_blocks jsonb;
|
||||
v_action_elements jsonb;
|
||||
v_context_parts text;
|
||||
begin
|
||||
if v_channel.type = 'slack' then
|
||||
v_url := (v_channel.config->>'webhook_url')::text;
|
||||
if v_url is not null then
|
||||
-- Build action buttons for Slack
|
||||
v_action_elements := jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'type', 'button',
|
||||
'text', jsonb_build_object('type', 'plain_text', 'text', 'View Issue'),
|
||||
'url', v_issue_path
|
||||
)
|
||||
);
|
||||
|
||||
if v_docs_url is not null then
|
||||
v_action_elements := v_action_elements || jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'type', 'button',
|
||||
'text', jsonb_build_object('type', 'plain_text', 'text', 'View Docs'),
|
||||
'url', v_docs_url
|
||||
)
|
||||
);
|
||||
end if;
|
||||
|
||||
v_context_parts := format(
|
||||
'Actions: Acknowledge · Resolve · Dismiss | %s alert(s) · %s',
|
||||
NEW.alert_count,
|
||||
NEW.category
|
||||
);
|
||||
|
||||
v_slack_blocks := jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'type', 'section',
|
||||
'text', jsonb_build_object(
|
||||
'type', 'mrkdwn',
|
||||
'text', format(
|
||||
'*%s* `%s` _%s_\n%s',
|
||||
NEW.title,
|
||||
upper(NEW.severity),
|
||||
NEW.category,
|
||||
coalesce(NEW.description, '')
|
||||
)
|
||||
)
|
||||
),
|
||||
jsonb_build_object(
|
||||
'type', 'actions',
|
||||
'elements', v_action_elements
|
||||
),
|
||||
jsonb_build_object(
|
||||
'type', 'context',
|
||||
'elements', jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'type', 'mrkdwn',
|
||||
'text', v_context_parts
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
perform net.http_post(
|
||||
url := v_url,
|
||||
headers := '{"Content-Type": "application/json"}'::jsonb,
|
||||
body := jsonb_build_object(
|
||||
'text', format('[%s] %s: %s', upper(NEW.severity), NEW.category, NEW.title),
|
||||
'blocks', v_slack_blocks
|
||||
)
|
||||
);
|
||||
end if;
|
||||
elsif v_channel.type = 'webhook' then
|
||||
v_url := (v_channel.config->>'url')::text;
|
||||
if v_url is not null then
|
||||
perform net.http_post(
|
||||
url := v_url,
|
||||
headers := '{"Content-Type": "application/json"}'::jsonb,
|
||||
body := v_payload
|
||||
);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
update _supabase_advisors.notifications
|
||||
set status = 'sent', sent_at = now()
|
||||
where issue_id = NEW.id
|
||||
and channel_id = v_channel.id
|
||||
and status = 'pending';
|
||||
exception when others then
|
||||
update _supabase_advisors.notifications
|
||||
set status = 'failed'
|
||||
where issue_id = NEW.id
|
||||
and channel_id = v_channel.id
|
||||
and status = 'pending';
|
||||
end;
|
||||
end if;
|
||||
end loop;
|
||||
end if;
|
||||
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
Reference in new issue
Block a user