latest changes

This commit is contained in:
Francesco Sansalvadore committed 2026-03-04 22:57:41 +01:00
1 parent 8d34553565
commit ae464480aa
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>&middot;</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>
+57 -1
View File
@@ -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) })
},
})
}
+2
View File
@@ -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,
}
@@ -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;
$$;