@@ -1,5 +1,6 @@
// #/little-blue — the caretaker page: Little Blue's chat (reuses agent_chat) +
// a manual Actions panel (run whitelisted a ctions; approve/reject risky ones).
// a manual Actions panel. A ctions are grouped into cards (Start/Stop of the same
// guest pair together); risky ones queue for owner approval.
import { el , mount , clear } from '../dom.js' ;
import { api } from '../api.js' ;
import { wireAgentChat } from '../components/agent_chat.js' ;
@@ -7,6 +8,8 @@ import { wireAgentChat } from '../components/agent_chat.js';
const BLUE _LABELS = {
search : '🔍 looking' , list _actions : '📋 checking what she can do' , propose _action : '🔧 taking action'
} ;
const VERB = { start : 'Start' , stop : 'Stop' , shutdown : 'Shutdown' , reboot : 'Reboot' } ;
const stripVerb = ( l ) => ( ( l || '' ) . replace ( /^(Start|Stop|Restart|Reboot|Shutdown)\s+/i , '' ) . trim ( ) || l ) ;
function toast ( host , msg , kind ) {
const t = el ( 'div' , { class : 'lb-toast' + ( kind === 'err' ? ' err' : '' ) } , msg ) ;
@@ -14,48 +17,70 @@ function toast(host, msg, kind) {
setTimeout ( ( ) => t . remove ( ) , 4000 ) ;
}
function actionButton ( a , panel , toastHost ) {
const verb = a . kind === 'guest_power' ? ( VERB [ a . op ] || a . op ) : 'Restart' ;
const btn = el ( 'button' , {
class : 'lb-act ' + ( a . tier === 'risky' ? 'risky' : 'safe' ) ,
title : a . tier === 'risky' ? 'Needs your approval' : 'Runs immediately' ,
onclick : async ( ) => {
btn . disabled = true ;
try {
const r = await api . post ( ` /api/actions/ ${ a . id } /run ` ) ;
toast ( toastHost , r . executed ? ` Ran: ${ a . label } ` : ` Queued for approval: ${ a . label } ` ) ;
renderActions ( panel , toastHost ) ;
} catch ( e ) { toast ( toastHost , 'Failed: ' + e . message , 'err' ) ; btn . disabled = false ; }
}
} , verb ) ;
return btn ;
}
async function renderActions ( panel , toastHost ) {
clear ( panel ) ;
let actions = [ ] , pending = [ ] ;
try { ( { actions } = await api . get ( '/api/actions' ) ) ; } catch ( e ) { actions = [ ] ; }
try { ( { pending } = await api . get ( '/api/actions/pending' ) ) ; } catch ( e ) { pending = [ ] ; }
try { ( { actions } = await api . get ( '/api/actions' ) ) ; } catch { /* */ }
try { ( { pending } = await api . get ( '/api/actions/pending' ) ) ; } catch { /* */ }
panel . appendChild ( el ( 'div' , { class : 'lb-sec-t ' } , 'Actions' ) ) ;
if ( ! actions . length ) {
panel . appendChild ( el ( 'div' , { class : 'muted' } , 'No actions configured yet (populate config/actions.json).' ) ) ;
}
panel . appendChild ( el ( 'div' , { class : 'lb-sec' } , 'Actions' ) ) ;
if ( ! actions . length ) panel . appendChild ( el ( 'p' , { class : 'muted' } , 'No actions configured yet.' ) ) ;
// Group guest power by guest (pairs Start/Stop); service restarts stand alone.
const groups = new Map ( ) ;
for ( const a of actions ) {
panel . appendChild ( el ( 'div' , { class : 'lb-action' } ,
el ( 'span' , { class : 'lb-a-label' } , a . label || a . id ) ,
el ( 'span' , { class : 'lb-a-tier ' + a . tier } , a . tier ) ,
el ( 'button' , { class : 'lb-run' , onclick : async ( ev ) => {
ev . target . disabled = true ;
try {
const r = await api . post ( ` /api/actions/ ${ a . id } /run ` ) ;
toast ( toastHost , r . executed ? ` Ran " ${ a . label || a . id } " ` : ` Queued " ${ a . label || a . id } " for approval ` ) ;
renderActions ( pan el, toastHost ) ;
} catch ( e ) { toast ( toastHost , 'Failed: ' + e . message , 'err' ) ; ev . target . disabled = false ; }
} } , a . tier === 'risky' ? 'Request' : 'Run' ) ) ) ;
const key = a . kind === 'guest_power' ? ` g: ${ a . node } : ${ a . vmid } ` : ` s: ${ a . id } ` ;
if ( ! groups . has ( key ) ) groups . set ( key , { name : stripVerb ( a . label ) , acts : [ ] } ) ;
groups . get ( key ) . acts . push ( a ) ;
}
if ( actions . length ) {
const cards = el ( 'div' , { class : 'lb-cards' } ) ;
for ( const { name , acts } of groups . values ( ) ) {
acts . sort ( ( x , y ) => ( x . op === 'start' ? - 1 : y . op === 'start' ? 1 : 0 ) ) ;
cards . appendChild ( el ( 'div' , { class : 'card lb-card' } ,
el ( 'div' , { class : 'lb-card-title' } , name ) ,
el ( 'div' , { class : 'lb-btn-row' } , acts . map ( a => actionButton ( a , panel , toastHost ) ) ) ) ) ;
}
panel . appendChild ( cards ) ;
}
if ( pending . length ) {
panel . appendChild ( el ( 'div' , { class : 'lb-sec-t ' } , 'Awaiting approval' ) ) ;
panel . appendChild ( el ( 'div' , { class : 'lb-sec' } , 'Awaiting approval' ) ) ;
const resolve = async ( id , verb ) => {
try { await api . post ( ` /api/actions/pending/ ${ id } / ${ verb } ` ) ; toast ( toastHost , verb === 'approve' ? 'Approved' : 'Rejected' ) ; renderActions ( panel , toastHost ) ; }
catch ( e ) { toast ( toastHost , 'Failed: ' + e . message , 'err' ) ; }
} ;
const pcards = el ( 'div' , { class : 'lb-cards' } ) ;
for ( const p of pending ) {
const row = el ( 'div' , { class : 'lb-pending' } ,
el ( 'span ' , { class : 'lb-a-label ' } , p . action _id ) ,
el ( 'button ' , { class : 'ok' , onclick : ( ) => resolve ( p . id , 'approve' ) } , 'Approve' ) ,
el ( 'button' , { class : 'no ' , onclick : ( ) => resolve ( p . id , 'reject ' ) } , 'Reject ' ) ) ;
panel . appendChild ( row ) ;
async function resolve ( id , verb ) {
try { await api . post ( ` /api/actions/pending/ ${ id } / ${ verb } ` ) ; toast ( toastHost , verb === 'approve' ? 'Approved' : 'Rejected' ) ; renderActions ( panel , toastHost ) ; }
catch ( e ) { toast ( toastHost , 'Failed: ' + e . message , 'err' ) ; }
}
pcards . appendChild ( el ( 'div' , { class : 'card lb-pending-card ' } ,
el ( 'div ' , { class : 'lb-card-title ' } , p . action _id ) ,
el ( 'div ' , { class : 'lb-btn-row' } ,
el ( 'button' , { class : 'lb-act safe ' , onclick : ( ) => resolve ( p . id , 'approve ' ) } , 'Approve ' ) ,
el ( 'button' , { class : 'lb-act risky' , onclick : ( ) => resolve ( p . id , 'reject' ) } , 'Reject' ) ) ) ) ;
}
panel . appendChild ( pcards ) ;
}
}
export async function render ( main ) {
const log = el ( 'div' , { class : 'rail-log sentine l-log' } ) ;
const log = el ( 'div' , { class : 'rail-log lb -log' } ) ;
const input = el ( 'textarea' , { class : 'rail-input' , rows : 1 , placeholder : 'Tell Little Blue what’ s wrong…' } ) ;
const actionsPanel = el ( 'div' , { class : 'lb-actions' } ) ;
const toastHost = el ( 'div' , { class : 'lb-toasts' } ) ;