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,12 +29,17 @@ export async function GET(request: NextRequest) {
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
const status = searchParams.get('status');
|
const status = searchParams.get('status');
|
||||||
const role = searchParams.get('role');
|
const role = searchParams.get('role');
|
||||||
|
const showHidden = searchParams.get('show_hidden') === 'true';
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||||
const offset = parseInt(searchParams.get('offset') || '0');
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
|
||||||
// Build dynamic query
|
// Build dynamic query
|
||||||
let query = 'SELECT * FROM agents WHERE workspace_id = ?';
|
let query = 'SELECT * FROM agents WHERE workspace_id = ?';
|
||||||
const params: any[] = [workspaceId];
|
const params: any[] = [workspaceId];
|
||||||
|
|
||||||
|
if (!showHidden) {
|
||||||
|
query += ' AND hidden = 0';
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
query += ' AND status = ?';
|
query += ' AND status = ?';
|
||||||
|
|
@ -116,6 +121,9 @@ export async function GET(request: NextRequest) {
|
||||||
// Get total count for pagination
|
// Get total count for pagination
|
||||||
let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?';
|
let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?';
|
||||||
const countParams: any[] = [workspaceId];
|
const countParams: any[] = [workspaceId];
|
||||||
|
if (!showHidden) {
|
||||||
|
countQuery += ' AND hidden = 0';
|
||||||
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
countQuery += ' AND status = ?';
|
countQuery += ' AND status = ?';
|
||||||
countParams.push(status);
|
countParams.push(status);
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ export function AgentSquadPanelPhase3() {
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [syncToast, setSyncToast] = useState<string | null>(null)
|
const [syncToast, setSyncToast] = useState<string | null>(null)
|
||||||
|
const [showHidden, setShowHidden] = useState(false)
|
||||||
|
|
||||||
// Sync agents from gateway config or local disk
|
// Sync agents from gateway config or local disk
|
||||||
const syncFromConfig = async (source?: 'local') => {
|
const syncFromConfig = async (source?: 'local') => {
|
||||||
|
|
@ -142,7 +143,8 @@ export function AgentSquadPanelPhase3() {
|
||||||
setError(null)
|
setError(null)
|
||||||
if (agents.length === 0) setLoading(true)
|
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) {
|
if (response.status === 401) {
|
||||||
window.location.assign('/login?next=%2Fagents')
|
window.location.assign('/login?next=%2Fagents')
|
||||||
return
|
return
|
||||||
|
|
@ -162,7 +164,7 @@ export function AgentSquadPanelPhase3() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [agents.length, setAgents])
|
}, [agents.length, setAgents, showHidden])
|
||||||
|
|
||||||
// Smart polling with visibility pause
|
// Smart polling with visibility pause
|
||||||
useSmartPoll(fetchAgents, 30000, { enabled: autoRefresh, pauseWhenSseConnected: true })
|
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 deleteAgent = async (agentId: number, removeWorkspace: boolean) => {
|
||||||
const previousAgents = agents
|
const previousAgents = agents
|
||||||
setAgents(agents.filter((agent) => agent.id !== agentId))
|
setAgents(agents.filter((agent) => agent.id !== agentId))
|
||||||
|
|
@ -333,6 +354,13 @@ export function AgentSquadPanelPhase3() {
|
||||||
>
|
>
|
||||||
{t('syncLocal')}
|
{t('syncLocal')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowHidden(!showHidden)}
|
||||||
|
variant={showHidden ? 'success' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showHidden ? 'Showing hidden' : 'Show hidden'}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -399,6 +427,7 @@ export function AgentSquadPanelPhase3() {
|
||||||
onClick={() => setSelectedAgent(agent)}
|
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}`} />
|
<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 */}
|
{/* Header: avatar + name + status */}
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
|
@ -494,6 +523,17 @@ export function AgentSquadPanelPhase3() {
|
||||||
>
|
>
|
||||||
{t('spawn')}
|
{t('spawn')}
|
||||||
</Button>
|
</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>
|
</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_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)`)
|
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
|
last_activity?: string
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
|
hidden?: number
|
||||||
config?: JsonValue
|
config?: JsonValue
|
||||||
taskStats?: {
|
taskStats?: {
|
||||||
total: number
|
total: number
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue