Add frontend UI

This commit is contained in:
Zac Gaetano 2026-04-16 12:13:35 -04:00
parent d706959f65
commit 7c0b99cb3e

311
public/index.html Normal file
View file

@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>herdctl Agent Editor</title>
<style>
:root {
--bg:#0f1117;--surface:#1a1d27;--surface2:#222535;--border:#2e3250;
--accent:#5b7cf6;--accent2:#7c3aed;--green:#22c55e;--red:#ef4444;
--yellow:#f59e0b;--text:#e2e8f0;--muted:#64748b;
--mono:'JetBrains Mono','Fira Code',monospace;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;}
header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;gap:16px;flex-shrink:0;}
.logo{display:flex;align-items:center;gap:10px;}
.logo-icon{width:28px;height:28px;background:linear-gradient(135deg,var(--accent),var(--accent2));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;}
.logo-text{font-weight:700;font-size:16px;}
.logo-sub{font-size:11px;color:var(--muted);}
.spacer{flex:1;}
.status-bar{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);}
.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);}
.layout{display:flex;flex:1;overflow:hidden;}
.sidebar{width:260px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;}
.sidebar-header{padding:14px 16px 10px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;display:flex;justify-content:space-between;align-items:center;}
.btn-new{background:var(--accent);color:#fff;border:none;border-radius:5px;padding:3px 10px;font-size:11px;cursor:pointer;font-weight:600;}
.agent-list{flex:1;overflow-y:auto;padding:4px 8px 8px;}
.agent-item{padding:10px 12px;border-radius:8px;cursor:pointer;border:1px solid transparent;transition:all .15s;margin-bottom:4px;}
.agent-item:hover{background:var(--surface2);border-color:var(--border);}
.agent-item.active{background:var(--surface2);border-color:var(--accent);}
.agent-name{font-size:13px;font-weight:600;margin-bottom:3px;}
.agent-desc{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.agent-tags{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap;}
.tag{font-size:10px;background:var(--border);color:var(--muted);border-radius:3px;padding:1px 6px;}
.agent-status{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--muted);margin-top:4px;}
.sdot{width:5px;height:5px;border-radius:50%;background:var(--green);}
.sdot.running{background:var(--yellow);animation:pulse 1s infinite;}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;}
.editor-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 20px;display:flex;align-items:center;gap:10px;flex-shrink:0;}
.editor-filename{font-family:var(--mono);font-size:13px;font-weight:600;}
.btn{border:none;border-radius:6px;padding:6px 14px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:6px;}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.btn-save{background:var(--accent);color:#fff;}
.btn-save:hover:not(:disabled){background:#4a6cf0;}
.btn-trigger{background:var(--green);color:#000;}
.btn-trigger:hover:not(:disabled){opacity:.85;}
.btn-discard{background:var(--surface2);color:var(--text);border:1px solid var(--border);}
.btn-discard:hover:not(:disabled){border-color:var(--accent);}
.editor-wrap{flex:1;display:flex;overflow:hidden;}
textarea#editor{flex:1;background:var(--bg);color:var(--text);font-family:var(--mono);font-size:13px;line-height:1.7;border:none;outline:none;resize:none;padding:20px 24px;tab-size:2;white-space:pre;overflow-wrap:normal;overflow-x:auto;}
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;}
.empty-icon{font-size:48px;opacity:.3;}
.empty-text{font-size:14px;}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 18px;border-radius:8px;font-size:13px;font-weight:500;z-index:999;transition:all .3s;transform:translateY(80px);opacity:0;max-width:380px;}
.toast.show{transform:translateY(0);opacity:1;}
.toast.success{background:#14532d;border:1px solid var(--green);color:#bbf7d0;}
.toast.error{background:#450a0a;border:1px solid var(--red);color:#fecaca;}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center;}
.overlay.show{display:flex;}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;width:480px;}
.modal h3{font-size:15px;margin-bottom:16px;}
.form-group{margin-bottom:14px;}
.form-group label{display:block;font-size:11px;color:var(--muted);margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px;}
.form-group input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;color:var(--text);font-size:13px;outline:none;}
.form-group input:focus{border-color:var(--accent);}
.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px;}
.dirty{color:var(--yellow);font-size:11px;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
::-webkit-scrollbar{width:6px;height:6px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px;}
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-icon"></div>
<div>
<div class="logo-text">herdctl editor</div>
<div class="logo-sub">Wild Dragon Agent Fleet</div>
</div>
</div>
<div class="spacer"></div>
<div class="status-bar">
<div class="dot" id="herdctlDot"></div>
<span id="herdctlStatus">connecting...</span>
</div>
</header>
<div class="layout">
<div class="sidebar">
<div class="sidebar-header">
Agents
<button class="btn-new" onclick="showNewModal()">+ New</button>
</div>
<div class="agent-list" id="agentList">
<div style="padding:20px;text-align:center;color:var(--muted);font-size:12px;">Loading...</div>
</div>
</div>
<div class="main">
<div id="editorToolbar" class="editor-toolbar" style="display:none">
<span class="editor-filename" id="editorFilename"></span>
<span class="dirty" id="dirtyIndicator"></span>
<div class="spacer"></div>
<button class="btn btn-discard" onclick="discardChanges()">↩ Discard</button>
<button class="btn btn-trigger" id="btnTrigger" onclick="triggerAgent()">▶ Run Now</button>
<button class="btn btn-save" id="btnSave" onclick="saveAgent()">💾 Save &amp; Restart</button>
</div>
<div class="editor-wrap">
<div class="empty-state" id="emptyState">
<div class="empty-icon">📋</div>
<div class="empty-text">Select an agent to edit</div>
</div>
<textarea id="editor" spellcheck="false" style="display:none" oninput="markDirty()"></textarea>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="overlay" id="newModal">
<div class="modal">
<h3>Create New Agent</h3>
<div class="form-group">
<label>Agent Name</label>
<input id="newAgentName" type="text" placeholder="e.g. weekly-report" oninput="updateNewFilename()">
</div>
<div class="form-group">
<label>Filename</label>
<input id="newAgentFilename" type="text" readonly style="opacity:.6">
</div>
<div class="modal-actions">
<button class="btn btn-discard" onclick="hideNewModal()">Cancel</button>
<button class="btn btn-save" onclick="createAgent()">Create</button>
</div>
</div>
</div>
<script>
let currentFile = null, originalContent = null, agentStatuses = {};
const TEMPLATE = n => `name: ${n}
description: "Describe what this agent does"
working_directory: /workspace
identity:
name: "${n} Agent"
role: "Assistant"
personality: "Helpful, concise"
system_prompt: |
You are a helpful agent for Wild Dragon / BMG.
Describe your agent role and behavior here.
schedules:
daily:
type: cron
expression: "0 9 * * *"
prompt: |
Describe what the agent should do when triggered.
runtime: cli
model: claude-sonnet-4-20250514
max_turns: 50
permission_mode: bypassPermissions
allowed_tools:
- Read
- Write
- Edit
- Bash
- mcp__mcp-server__*
`;
async function loadAgents() {
try {
const [ar, sr] = await Promise.all([
fetch('/api/agents'),
fetch('/api/status').catch(() => null)
]);
const agents = await ar.json();
const statuses = sr ? await sr.json().catch(() => []) : [];
agentStatuses = {};
statuses.forEach(s => { agentStatuses[s.name] = s; });
const list = document.getElementById('agentList');
if (!agents.length) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--muted);font-size:12px;">No agents found</div>';
return;
}
list.innerHTML = agents.map(a => {
const st = agentStatuses[a.name];
const running = st?.status === 'running';
const next = st?.schedules?.[0]?.nextRunAt;
const nextStr = next ? new Date(next).toLocaleString([],{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '';
return `<div class="agent-item${currentFile===a.filename?' active':''}" onclick="loadAgent('${a.filename}')">
<div class="agent-name">${a.name}</div>
<div class="agent-desc">${a.description||'No description'}</div>
<div class="agent-tags">${a.schedules.map(s=>`<span class="tag">⏰ ${s}</span>`).join('')}</div>
<div class="agent-status"><div class="sdot${running?' running':''}"></div>${running?'Running':nextStr?`Next: ${nextStr}`:'Idle'}</div>
</div>`;
}).join('');
document.getElementById('herdctlDot').style.background = 'var(--green)';
document.getElementById('herdctlStatus').textContent = `herdctl • ${agents.length} agent${agents.length!==1?'s':''}`;
} catch(e) {
document.getElementById('herdctlDot').style.background = 'var(--red)';
document.getElementById('herdctlStatus').textContent = 'herdctl offline';
}
}
async function loadAgent(filename) {
if (isDirty() && !confirm('Discard unsaved changes?')) return;
try {
const r = await fetch(`/api/agents/${filename}`);
const d = await r.json();
currentFile = filename;
originalContent = d.content;
document.getElementById('editor').value = d.content;
document.getElementById('editorFilename').textContent = filename;
document.getElementById('editor').style.display = 'block';
document.getElementById('emptyState').style.display = 'none';
document.getElementById('editorToolbar').style.display = 'flex';
document.getElementById('dirtyIndicator').textContent = '';
loadAgents();
} catch(e) { showToast('Failed to load agent','error'); }
}
function isDirty() { return currentFile && document.getElementById('editor').value !== originalContent; }
function markDirty() { document.getElementById('dirtyIndicator').textContent = isDirty() ? '● unsaved' : ''; }
function discardChanges() {
if (isDirty() && !confirm('Discard unsaved changes?')) return;
document.getElementById('editor').value = originalContent;
document.getElementById('dirtyIndicator').textContent = '';
}
async function saveAgent() {
if (!currentFile) return;
const content = document.getElementById('editor').value;
const btn = document.getElementById('btnSave');
btn.disabled = true; btn.textContent = 'Saving...';
try {
const r = await fetch(`/api/agents/${currentFile}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({content})});
const d = await r.json();
if (!r.ok) throw new Error(d.error);
originalContent = content;
document.getElementById('dirtyIndicator').textContent = '';
showToast('✓ '+d.message,'success');
setTimeout(loadAgents,2000);
} catch(e) { showToast('Error: '+e.message,'error'); }
finally { btn.disabled=false; btn.innerHTML='💾 Save &amp; Restart'; }
}
async function triggerAgent() {
if (!currentFile || !confirm('Run this agent now?')) return;
const btn = document.getElementById('btnTrigger');
btn.disabled=true; btn.textContent='Triggering...';
try {
const r = await fetch(`/api/agents/${currentFile}/trigger`,{method:'POST'});
const d = await r.json();
if (!r.ok) throw new Error(d.error);
showToast('▶ '+d.message,'success');
setTimeout(loadAgents,1500);
} catch(e) { showToast('Error: '+e.message,'error'); }
finally { btn.disabled=false; btn.textContent='▶ Run Now'; }
}
function showNewModal() {
document.getElementById('newModal').classList.add('show');
document.getElementById('newAgentName').value='';
document.getElementById('newAgentFilename').value='';
setTimeout(()=>document.getElementById('newAgentName').focus(),50);
}
function hideNewModal() { document.getElementById('newModal').classList.remove('show'); }
function updateNewFilename() {
const n=document.getElementById('newAgentName').value.trim().toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,'');
document.getElementById('newAgentFilename').value = n ? n+'.yaml' : '';
}
async function createAgent() {
const name=document.getElementById('newAgentName').value.trim();
const filename=document.getElementById('newAgentFilename').value;
if (!name||!filename){showToast('Enter an agent name','error');return;}
try {
const r=await fetch('/api/agents',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,content:TEMPLATE(name)})});
const d=await r.json();
if (!r.ok) throw new Error(d.error);
hideNewModal(); await loadAgents(); await loadAgent(filename);
showToast('✓ Agent created','success');
} catch(e){showToast('Error: '+e.message,'error');}
}
document.getElementById('editor').addEventListener('keydown',e=>{
if(e.key==='Tab'){
e.preventDefault();
const t=e.target,s=t.selectionStart,en=t.selectionEnd;
t.value=t.value.substring(0,s)+' '+t.value.substring(en);
t.selectionStart=t.selectionEnd=s+2; markDirty();
}
if((e.ctrlKey||e.metaKey)&&e.key==='s'){e.preventDefault();saveAgent();}
});
function showToast(msg,type='success'){
const t=document.getElementById('toast');
t.textContent=msg; t.className=`toast ${type} show`;
setTimeout(()=>t.classList.remove('show'),4000);
}
document.getElementById('newModal').addEventListener('click',e=>{if(e.target===e.currentTarget)hideNewModal();});
loadAgents();
setInterval(loadAgents,15000);
</script>
</body>
</html>