From bdcc33f391441184c0b9cdbaecfdb8beb81b2652 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 3 Mar 2026 21:09:02 +0000 Subject: Add clickable fold to expand hidden completed/failed tasks Replace the static "N hidden tasks" label with a toggle button that expands an inline fold showing the hidden task cards dimmed at 0.6 opacity. Fold state is module-level so it survives poll cycles within a session but resets on reload. Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 703 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 699 insertions(+), 4 deletions(-) (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index e66c878..6289d00 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,5 @@ -const API_BASE = window.location.origin; +const BASE_PATH = document.querySelector('meta[name="base-path"]')?.content ?? ''; +const API_BASE = window.location.origin + BASE_PATH; // ── Fetch ───────────────────────────────────────────────────────────────────── @@ -8,6 +9,12 @@ async function fetchTasks() { return res.json(); } +async function fetchTemplates() { + const res = await fetch(`${API_BASE}/api/templates`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + // ── Render ──────────────────────────────────────────────────────────────────── function formatDate(iso) { @@ -70,15 +77,48 @@ function createTaskCard(task) { const btn = document.createElement('button'); btn.className = 'btn-run'; btn.textContent = 'Run'; - btn.addEventListener('click', () => handleRun(task.id, btn, footer)); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + handleRun(task.id, btn, footer); + }); footer.appendChild(btn); card.appendChild(footer); } + card.addEventListener('click', () => openTaskPanel(task.id)); + return card; } +// ── Filter ──────────────────────────────────────────────────────────────────── + +const HIDE_STATES = new Set(['COMPLETED', 'FAILED']); + +let showHiddenFold = false; + +function filterTasks(tasks, hideCompletedFailed = false) { + if (!hideCompletedFailed) return tasks; + return tasks.filter(t => !HIDE_STATES.has(t.state)); +} + +function getHideCompletedFailed() { + const stored = localStorage.getItem('hideCompletedFailed'); + return stored === null ? true : stored === 'true'; +} + +function setHideCompletedFailed(val) { + localStorage.setItem('hideCompletedFailed', String(val)); +} + +function updateToggleButton() { + const btn = document.getElementById('btn-toggle-completed'); + if (!btn) return; + btn.textContent = getHideCompletedFailed() + ? 'Show completed/failed' + : 'Hide completed/failed'; +} + function renderTaskList(tasks) { const container = document.querySelector('.task-list'); @@ -87,11 +127,93 @@ function renderTaskList(tasks) { return; } + const hide = getHideCompletedFailed(); + const visible = filterTasks(tasks, hide); + const hiddenCount = tasks.length - visible.length; + // Replace contents with task cards container.innerHTML = ''; - for (const task of tasks) { + for (const task of visible) { container.appendChild(createTaskCard(task)); } + + if (hiddenCount > 0) { + const info = document.createElement('button'); + info.className = 'hidden-tasks-info'; + const arrow = showHiddenFold ? '▼' : '▶'; + info.textContent = `${arrow} ${hiddenCount} hidden task${hiddenCount === 1 ? '' : 's'}`; + info.addEventListener('click', () => { + showHiddenFold = !showHiddenFold; + renderTaskList(tasks); + }); + container.appendChild(info); + + if (showHiddenFold) { + const fold = document.createElement('div'); + fold.className = 'hidden-tasks-fold'; + const hiddenTasks = tasks.filter(t => HIDE_STATES.has(t.state)); + for (const task of hiddenTasks) { + fold.appendChild(createTaskCard(task)); + } + container.appendChild(fold); + } + } +} + +function createTemplateCard(tmpl) { + const card = document.createElement('div'); + card.className = 'template-card'; + + const name = document.createElement('div'); + name.className = 'template-name'; + name.textContent = tmpl.name; + card.appendChild(name); + + if (tmpl.description) { + const desc = document.createElement('div'); + desc.className = 'template-description'; + desc.textContent = tmpl.description; + card.appendChild(desc); + } + + if (tmpl.tags && tmpl.tags.length > 0) { + const tagsEl = document.createElement('div'); + tagsEl.className = 'template-tags'; + for (const tag of tmpl.tags) { + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + chip.textContent = tag; + tagsEl.appendChild(chip); + } + card.appendChild(tagsEl); + } + + const footer = document.createElement('div'); + footer.className = 'template-card-footer'; + + const delBtn = document.createElement('button'); + delBtn.className = 'btn-danger btn-sm'; + delBtn.textContent = 'Delete'; + delBtn.addEventListener('click', () => deleteTemplate(tmpl.id)); + + footer.appendChild(delBtn); + card.appendChild(footer); + + return card; +} + +function renderTemplateList(templates) { + const container = document.querySelector('.template-list'); + + if (!templates || templates.length === 0) { + container.innerHTML = '
No templates yet.
'; + return; + } + + container.innerHTML = ''; + for (const tmpl of templates) { + container.appendChild(createTemplateCard(tmpl)); + } } // ── Run action ──────────────────────────────────────────────────────────────── @@ -130,6 +252,23 @@ async function handleRun(taskId, btn, footer) { } } +// ── Delete template ──────────────────────────────────────────────────────────── + +async function deleteTemplate(id) { + if (!window.confirm('Delete this template?')) return; + + const res = await fetch(`${API_BASE}/api/templates/${id}`, { method: 'DELETE' }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} + alert(`Failed to delete: ${msg}`); + return; + } + + const templates = await fetchTemplates(); + renderTemplateList(templates); +} + // ── Polling ─────────────────────────────────────────────────────────────────── async function poll() { @@ -147,6 +286,562 @@ function startPolling(intervalMs = 10_000) { setInterval(poll, intervalMs); } +// ── Elaborate (Draft with AI) ───────────────────────────────────────────────── + +async function elaborateTask(prompt) { + const res = await fetch(`${API_BASE}/api/tasks/elaborate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const body = await res.json(); msg = body.error || msg; } catch {} + throw new Error(msg); + } + return res.json(); +} + +// ── Task modal ──────────────────────────────────────────────────────────────── + +function openTaskModal() { + document.getElementById('task-modal').showModal(); +} + +function closeTaskModal() { + document.getElementById('task-modal').close(); + document.getElementById('task-form').reset(); + document.getElementById('elaborate-prompt').value = ''; +} + +async function createTask(formData) { + const body = { + name: formData.get('name'), + description: '', + claude: { + model: formData.get('model'), + instructions: formData.get('instructions'), + working_dir: formData.get('working_dir'), + max_budget_usd: parseFloat(formData.get('max_budget_usd')), + }, + timeout: formData.get('timeout'), + priority: formData.get('priority'), + tags: [], + }; + + const res = await fetch(`${API_BASE}/api/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + + closeTaskModal(); + const tasks = await fetchTasks(); + renderTaskList(tasks); +} + +// ── Template modal ──────────────────────────────────────────────────────────── + +function openTemplateModal() { + document.getElementById('template-modal').showModal(); +} + +function closeTemplateModal() { + document.getElementById('template-modal').close(); + document.getElementById('template-form').reset(); +} + +async function saveTemplate(formData) { + const splitTrim = val => val.split(',').map(s => s.trim()).filter(Boolean); + + const body = { + name: formData.get('name'), + description: formData.get('description'), + claude: { + model: formData.get('model'), + instructions: formData.get('instructions'), + working_dir: formData.get('working_dir'), + max_budget_usd: parseFloat(formData.get('max_budget_usd')), + allowed_tools: splitTrim(formData.get('allowed_tools') || ''), + }, + timeout: formData.get('timeout'), + priority: formData.get('priority'), + tags: splitTrim(formData.get('tags') || ''), + }; + + const res = await fetch(`${API_BASE}/api/templates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + + closeTemplateModal(); + const templates = await fetchTemplates(); + renderTemplateList(templates); +} + +// ── Task side panel ─────────────────────────────────────────────────────────── + +// Format Go's task.Duration JSON value {"Duration": } to human string. +function formatDurationNs(timeout) { + const ns = timeout && timeout.Duration; + if (!ns) return '—'; + const secs = ns / 1e9; + if (secs < 60) return `${secs.toFixed(1)}s`; + const mins = Math.floor(secs / 60); + const remSecs = Math.floor(secs % 60); + if (mins < 60) return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins > 0 ? `${hrs}h ${remMins}m` : `${hrs}h`; +} + +function formatDateLong(iso) { + if (!iso) return '—'; + return new Date(iso).toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); +} + +function openTaskPanel(taskId) { + const panel = document.getElementById('task-panel'); + const backdrop = document.getElementById('task-panel-backdrop'); + const content = document.getElementById('task-panel-content'); + document.getElementById('task-panel-title').textContent = 'Task Details'; + + content.innerHTML = ''; + const loading = document.createElement('div'); + loading.className = 'panel-loading'; + loading.textContent = 'Loading…'; + content.appendChild(loading); + + backdrop.hidden = false; + panel.classList.add('open'); + + Promise.all([ + fetch(`${API_BASE}/api/tasks/${taskId}`).then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }), + fetch(`${API_BASE}/api/tasks/${taskId}/executions`).then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }), + ]).then(([task, executions]) => { + renderTaskPanel(task, executions); + }).catch(err => { + content.innerHTML = ''; + const errEl = document.createElement('div'); + errEl.className = 'panel-fetch-error'; + errEl.textContent = `Failed to load: ${err.message}`; + content.appendChild(errEl); + }); +} + +function closeTaskPanel() { + document.getElementById('task-panel').classList.remove('open'); + document.getElementById('task-panel-backdrop').hidden = true; +} + +function makeSection(title) { + const section = document.createElement('div'); + section.className = 'panel-section'; + const hdr = document.createElement('div'); + hdr.className = 'panel-section-title'; + hdr.textContent = title; + section.appendChild(hdr); + return section; +} + +function makeMetaItem(label, valueText, opts = {}) { + const item = document.createElement('div'); + item.className = 'meta-item' + (opts.fullWidth ? ' full-width' : ''); + + const lbl = document.createElement('div'); + lbl.className = 'meta-label'; + lbl.textContent = label; + item.appendChild(lbl); + + if (opts.badge) { + const badge = document.createElement('span'); + badge.className = 'state-badge'; + badge.dataset.state = valueText; + badge.textContent = valueText.replace(/_/g, ' '); + item.appendChild(badge); + } else if (opts.code) { + const pre = document.createElement('pre'); + pre.className = 'panel-code'; + pre.textContent = valueText; + item.appendChild(pre); + } else if (opts.tags) { + const wrap = document.createElement('div'); + if (opts.tags.length > 0) { + wrap.className = 'panel-tags'; + for (const tag of opts.tags) { + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + chip.textContent = tag; + wrap.appendChild(chip); + } + } else { + wrap.className = 'meta-value muted'; + wrap.textContent = '—'; + } + item.appendChild(wrap); + } else { + const val = document.createElement('div'); + val.className = 'meta-value' + (opts.mono ? ' mono' : '') + (opts.muted ? ' muted' : ''); + val.textContent = valueText || '—'; + item.appendChild(val); + } + return item; +} + +function renderTaskPanel(task, executions) { + document.getElementById('task-panel-title').textContent = task.name; + const content = document.getElementById('task-panel-content'); + content.innerHTML = ''; + + // ── Overview ── + const overview = makeSection('Overview'); + const overviewGrid = document.createElement('div'); + overviewGrid.className = 'meta-grid'; + overviewGrid.append( + makeMetaItem('State', task.state, { badge: true }), + makeMetaItem('Priority', task.priority), + makeMetaItem('Created', formatDateLong(task.created_at)), + makeMetaItem('Updated', formatDateLong(task.updated_at)), + makeMetaItem('ID', task.id, { fullWidth: true, mono: true }), + ); + if (task.parent_task_id) { + overviewGrid.append(makeMetaItem('Parent Task', task.parent_task_id, { fullWidth: true, mono: true })); + } + if (task.tags && task.tags.length >= 0) { + overviewGrid.append(makeMetaItem('Tags', '', { fullWidth: true, tags: task.tags || [] })); + } + if (task.description) { + overviewGrid.append(makeMetaItem('Description', task.description, { fullWidth: true })); + } + overview.appendChild(overviewGrid); + content.appendChild(overview); + + // ── Claude Config ── + const c = task.claude || {}; + const claudeSection = makeSection('Claude Config'); + const claudeGrid = document.createElement('div'); + claudeGrid.className = 'meta-grid'; + claudeGrid.append( + makeMetaItem('Model', c.model), + makeMetaItem('Max Budget', c.max_budget_usd != null ? `$${c.max_budget_usd.toFixed(2)}` : '—'), + makeMetaItem('Working Dir', c.working_dir), + makeMetaItem('Permission Mode', c.permission_mode || 'default'), + ); + if (c.allowed_tools && c.allowed_tools.length > 0) { + claudeGrid.append(makeMetaItem('Allowed Tools', c.allowed_tools.join(', '), { fullWidth: true })); + } + if (c.disallowed_tools && c.disallowed_tools.length > 0) { + claudeGrid.append(makeMetaItem('Disallowed Tools', c.disallowed_tools.join(', '), { fullWidth: true })); + } + if (c.instructions) { + claudeGrid.append(makeMetaItem('Instructions', c.instructions, { fullWidth: true, code: true })); + } + if (c.system_prompt_append) { + claudeGrid.append(makeMetaItem('System Prompt Append', c.system_prompt_append, { fullWidth: true, code: true })); + } + claudeSection.appendChild(claudeGrid); + content.appendChild(claudeSection); + + // ── Execution Settings ── + const settingsSection = makeSection('Execution Settings'); + const settingsGrid = document.createElement('div'); + settingsGrid.className = 'meta-grid'; + settingsGrid.append( + makeMetaItem('Timeout', formatDurationNs(task.timeout)), + makeMetaItem('Retry Attempts', String(task.retry ? task.retry.max_attempts : 1)), + makeMetaItem('Backoff', task.retry ? task.retry.backoff : '—'), + ); + if (task.depends_on && task.depends_on.length > 0) { + settingsGrid.append(makeMetaItem('Depends On', task.depends_on.join(', '), { fullWidth: true, mono: true })); + } + settingsSection.appendChild(settingsGrid); + content.appendChild(settingsSection); + + // ── Executions ── + const execSection = makeSection('Executions'); + if (!executions || executions.length === 0) { + const none = document.createElement('div'); + none.className = 'meta-value muted'; + none.textContent = 'No executions yet.'; + execSection.appendChild(none); + } else { + const list = document.createElement('div'); + list.className = 'executions-list'; + // Newest first + for (const exec of [...executions].reverse()) { + const row = document.createElement('div'); + row.className = 'execution-row'; + + const shortId = document.createElement('span'); + shortId.className = 'execution-id'; + shortId.textContent = exec.ID ? exec.ID.slice(0, 8) : '—'; + row.appendChild(shortId); + + const badge = document.createElement('span'); + badge.className = 'state-badge'; + badge.dataset.state = exec.Status || ''; + badge.textContent = (exec.Status || '—').replace(/_/g, ' '); + row.appendChild(badge); + + const times = document.createElement('span'); + times.className = 'execution-times'; + const start = exec.StartTime ? formatDate(exec.StartTime) : '?'; + const end = exec.EndTime && exec.EndTime !== '0001-01-01T00:00:00Z' ? formatDate(exec.EndTime) : '…'; + times.textContent = `${start} → ${end}`; + row.appendChild(times); + + if (exec.CostUSD != null && exec.CostUSD > 0) { + const cost = document.createElement('span'); + cost.className = 'execution-cost'; + cost.textContent = `$${exec.CostUSD.toFixed(4)}`; + row.appendChild(cost); + } + + const exitEl = document.createElement('span'); + exitEl.className = 'execution-exit'; + exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`; + row.appendChild(exitEl); + + const logsBtn = document.createElement('button'); + logsBtn.className = 'btn-view-logs'; + logsBtn.textContent = 'View Logs'; + logsBtn.addEventListener('click', () => handleViewLogs(exec.ID)); + row.appendChild(logsBtn); + + list.appendChild(row); + } + execSection.appendChild(list); + } + content.appendChild(execSection); +} + +async function handleViewLogs(execId) { + const modal = document.getElementById('logs-modal'); + const body = document.getElementById('logs-modal-body'); + document.getElementById('logs-modal-title').textContent = `Execution ${execId.slice(0, 8)}`; + body.innerHTML = '
Loading…
'; + modal.showModal(); + + try { + const res = await fetch(`${API_BASE}/api/executions/${execId}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const exec = await res.json(); + + body.innerHTML = ''; + const grid = document.createElement('div'); + grid.className = 'meta-grid'; + + const entries = [ + ['ID', exec.ID, { fullWidth: true, mono: true }], + ['Status', exec.Status, { badge: true }], + ['Exit Code', String(exec.ExitCode ?? '—'), {}], + ['Cost', exec.CostUSD > 0 ? `$${exec.CostUSD.toFixed(4)}` : '—', {}], + ['Start', formatDateLong(exec.StartTime), {}], + ['End', exec.EndTime && !exec.EndTime.startsWith('0001-') ? formatDateLong(exec.EndTime) : '—', {}], + ['Error', exec.ErrorMsg || '—', { fullWidth: true }], + ['Stdout', exec.StdoutPath || '—', { fullWidth: true, mono: true }], + ['Stderr', exec.StderrPath || '—', { fullWidth: true, mono: true }], + ]; + for (const [label, value, opts] of entries) { + grid.appendChild(makeMetaItem(label, value, opts)); + } + body.appendChild(grid); + } catch (err) { + body.innerHTML = `
Failed to load: ${err.message}
`; + } +} + +// ── Tab switching ───────────────────────────────────────────────────────────── + +function switchTab(name) { + // Update tab button active state + document.querySelectorAll('.tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === name); + }); + + // Show/hide panels + document.querySelectorAll('[data-panel]').forEach(panel => { + if (panel.dataset.panel === name) { + panel.removeAttribute('hidden'); + } else { + panel.setAttribute('hidden', ''); + } + }); + + // Show/hide the header New Task button (only relevant on tasks tab) + document.getElementById('btn-new-task').style.display = + name === 'tasks' ? '' : 'none'; + + if (name === 'templates') { + fetchTemplates().then(renderTemplateList).catch(() => { + document.querySelector('.template-list').innerHTML = + '
Could not reach server.
'; + }); + } +} + // ── Boot ────────────────────────────────────────────────────────────────────── -startPolling(); +document.addEventListener('DOMContentLoaded', () => { + updateToggleButton(); + document.getElementById('btn-toggle-completed').addEventListener('click', async () => { + setHideCompletedFailed(!getHideCompletedFailed()); + updateToggleButton(); + await poll(); + }); + + startPolling(); + + // Side panel close + document.getElementById('btn-close-panel').addEventListener('click', closeTaskPanel); + document.getElementById('task-panel-backdrop').addEventListener('click', closeTaskPanel); + + // Execution logs modal close + document.getElementById('btn-close-logs').addEventListener('click', () => { + document.getElementById('logs-modal').close(); + }); + + // Tab bar + document.querySelectorAll('.tab').forEach(btn => { + btn.addEventListener('click', () => switchTab(btn.dataset.tab)); + }); + + // Task modal + document.getElementById('btn-new-task').addEventListener('click', openTaskModal); + document.getElementById('btn-cancel-task').addEventListener('click', closeTaskModal); + + // Draft with AI button + const btnElaborate = document.getElementById('btn-elaborate'); + btnElaborate.addEventListener('click', async () => { + const prompt = document.getElementById('elaborate-prompt').value.trim(); + if (!prompt) { + const form = document.getElementById('task-form'); + // Remove previous error + const prev = form.querySelector('.form-error'); + if (prev) prev.remove(); + const errEl = document.createElement('p'); + errEl.className = 'form-error'; + errEl.textContent = 'Please enter a description before drafting.'; + form.querySelector('.elaborate-section').appendChild(errEl); + return; + } + + btnElaborate.disabled = true; + btnElaborate.textContent = 'Drafting…'; + + // Remove any previous errors or banners + const form = document.getElementById('task-form'); + form.querySelectorAll('.form-error, .elaborate-banner').forEach(el => el.remove()); + + try { + const result = await elaborateTask(prompt); + + // Populate form fields + const f = document.getElementById('task-form'); + if (result.name) + f.querySelector('[name="name"]').value = result.name; + if (result.claude && result.claude.instructions) + f.querySelector('[name="instructions"]').value = result.claude.instructions; + if (result.claude && result.claude.working_dir) + f.querySelector('[name="working_dir"]').value = result.claude.working_dir; + if (result.claude && result.claude.model) + f.querySelector('[name="model"]').value = result.claude.model; + if (result.claude && result.claude.max_budget_usd != null) + f.querySelector('[name="max_budget_usd"]').value = result.claude.max_budget_usd; + if (result.timeout) + f.querySelector('[name="timeout"]').value = result.timeout; + if (result.priority) { + const sel = f.querySelector('[name="priority"]'); + if ([...sel.options].some(o => o.value === result.priority)) { + sel.value = result.priority; + } + } + + // Show success banner + const banner = document.createElement('p'); + banner.className = 'elaborate-banner'; + banner.textContent = 'AI draft ready — review and submit.'; + document.getElementById('task-form').querySelector('.elaborate-section').appendChild(banner); + } catch (err) { + const errEl = document.createElement('p'); + errEl.className = 'form-error'; + errEl.textContent = `Elaboration failed: ${err.message}`; + document.getElementById('task-form').querySelector('.elaborate-section').appendChild(errEl); + } finally { + btnElaborate.disabled = false; + btnElaborate.textContent = 'Draft with AI ✦'; + } + }); + + document.getElementById('task-form').addEventListener('submit', async e => { + e.preventDefault(); + + // Remove any previous error + const prev = e.target.querySelector('.form-error'); + if (prev) prev.remove(); + + const btn = e.submitter; + btn.disabled = true; + btn.textContent = 'Creating…'; + + try { + await createTask(new FormData(e.target)); + } catch (err) { + const errEl = document.createElement('p'); + errEl.className = 'form-error'; + errEl.textContent = err.message; + e.target.appendChild(errEl); + } finally { + btn.disabled = false; + btn.textContent = 'Create & Queue'; + } + }); + + // Template modal + document.getElementById('btn-new-template').addEventListener('click', openTemplateModal); + document.getElementById('btn-cancel-template').addEventListener('click', closeTemplateModal); + + document.getElementById('template-form').addEventListener('submit', async e => { + e.preventDefault(); + + // Remove any previous error + const prev = e.target.querySelector('.form-error'); + if (prev) prev.remove(); + + const btn = e.submitter; + btn.disabled = true; + btn.textContent = 'Saving…'; + + try { + await saveTemplate(new FormData(e.target)); + } catch (err) { + const errEl = document.createElement('p'); + errEl.className = 'form-error'; + errEl.textContent = err.message; + e.target.appendChild(errEl); + } finally { + btn.disabled = false; + btn.textContent = 'Save Template'; + } + }); +}); -- cgit v1.2.3