feat: add hide/unhide agents from UI (#453)
Add ability to hide agents from the dashboard without deleting them. Hidden agents still receive heartbeats and exist in the DB — they are purely hidden from the default UI view. - Migration 042: adds `hidden` column to agents table - API: POST/DELETE /api/agents/[id]/hide to toggle visibility - GET /api/agents filters hidden by default, ?show_hidden=true to include - UI: "Show hidden" toggle in panel header, Hide/Unhide button on cards Co-authored-by: Dan <github@bookkept.com.au>
This commit is contained in:
parent
51a51a0ba2
commit
f872aac504
|
|
@ -0,0 +1,70 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/db'
|
||||
import { requireRole } from '@/lib/auth'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
/**
|
||||
* POST /api/agents/[id]/hide - Hide an agent from the UI
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = requireRole(request, 'operator')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const { id } = await params
|
||||
const workspaceId = auth.user.workspace_id ?? 1
|
||||
|
||||
const idNum = Number(id)
|
||||
const agent = isNaN(idNum)
|
||||
? db.prepare('SELECT id, name FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any
|
||||
: db.prepare('SELECT id, name FROM agents WHERE id = ? AND workspace_id = ?').get(idNum, workspaceId) as any
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
db.prepare('UPDATE agents SET hidden = 1, updated_at = unixepoch() WHERE id = ?').run(agent.id)
|
||||
|
||||
return NextResponse.json({ success: true, agent_id: agent.id, hidden: true })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'POST /api/agents/[id]/hide error')
|
||||
return NextResponse.json({ error: 'Failed to hide agent' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/[id]/hide - Unhide an agent
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = requireRole(request, 'operator')
|
||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const { id } = await params
|
||||
const workspaceId = auth.user.workspace_id ?? 1
|
||||
|
||||
const idNum = Number(id)
|
||||
const agent = isNaN(idNum)
|
||||
? db.prepare('SELECT id, name FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any
|
||||
: db.prepare('SELECT id, name FROM agents WHERE id = ? AND workspace_id = ?').get(idNum, workspaceId) as any
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
db.prepare('UPDATE agents SET hidden = 0, updated_at = unixepoch() WHERE id = ?').run(agent.id)
|
||||
|
||||
return NextResponse.json({ success: true, agent_id: agent.id, hidden: false })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'DELETE /api/agents/[id]/hide error')
|
||||
return NextResponse.json({ error: 'Failed to unhide agent' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ export async function GET(request: NextRequest) {
|
|||
// Parse query parameters
|
||||
const status = searchParams.get('status');
|
||||
const role = searchParams.get('role');
|
||||
const showHidden = searchParams.get('show_hidden') === 'true';
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
|
||||
|
|
@ -36,6 +37,10 @@ export async function GET(request: NextRequest) {
|
|||
let query = 'SELECT * FROM agents WHERE workspace_id = ?';
|
||||
const params: any[] = [workspaceId];
|
||||
|
||||
if (!showHidden) {
|
||||
query += ' AND hidden = 0';
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(status);
|
||||
|
|
@ -116,6 +121,9 @@ export async function GET(request: NextRequest) {
|
|||
// Get total count for pagination
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?';
|
||||
const countParams: any[] = [workspaceId];
|
||||
if (!showHidden) {
|
||||
countQuery += ' AND hidden = 0';
|
||||
}
|
||||
if (status) {
|
||||
countQuery += ' AND status = ?';
|
||||
countParams.push(status);
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export function AgentSquadPanelPhase3() {
|
|||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [syncToast, setSyncToast] = useState<string | null>(null)
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
|
||||
// Sync agents from gateway config or local disk
|
||||
const syncFromConfig = async (source?: 'local') => {
|
||||
|
|
@ -142,7 +143,8 @@ export function AgentSquadPanelPhase3() {
|
|||
setError(null)
|
||||
if (agents.length === 0) setLoading(true)
|
||||
|
||||
const response = await fetch('/api/agents')
|
||||
const url = showHidden ? '/api/agents?show_hidden=true' : '/api/agents'
|
||||
const response = await fetch(url)
|
||||
if (response.status === 401) {
|
||||
window.location.assign('/login?next=%2Fagents')
|
||||
return
|
||||
|
|
@ -162,7 +164,7 @@ export function AgentSquadPanelPhase3() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [agents.length, setAgents])
|
||||
}, [agents.length, setAgents, showHidden])
|
||||
|
||||
// Smart polling with visibility pause
|
||||
useSmartPoll(fetchAgents, 30000, { enabled: autoRefresh, pauseWhenSseConnected: true })
|
||||
|
|
@ -223,6 +225,25 @@ export function AgentSquadPanelPhase3() {
|
|||
}
|
||||
}
|
||||
|
||||
// Re-fetch when showHidden changes
|
||||
useEffect(() => {
|
||||
fetchAgents()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showHidden])
|
||||
|
||||
const toggleAgentHidden = async (agentId: number, hide: boolean) => {
|
||||
try {
|
||||
const response = await fetch(`/api/agents/${agentId}/hide`, {
|
||||
method: hide ? 'POST' : 'DELETE',
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to update visibility')
|
||||
fetchAgents()
|
||||
} catch (error) {
|
||||
log.error('Failed to toggle agent visibility:', error)
|
||||
setError('Failed to update agent visibility')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAgent = async (agentId: number, removeWorkspace: boolean) => {
|
||||
const previousAgents = agents
|
||||
setAgents(agents.filter((agent) => agent.id !== agentId))
|
||||
|
|
@ -333,6 +354,13 @@ export function AgentSquadPanelPhase3() {
|
|||
>
|
||||
{t('syncLocal')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowHidden(!showHidden)}
|
||||
variant={showHidden ? 'success' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{showHidden ? 'Showing hidden' : 'Show hidden'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
size="sm"
|
||||
|
|
@ -399,6 +427,7 @@ export function AgentSquadPanelPhase3() {
|
|||
onClick={() => setSelectedAgent(agent)}
|
||||
>
|
||||
<div className={`pointer-events-none absolute inset-y-0 left-0 w-1 bg-gradient-to-b ${(statusCardStyles[agent.status] || defaultCardStyle).edge}`} />
|
||||
{agent.hidden ? <div className="absolute top-2 right-2 text-2xs text-slate-500">hidden</div> : null}
|
||||
|
||||
{/* Header: avatar + name + status */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
|
|
@ -494,6 +523,17 @@ export function AgentSquadPanelPhase3() {
|
|||
>
|
||||
{t('spawn')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleAgentHidden(agent.id, !agent.hidden)
|
||||
}}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-slate-400 hover:bg-slate-500/15 hover:text-slate-300"
|
||||
>
|
||||
{agent.hidden ? 'Unhide' : 'Hide'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1262,6 +1262,12 @@ const migrations: Migration[] = [
|
|||
db.exec(`CREATE INDEX IF NOT EXISTS idx_gateway_health_logs_gateway_id ON gateway_health_logs(gateway_id)`)
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_gateway_health_logs_probed_at ON gateway_health_logs(probed_at)`)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '042_agent_hidden',
|
||||
up(db: Database.Database) {
|
||||
db.exec(`ALTER TABLE agents ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ export interface Agent {
|
|||
last_activity?: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
hidden?: number
|
||||
config?: JsonValue
|
||||
taskStats?: {
|
||||
total: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue