import React, { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import { AgentPodWakePatch } from './useAgentSettings'; interface InstallationSettings { podId: string; podName: string; status: string; wakeOnMessage: boolean; humanOnly: boolean; boardWake: boolean; heartbeatEnabled: boolean; heartbeatMinutes: number; } // Message wake is one choice of three, shown together: the human-only // filter used to appear only after "Wake on messages" was ticked (D173). const MESSAGE_WAKE_CHOICES = ['mentions', 'human', 'all'] as const; type MessageWakeChoice = typeof MESSAGE_WAKE_CHOICES[number]; const MESSAGE_WAKE_SETTINGS: Record> = { mentions: { wakeOnMessage: false, humanOnly: false }, human: { wakeOnMessage: true, humanOnly: true }, all: { wakeOnMessage: true, humanOnly: false }, }; const messageWakeChoice = (entry: InstallationSettings): MessageWakeChoice => { if (!entry.wakeOnMessage) return 'mentions'; return entry.humanOnly ? 'human' : 'all'; }; const V2AgentWakePods: React.FC<{ agentName: string; instanceId: string; updatePod: (podId: string, patch: AgentPodWakePatch) => Promise; onRemoved?: (podId: string, wasLastPod: boolean) => void; }> = ({ agentName, instanceId, updatePod, onRemoved }) => { const { t } = useTranslation(); const [installations, setInstallations] = useState([]); const [busyPod, setBusyPod] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [activePodCount, setActivePodCount] = useState(0); const [podCount, setPodCount] = useState(0); const [turnCap, setTurnCap] = useState(null); const [draftMinutes, setDraftMinutes] = useState>({}); // A pod whose heartbeat select is set to Off, until its Save applies it. const [draftOff, setDraftOff] = useState>({}); const [openPods, setOpenPods] = useState>({}); const [loadedIdentity, setLoadedIdentity] = useState(null); const query = `instanceId=${encodeURIComponent(instanceId)}`; const base = `/api/registry/agents/${encodeURIComponent(agentName)}/manage-settings`; const identity = `${agentName}:${instanceId}`; const identityRef = useRef(identity); identityRef.current = identity; // Per-pod save state; see save() below. const committedRef = useRef>({}); const queuedRef = useRef>({}); const sendingRef = useRef>({}); useEffect(() => { let active = true; setLoading(true); setInstallations([]); setBusyPod(null); setError(null); setDraftMinutes({}); setDraftOff({}); committedRef.current = {}; queuedRef.current = {}; sendingRef.current = {}; const load = async () => { try { const settingsResult = await axios.get<{ installations: InstallationSettings[]; activePodCount: number; podCount: number; turnCap?: number; }>(`${base}?${query}`); if (!active) return; const rows = Array.isArray(settingsResult.data.installations) ? settingsResult.data.installations : []; setInstallations(rows); committedRef.current = Object.fromEntries(rows.map((row) => [row.podId, row])); setActivePodCount(settingsResult.data.activePodCount ?? rows.filter((row) => row.status === 'active').length); setPodCount(settingsResult.data.podCount ?? rows.length); setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes]))); setTurnCap(settingsResult.data.turnCap ?? null); } catch (requestError) { if (active) setError((requestError as { response?: { status?: number } })?.response?.status === 403 ? t('agentSettings.forbidden') : t('agentSettings.loadError')); } finally { if (active) { setLoadedIdentity(identity); setLoading(false); } } }; void load(); return () => { active = false; }; }, [base, query, identity, t]); // Saves per pod go out one at a time, newest pick last. The message-wake // radios stay enabled while a save is in flight (disabling them dropped // keyboard focus to the page), so a second pick queues behind the first // rather than racing it to the server. A failure rolls the row back to the // last settings the server accepted, never to an unsaved pick. const save = async (entry: InstallationSettings, changes: Partial) => { const requestedIdentity = identity; const { podId } = entry; const next = { ...entry, ...changes }; // An agent switch replaces these objects. Hold the ones this save started // with, so a loop still in flight for the old agent never writes into the // new agent's queue. const committed = committedRef.current; const queued = queuedRef.current; const sending = sendingRef.current; if (!committed[podId]) committed[podId] = entry; queued[podId] = next; setBusyPod(podId); setError(null); // Show the pick at once, so the next arrow key moves on from it. setInstallations((current) => current.map((item) => item.podId === podId ? next : item)); if (sending[podId]) return; sending[podId] = true; try { while (queued[podId]) { const target = queued[podId] as InstallationSettings; queued[podId] = undefined; try { // eslint-disable-next-line no-await-in-loop await updatePod(podId, { wakeOnMessage: target.wakeOnMessage, boardWake: target.boardWake, humanOnly: target.humanOnly, heartbeatEnabled: target.heartbeatEnabled, heartbeatMinutes: target.heartbeatMinutes, }); committed[podId] = target; } catch (requestError) { queued[podId] = undefined; if (identityRef.current === requestedIdentity) { const saved = committed[podId]; setInstallations((current) => current.map((item) => item.podId === podId ? saved : item)); setError((requestError as { response?: { status?: number } })?.response?.status === 403 ? t('agentSettings.forbidden') : t('agentSettings.saveError')); } } } } finally { sending[podId] = false; if (identityRef.current === requestedIdentity) setBusyPod(null); } }; const remove = async (entry: InstallationSettings) => { // eslint-disable-next-line no-alert const willRevokeKeys = activePodCount <= (entry.status === 'active' ? 1 : 0); const confirmKey = podCount === 1 ? 'agentSettings.removeLastConfirm' : willRevokeKeys ? 'agentSettings.removeDisconnectConfirm' : 'agentSettings.removeConfirm'; if (!window.confirm(t(confirmKey, { podName: entry.podName }))) return; const requestedIdentity = identity; setBusyPod(entry.podId); setError(null); try { await axios.delete( `/api/registry/agents/${encodeURIComponent(agentName)}/pods/${encodeURIComponent(entry.podId)}?${query}`, ); if (identityRef.current === requestedIdentity) { setInstallations((current) => current.filter((item) => item.podId !== entry.podId)); setPodCount((count) => Math.max(0, count - 1)); if (entry.status === 'active') setActivePodCount((count) => Math.max(0, count - 1)); onRemoved?.(entry.podId, podCount === 1); } } catch (requestError) { if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403 ? t('agentSettings.forbidden') : t('agentSettings.removeError')); } finally { if (identityRef.current === requestedIdentity) setBusyPod(null); } }; // Sum of the draft heartbeat turns, so the header previews the cap as the select changes. const turnsUsed = installations.reduce((total, entry) => { const off = draftOff[entry.podId] ?? !entry.heartbeatEnabled; const minutes = draftMinutes[entry.podId] ?? entry.heartbeatMinutes; return off ? total : total + Math.ceil(1440 / minutes); }, 0); const overCap = turnCap !== null && turnsUsed > turnCap; if (loading || loadedIdentity !== identity) { return
{t('agentProfile.loading')}
; } return (
{error &&

{error}

}

{t('agentSettings.wakeTitle')} {t('agentSettings.mentionsAlwaysWake')} {turnCap === null ? t('agentSettings.turnsUsedNoCap', { used: turnsUsed }) : t('agentSettings.turnsUsed', { used: turnsUsed, cap: turnCap })}

{overCap &&

{t('agentSettings.overCap')}

}
{installations.length ? ( {installations.map((entry) => ( ))}
{t('agentSettings.podsTitle')} {t('agentSettings.colMessages')} {t('agentSettings.colBoard')} {t('agentSettings.colHeartbeat')} {t('agentSettings.remove')}
{entry.podName} {t(`agentSettings.messageWakeShort_${messageWakeChoice(entry)}`)}
{MESSAGE_WAKE_CHOICES.map((choice) => ( ))}
{(() => { const off = draftOff[entry.podId] ?? !entry.heartbeatEnabled; const minutes = draftMinutes[entry.podId] ?? entry.heartbeatMinutes; const dirty = off === entry.heartbeatEnabled || (!off && minutes !== entry.heartbeatMinutes); return ( <> {dirty && } ); })()}
) :

{t('agentSettings.noPods')}

}
); }; export default V2AgentWakePods;