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:
danielaustralia1 2026-03-20 00:48:25 +11:00 committed by GitHub
parent 51a51a0ba2
commit f872aac504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 128 additions and 3 deletions

View File

@ -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 })
}
}

View File

@ -29,12 +29,17 @@ 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');
// Build dynamic query
let query = 'SELECT * FROM agents WHERE workspace_id = ?';
const params: any[] = [workspaceId];
if (!showHidden) {
query += ' AND hidden = 0';
}
if (status) {
query += ' AND 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);

View File

@ -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>

View File

@ -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`)
}
}
]

View File

@ -143,6 +143,7 @@ export interface Agent {
last_activity?: string
created_at: number
updated_at: number
hidden?: number
config?: JsonValue
taskStats?: {
total: number