In the header it pushed the dock and close buttons out of view when the browser is docked or the display is scaled. It now sits under the tool dock and hides with the collapsed sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
316 lines
14 KiB
JavaScript
316 lines
14 KiB
JavaScript
import { translate } from './locales.js';
|
||
import { createViewScope } from './ui_lifecycle.js';
|
||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||
|
||
/**
|
||
* Version chip in the sidebar footer and the "Version & updates" panel:
|
||
* shows the installed version, lists published releases only when asked,
|
||
* and switches, rolls back, returns to latest or undoes the last switch.
|
||
*/
|
||
|
||
const t = (key, params) => translate(key, params);
|
||
let activeScope = null;
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function button(className, label, onClick) {
|
||
const btn = el('button', className, label);
|
||
btn.type = 'button';
|
||
btn.onclick = onClick;
|
||
return btn;
|
||
}
|
||
|
||
async function request(url, options = {}) {
|
||
let resp;
|
||
try {
|
||
resp = await fetch(url, options);
|
||
} catch (e) {
|
||
return { success: false, code: 'offline', error: e.message };
|
||
}
|
||
const data = await resp.json().catch(() => ({ success: false, error: `HTTP ${resp.status}` }));
|
||
return data;
|
||
}
|
||
|
||
const post = (url, body = {}) => request(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
function errorText(result) {
|
||
const key = `versionError_${result.code}`;
|
||
const text = t(key, { error: result.error || '' });
|
||
const message = text === key ? t('versionErrorGeneric', { error: result.error || result.code || '' }) : text;
|
||
return result.files?.length ? `${message}\n\n${result.files.join('\n')}` : message;
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString();
|
||
}
|
||
|
||
function stateLabel(state) {
|
||
if (!state?.is_git) return t('versionUnknown');
|
||
if (state.tag) return state.tag;
|
||
return state.branch ? t('versionDevBuild', { base: state.base_tag || state.commit, branch: state.branch }) : state.label;
|
||
}
|
||
|
||
// ---------- version chip ----------
|
||
|
||
/** Small version button for the sidebar footer; loads the installed version locally (no network). */
|
||
export function createVersionChip(owner) {
|
||
const chip = button('anomalous-version-chip', '…', () => openVersionPanel(owner, chip));
|
||
chip.title = t('versionPanelTitle');
|
||
chip.setAttribute('aria-label', t('versionPanelTitle'));
|
||
request('/anomalous/version').then(data => {
|
||
chip.textContent = data.success && data.state?.is_git ? (data.state.tag || data.state.base_tag || data.state.label) : t('versionChipUnknown');
|
||
});
|
||
return chip;
|
||
}
|
||
|
||
// ---------- panel ----------
|
||
|
||
export function openVersionPanel(owner, chip = null) {
|
||
activeScope?.dispose();
|
||
const scope = createViewScope();
|
||
activeScope = scope;
|
||
|
||
const overlay = el('div', 'anomalous-version-overlay');
|
||
const panel = el('div', 'anomalous-version-panel');
|
||
panel.setAttribute('role', 'dialog');
|
||
panel.setAttribute('aria-modal', 'true');
|
||
const close = () => scope.dispose();
|
||
scope.onDispose(() => {
|
||
overlay.remove();
|
||
if (activeScope === scope) activeScope = null;
|
||
});
|
||
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||
scope.listen(window, 'keydown', (e) => {
|
||
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
|
||
e.stopPropagation();
|
||
close();
|
||
}, true);
|
||
|
||
const header = el('div', 'anomalous-version-header');
|
||
header.append(el('div', 'anomalous-version-title', t('versionPanelTitle')), button('anomalous-version-close', '×', close));
|
||
|
||
const current = el('div', 'anomalous-version-current');
|
||
const actions = el('div', 'anomalous-version-actions');
|
||
const status = el('div', 'anomalous-version-status');
|
||
const listBar = el('label', 'anomalous-version-prerelease');
|
||
const prereleaseToggle = document.createElement('input');
|
||
prereleaseToggle.type = 'checkbox';
|
||
listBar.append(prereleaseToggle, el('span', '', t('versionShowPrerelease')));
|
||
listBar.hidden = true;
|
||
const list = el('div', 'anomalous-version-list');
|
||
|
||
const view = { state: null, listing: null, busy: false };
|
||
const setBusy = (busy, message = '') => {
|
||
view.busy = busy;
|
||
panel.classList.toggle('is-busy', busy);
|
||
status.textContent = message;
|
||
status.classList.remove('is-error');
|
||
panel.querySelectorAll('button.anomalous-version-action').forEach(btn => { btn.disabled = busy || btn.dataset.blocked === '1'; });
|
||
};
|
||
const showError = (result) => {
|
||
status.textContent = errorText(result);
|
||
status.classList.add('is-error');
|
||
};
|
||
|
||
const renderCurrent = () => {
|
||
const state = view.state;
|
||
current.replaceChildren();
|
||
const line = el('div', 'anomalous-version-current-line');
|
||
line.append(el('span', 'anomalous-version-current-label', t('versionCurrent')), el('strong', '', stateLabel(state)));
|
||
current.appendChild(line);
|
||
if (state?.is_git) {
|
||
const meta = [state.branch ? t('versionBranch', { branch: state.branch }) : t('versionDetached'), state.commit].filter(Boolean).join(' · ');
|
||
current.appendChild(el('div', 'anomalous-version-meta', meta));
|
||
}
|
||
if (state?.dirty) {
|
||
current.appendChild(el('div', 'anomalous-version-warning', t('versionDirtyWarning')));
|
||
} else if (state && !state.is_git) {
|
||
current.appendChild(el('div', 'anomalous-version-warning', t('versionError_not_git')));
|
||
}
|
||
};
|
||
|
||
const blocked = () => !view.state?.is_git || view.state.dirty;
|
||
|
||
const renderActions = () => {
|
||
const check = button('anomalous-version-action primary', t('versionCheck'), () => loadReleases(true));
|
||
const latest = button('anomalous-version-action', t('versionReturnLatest'), returnToLatest);
|
||
latest.title = t('versionReturnLatestHint');
|
||
const items = [check, latest];
|
||
const previous = view.state?.previous;
|
||
if (previous?.label) items.push(button('anomalous-version-action', t('versionUndo', { label: previous.label }), undoSwitch));
|
||
for (const item of items.slice(1)) item.dataset.blocked = blocked() ? '1' : '0';
|
||
check.dataset.blocked = view.state?.is_git ? '0' : '1';
|
||
items.forEach(item => { item.disabled = view.busy || item.dataset.blocked === '1'; });
|
||
actions.replaceChildren(...items);
|
||
};
|
||
|
||
const renderList = () => {
|
||
const listing = view.listing;
|
||
if (!listing) {
|
||
list.replaceChildren(el('div', 'anomalous-version-empty', t('versionCheckHint')));
|
||
return;
|
||
}
|
||
listBar.hidden = !listing.releases.some(item => item.prerelease);
|
||
const releases = listing.releases.filter(item => prereleaseToggle.checked || !item.prerelease || item.current);
|
||
list.replaceChildren(...releases.map(renderRelease));
|
||
if (!releases.length) list.appendChild(el('div', 'anomalous-version-empty', t('versionNoReleases')));
|
||
};
|
||
|
||
const renderRelease = (release) => {
|
||
const card = el('div', `anomalous-version-release${release.current ? ' is-current' : ''}`);
|
||
const top = el('div', 'anomalous-version-release-top');
|
||
const titleBox = el('div', 'anomalous-version-release-title');
|
||
titleBox.append(el('strong', '', release.tag));
|
||
if (release.name && release.name !== release.tag) titleBox.append(el('span', 'anomalous-version-release-name', release.name));
|
||
const badges = el('div', 'anomalous-version-badges');
|
||
if (release.current) badges.appendChild(el('span', 'anomalous-version-badge is-current', t('versionBadgeCurrent')));
|
||
if (release.latest) badges.appendChild(el('span', 'anomalous-version-badge is-latest', t('versionBadgeLatest')));
|
||
if (release.prerelease) badges.appendChild(el('span', 'anomalous-version-badge is-pre', t('versionBadgePrerelease')));
|
||
const date = formatDate(release.published_at);
|
||
if (date) badges.appendChild(el('span', 'anomalous-version-date', date));
|
||
top.append(titleBox, badges);
|
||
card.appendChild(top);
|
||
|
||
const footer = el('div', 'anomalous-version-release-footer');
|
||
if (release.notes) {
|
||
const notes = el('pre', 'anomalous-version-notes', release.notes);
|
||
notes.hidden = true;
|
||
const toggle = button('anomalous-version-link', t('versionNotes'), () => {
|
||
notes.hidden = !notes.hidden;
|
||
});
|
||
footer.appendChild(toggle);
|
||
card.appendChild(notes);
|
||
}
|
||
if (release.url) {
|
||
const link = el('a', 'anomalous-version-link', t('versionOpenGithub'));
|
||
link.href = release.url;
|
||
link.target = '_blank';
|
||
link.rel = 'noopener noreferrer';
|
||
footer.appendChild(link);
|
||
}
|
||
if (!release.current) {
|
||
const label = release.direction === 'newer' ? t('versionUpdateTo') : t('versionRollbackTo');
|
||
const switchBtn = button(`anomalous-version-action${release.direction === 'newer' ? ' primary' : ''}`, label, () => switchTo(release));
|
||
switchBtn.dataset.blocked = blocked() ? '1' : '0';
|
||
switchBtn.disabled = view.busy || blocked();
|
||
footer.appendChild(switchBtn);
|
||
}
|
||
card.appendChild(footer);
|
||
return card;
|
||
};
|
||
|
||
const renderAll = () => {
|
||
renderCurrent();
|
||
renderActions();
|
||
renderList();
|
||
};
|
||
|
||
const refreshState = async () => {
|
||
const data = await request('/anomalous/version');
|
||
if (data.success) view.state = data.state;
|
||
if (chip && view.state) chip.textContent = view.state.tag || view.state.base_tag || view.state.label || chip.textContent;
|
||
};
|
||
|
||
const loadReleases = async (force) => {
|
||
setBusy(true, t('versionChecking'));
|
||
const data = await request(`/anomalous/version/releases${force ? '?refresh=1' : ''}`);
|
||
if (scope.signal.aborted) return;
|
||
setBusy(false);
|
||
if (!data.success) {
|
||
showError(data);
|
||
renderAll();
|
||
return;
|
||
}
|
||
view.state = data.state;
|
||
view.listing = data;
|
||
renderAll();
|
||
const latest = data.releases.find(item => item.latest);
|
||
status.textContent = latest?.current ? t('versionUpToDate') : (latest ? t('versionNewAvailable', { tag: latest.tag }) : '');
|
||
if (data.source === 'git') status.textContent += ` ${t('versionSourceGit')}`;
|
||
};
|
||
|
||
/** Shared ending for every switch: tell the user to restart, offer Manager's restart. */
|
||
const afterSwitch = async (result, successKey, params) => {
|
||
await refreshState();
|
||
view.listing = null;
|
||
renderAll();
|
||
if (!result.success) {
|
||
showError(result);
|
||
return;
|
||
}
|
||
if (chip) chip.textContent = `${chip.textContent} · ${t('versionPendingRestart')}`;
|
||
status.textContent = t(successKey, params);
|
||
const restart = await anomalousConfirm(`${t(successKey, params)}\n\n${t('versionRestartQuestion')}`, t('versionPanelTitle'), {
|
||
okLabel: t('versionRestartNow'), cancelLabel: t('versionRestartLater'),
|
||
});
|
||
if (!restart) return;
|
||
// ComfyUI Manager's reboot exits the server, so a dropped connection means it is restarting.
|
||
// 404 (no Manager) or 403 (Manager security level) means the user restarts by hand.
|
||
const reboot = await post('/api/manager/reboot');
|
||
if (reboot.code === 'offline' || reboot.success !== false) {
|
||
status.textContent = t('versionRestarting');
|
||
} else {
|
||
await anomalousAlert(t('versionRestartManual'));
|
||
}
|
||
};
|
||
|
||
const switchTo = async (release) => {
|
||
if (view.busy) return;
|
||
setBusy(true, t('versionPreparing', { tag: release.tag }));
|
||
const preview = await post('/anomalous/version/preview', { tag: release.tag });
|
||
setBusy(false);
|
||
if (scope.signal.aborted) return;
|
||
if (!preview.success) {
|
||
showError(preview);
|
||
return;
|
||
}
|
||
const lines = [t(preview.direction === 'newer' ? 'versionConfirmUpdate' : 'versionConfirmRollback', { tag: release.tag, from: preview.from })];
|
||
if (preview.direction !== 'newer') lines.push(t('versionRollbackDataWarning'));
|
||
if (!preview.has_switcher) lines.push(t('versionNoPanelWarning', { tag: release.tag }));
|
||
lines.push(t('versionRestartNote'));
|
||
if (!await anomalousConfirm(lines.join('\n\n'), t('versionPanelTitle'))) return;
|
||
setBusy(true, t('versionSwitching', { tag: release.tag }));
|
||
const result = await post('/anomalous/version/switch', { tag: release.tag });
|
||
setBusy(false);
|
||
await afterSwitch(result, 'versionSwitched', { tag: release.tag });
|
||
};
|
||
|
||
const returnToLatest = async () => {
|
||
if (view.busy) return;
|
||
if (!await anomalousConfirm(`${t('versionConfirmLatest')}\n\n${t('versionRestartNote')}`, t('versionPanelTitle'))) return;
|
||
setBusy(true, t('versionSwitching', { tag: 'main' }));
|
||
const result = await post('/anomalous/version/latest');
|
||
setBusy(false);
|
||
await afterSwitch(result, 'versionReturnedLatest', {});
|
||
};
|
||
|
||
const undoSwitch = async () => {
|
||
if (view.busy) return;
|
||
const label = view.state?.previous?.label || '';
|
||
if (!await anomalousConfirm(`${t('versionConfirmUndo', { label })}\n\n${t('versionRestartNote')}`, t('versionPanelTitle'))) return;
|
||
setBusy(true, t('versionSwitching', { tag: label }));
|
||
const result = await post('/anomalous/version/undo');
|
||
setBusy(false);
|
||
await afterSwitch(result, 'versionSwitched', { tag: label });
|
||
};
|
||
|
||
prereleaseToggle.onchange = renderList;
|
||
panel.append(header, current, actions, status, listBar, list);
|
||
overlay.appendChild(panel);
|
||
document.body.appendChild(overlay);
|
||
renderAll();
|
||
refreshState().then(() => { if (!scope.signal.aborted) renderAll(); });
|
||
return close;
|
||
}
|