Files
shopify-ai-backup/chat/public/admin.js
2026-02-09 18:09:12 +00:00

2529 lines
97 KiB
JavaScript

(() => {
const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'opencode'];
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'ollama'];
const pageType = document?.body?.dataset?.page || 'build';
console.log('Admin JS loaded, pageType:', pageType);
const state = {
available: [],
configured: [],
icons: [],
accounts: [],
affiliates: [],
withdrawals: [],
planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] },
providerLimits: {},
providerUsage: [],
opencodeBackupModel: '',
providerOptions: [],
providerModels: {},
tokenRates: {},
};
const el = {
availableModels: document.getElementById('available-models'),
displayLabel: document.getElementById('display-label'),
modelTier: document.getElementById('model-tier'),
iconSelect: document.getElementById('icon-select'),
iconList: document.getElementById('icon-list'),
modelForm: document.getElementById('model-form'),
status: document.getElementById('admin-status'),
configuredList: document.getElementById('configured-list'),
configuredCount: document.getElementById('configured-count'),
logout: document.getElementById('admin-logout'),
refresh: document.getElementById('admin-refresh'),
reloadAvailable: document.getElementById('reload-available'),
// legacy planning fields (build page no longer renders these)
orForm: document.getElementById('openrouter-form'),
orPrimary: document.getElementById('or-primary'),
orBackup1: document.getElementById('or-backup1'),
orBackup2: document.getElementById('or-backup2'),
orBackup3: document.getElementById('or-backup3'),
orStatus: document.getElementById('or-status'),
autoModelForm: document.getElementById('auto-model-form'),
autoModelSelect: document.getElementById('auto-model-select'),
autoModelStatus: document.getElementById('auto-model-status'),
planProviderForm: document.getElementById('plan-provider-form'),
planProvider: document.getElementById('plan-provider'),
freePlanModel: document.getElementById('free-plan-model'),
planProviderStatus: document.getElementById('plan-provider-status'),
planPriorityList: document.getElementById('plan-priority-list'),
addPlanRow: document.getElementById('add-plan-row'),
planChainStatus: document.getElementById('plan-chain-status'),
mistralForm: document.getElementById('mistral-form'),
mistralPrimary: document.getElementById('mistral-primary'),
mistralBackup1: document.getElementById('mistral-backup1'),
mistralBackup2: document.getElementById('mistral-backup2'),
mistralBackup3: document.getElementById('mistral-backup3'),
mistralStatus: document.getElementById('mistral-status'),
accountsTable: document.getElementById('accounts-table'),
accountsCount: document.getElementById('accounts-count'),
affiliatesTable: document.getElementById('affiliates-table'),
affiliatesCount: document.getElementById('affiliates-count'),
withdrawalsTable: document.getElementById('withdrawals-table'),
withdrawalsCount: document.getElementById('withdrawals-count'),
providerOrder: document.getElementById('provider-order'),
providerLimitForm: document.getElementById('provider-limit-form'),
limitProvider: document.getElementById('limit-provider'),
limitScope: document.getElementById('limit-scope'),
limitModel: document.getElementById('limit-model'),
limitModelInput: document.getElementById('limit-model-input'),
limitTpm: document.getElementById('limit-tpm'),
limitTpd: document.getElementById('limit-tpd'),
limitRpm: document.getElementById('limit-rpm'),
limitRpd: document.getElementById('limit-rpd'),
limitBackup: document.getElementById('limit-backup'),
providerLimitStatus: document.getElementById('provider-limit-status'),
providerUsage: document.getElementById('provider-usage'),
availableModelDatalist: document.getElementById('available-model-datalist'),
supportsMedia: document.getElementById('supports-media'),
// Plan tokens UI
planTokensTable: document.getElementById('plan-tokens-table'),
savePlanTokens: document.getElementById('save-plan-tokens'),
planTokensStatus: document.getElementById('plan-tokens-status'),
tokenRateUsd: document.getElementById('token-rate-usd'),
tokenRateGbp: document.getElementById('token-rate-gbp'),
tokenRateEur: document.getElementById('token-rate-eur'),
saveTokenRates: document.getElementById('save-token-rates'),
tokenRatesStatus: document.getElementById('token-rates-status'),
// Cancel messages UI
cancelAllMessages: document.getElementById('cancel-all-messages'),
cancelMessagesStatus: document.getElementById('cancel-messages-status'),
opencodeBackupForm: document.getElementById('opencode-backup-form'),
opencodeBackup: document.getElementById('opencode-backup'),
opencodeBackupStatus: document.getElementById('opencode-backup-status'),
externalTestingRun: document.getElementById('external-testing-run'),
externalTestingStatus: document.getElementById('external-testing-status'),
externalTestingOutput: document.getElementById('external-testing-output'),
externalTestingConfig: document.getElementById('external-testing-config'),
ollamaTestRun: document.getElementById('ollama-test-run'),
ollamaTestStatus: document.getElementById('ollama-test-status'),
ollamaTestOutput: document.getElementById('ollama-test-output'),
};
console.log('Element check - opencodeBackupForm:', el.opencodeBackupForm);
console.log('Element check - opencodeBackup:', el.opencodeBackup);
console.log('Element check - opencodeBackupStatus:', el.opencodeBackupStatus);
function ensureAvailableDatalist() {
if (el.availableModelDatalist) return el.availableModelDatalist;
const dl = document.createElement('datalist');
dl.id = 'available-model-datalist';
document.body.appendChild(dl);
el.availableModelDatalist = dl;
return dl;
}
function getAvailableModelNames() {
const names = new Set();
(state.available || []).forEach((m) => {
const name = m.name || m.id || m;
if (name) names.add(name);
});
(state.configured || []).forEach((m) => { if (m.name) names.add(m.name); });
// include provider-specific models discovered by provider limits endpoint
Object.values(state.providerModels || {}).forEach((arr) => {
(arr || []).forEach((name) => { if (name) names.add(name); });
});
return Array.from(names);
}
function syncAvailableModelDatalist() {
const dl = ensureAvailableDatalist();
if (!dl) return;
dl.innerHTML = '';
getAvailableModelNames().forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
dl.appendChild(opt);
});
}
function setStatus(msg, isError = false) {
if (!el.status) return;
el.status.textContent = msg || '';
el.status.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setOrStatus(msg, isError = false) {
if (!el.orStatus) return;
el.orStatus.textContent = msg || '';
el.orStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setMistralStatus(msg, isError = false) {
if (!el.mistralStatus) return;
el.mistralStatus.textContent = msg || '';
el.mistralStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setPlanProviderStatus(msg, isError = false) {
if (!el.planProviderStatus) return;
el.planProviderStatus.textContent = msg || '';
el.planProviderStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setPlanChainStatus(msg, isError = false) {
if (!el.planChainStatus) return;
el.planChainStatus.textContent = msg || '';
el.planChainStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setProviderLimitStatus(msg, isError = false) {
if (!el.providerLimitStatus) return;
el.providerLimitStatus.textContent = msg || '';
el.providerLimitStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setAutoModelStatus(msg, isError = false) {
if (!el.autoModelStatus) return;
el.autoModelStatus.textContent = msg || '';
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setOpencodeBackupStatus(msg, isError = false) {
if (!el.opencodeBackupStatus) return;
el.opencodeBackupStatus.textContent = msg || '';
el.opencodeBackupStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setExternalTestingStatus(msg, isError = false) {
if (!el.externalTestingStatus) return;
el.externalTestingStatus.textContent = msg || '';
el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function renderExternalTestingConfig(config) {
if (!el.externalTestingConfig) return;
el.externalTestingConfig.innerHTML = '';
if (!config) return;
const rows = [
['WP host', config.wpHost || '—'],
['WP path', config.wpPath || '—'],
['Base URL', config.wpBaseUrl || '—'],
['Multisite enabled', config.enableMultisite ? 'Yes' : 'No'],
['Subsite mode', config.subsiteMode || '—'],
['Subsite domain', config.subsiteDomain || '—'],
['Max concurrent tests', String(config.maxConcurrentTests ?? '—')],
['Auto cleanup', config.autoCleanup ? 'Yes' : 'No'],
['Cleanup delay (ms)', String(config.cleanupDelayMs ?? '—')],
['SSH key configured', config.sshKeyConfigured ? 'Yes' : 'No'],
];
rows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = label;
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingConfig.appendChild(row);
});
}
function renderExternalTestingOutput(result) {
if (!el.externalTestingOutput) return;
el.externalTestingOutput.innerHTML = '';
if (!result) return;
const summary = document.createElement('div');
summary.className = 'admin-row';
const summaryLabel = document.createElement('div');
summaryLabel.style.minWidth = '180px';
const summaryStrong = document.createElement('strong');
summaryStrong.textContent = 'Overall result';
summaryLabel.appendChild(summaryStrong);
const summaryValue = document.createElement('div');
summaryValue.textContent = result.ok ? 'Passed' : 'Failed';
summary.appendChild(summaryLabel);
summary.appendChild(summaryValue);
el.externalTestingOutput.appendChild(summary);
const detailRows = [
['Subsite URL', result.subsite_url || '—'],
['Duration', typeof result.duration === 'number' ? `${(result.duration / 1000).toFixed(1)}s` : '—'],
];
detailRows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = label;
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingOutput.appendChild(row);
});
const scenarioResults = result?.test_results?.cli_tests?.results || [];
if (scenarioResults.length) {
scenarioResults.forEach((scenario) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = scenario.name || 'Scenario';
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = scenario.status === 'passed' ? 'Passed' : 'Failed';
if (scenario.status !== 'passed') {
valueWrap.style.color = 'var(--danger)';
}
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingOutput.appendChild(row);
});
}
const errors = Array.isArray(result.errors) ? result.errors : [];
if (errors.length) {
errors.forEach((err) => {
const row = document.createElement('div');
row.className = 'admin-row';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '180px';
const strong = document.createElement('strong');
strong.textContent = 'Error';
labelWrap.appendChild(strong);
const valueWrap = document.createElement('div');
valueWrap.textContent = err;
valueWrap.style.color = 'var(--danger)';
row.appendChild(labelWrap);
row.appendChild(valueWrap);
el.externalTestingOutput.appendChild(row);
});
}
}
async function loadExternalTestingStatus() {
const data = await api('/api/admin/external-testing-status');
renderExternalTestingConfig(data.config || {});
}
// --- Ollama Test UI ---
function setOllamaTestStatus(msg, isError = false) {
if (!el.ollamaTestStatus) return;
el.ollamaTestStatus.textContent = msg || '';
el.ollamaTestStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function renderOllamaTestOutput(data) {
if (!el.ollamaTestOutput) return;
el.ollamaTestOutput.innerHTML = '';
if (!data) return;
// Config section
const configSection = document.createElement('div');
configSection.style.marginBottom = '16px';
configSection.style.padding = '12px';
configSection.style.background = 'var(--surface)';
configSection.style.borderRadius = '6px';
const configTitle = document.createElement('div');
configTitle.style.fontWeight = '600';
configTitle.style.marginBottom = '8px';
configTitle.textContent = 'Configuration';
configSection.appendChild(configTitle);
const configRows = [
['URL', data.config?.url || '—'],
['Model', data.config?.model || '—'],
['API Key Configured', data.config?.apiKeyConfigured ? 'Yes' : 'No'],
['API Key Preview', data.config?.apiKeyPreview || '—'],
];
configRows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
row.style.marginBottom = '4px';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '140px';
labelWrap.style.fontSize = '12px';
labelWrap.style.color = 'var(--muted)';
labelWrap.textContent = label;
const valueWrap = document.createElement('div');
valueWrap.style.fontSize = '12px';
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
configSection.appendChild(row);
});
el.ollamaTestOutput.appendChild(configSection);
// Result section
if (data.result) {
const resultSection = document.createElement('div');
resultSection.style.marginBottom = '16px';
resultSection.style.padding = '12px';
resultSection.style.background = 'rgba(0, 200, 0, 0.1)';
resultSection.style.borderRadius = '6px';
resultSection.style.border = '1px solid var(--shopify-green)';
const resultTitle = document.createElement('div');
resultTitle.style.fontWeight = '600';
resultTitle.style.marginBottom = '8px';
resultTitle.style.color = 'var(--shopify-green)';
resultTitle.textContent = `✓ Test Passed (${data.duration}ms)`;
resultSection.appendChild(resultTitle);
const resultRows = [
['Response', data.result.reply || '—'],
['Model Used', data.result.model || '—'],
];
resultRows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
row.style.marginBottom = '4px';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '140px';
labelWrap.style.fontSize = '12px';
labelWrap.style.color = 'var(--muted)';
labelWrap.textContent = label;
const valueWrap = document.createElement('div');
valueWrap.style.fontSize = '12px';
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
resultSection.appendChild(row);
});
el.ollamaTestOutput.appendChild(resultSection);
}
// Error section
if (data.error) {
const errorSection = document.createElement('div');
errorSection.style.marginBottom = '16px';
errorSection.style.padding = '12px';
errorSection.style.background = 'rgba(255, 0, 0, 0.05)';
errorSection.style.borderRadius = '6px';
errorSection.style.border = '1px solid var(--danger)';
const errorTitle = document.createElement('div');
errorTitle.style.fontWeight = '600';
errorTitle.style.marginBottom = '8px';
errorTitle.style.color = 'var(--danger)';
errorTitle.textContent = `✗ Test Failed (${data.duration}ms)`;
errorSection.appendChild(errorTitle);
const errorRows = [
['Error Message', data.error.message || '—'],
['Status Code', data.error.status || '—'],
['Detail', data.error.detail || '—'],
['Auth Error', data.error.isAuthError ? 'Yes' : 'No'],
['Model Missing', data.error.isModelMissing ? 'Yes' : 'No'],
['Error Code', data.error.code || '—'],
];
errorRows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'admin-row';
row.style.marginBottom = '4px';
const labelWrap = document.createElement('div');
labelWrap.style.minWidth = '140px';
labelWrap.style.fontSize = '12px';
labelWrap.style.color = 'var(--muted)';
labelWrap.textContent = label;
const valueWrap = document.createElement('div');
valueWrap.style.fontSize = '12px';
valueWrap.style.color = label === 'Error Message' ? 'var(--danger)' : 'inherit';
valueWrap.textContent = value;
row.appendChild(labelWrap);
row.appendChild(valueWrap);
errorSection.appendChild(row);
});
el.ollamaTestOutput.appendChild(errorSection);
}
}
async function api(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (res.status === 401) {
window.location.href = '/admin/login';
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function parseProviderOrderInput(raw, fallbackModel) {
const input = (raw || '').trim();
if (!input) return [];
const parts = input.split(',').map((p) => p.trim()).filter(Boolean);
return parts.map((part, idx) => {
const segments = part.split(':').map((s) => s.trim());
const provider = segments[0];
const model = segments[1] || fallbackModel || provider;
if (!provider) return null;
return { provider, model, primary: idx === 0 };
}).filter(Boolean).map((p, idx) => ({ ...p, primary: idx === 0 }));
}
function renderAvailable() {
if (!el.availableModels) return;
el.availableModels.innerHTML = '';
if (!state.available.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No models discovered';
opt.disabled = true;
opt.selected = true;
el.availableModels.appendChild(opt);
return;
}
state.available.forEach((m) => {
const opt = document.createElement('option');
opt.value = m.name || m.id || m;
opt.textContent = m.label || m.name || m.id || m;
el.availableModels.appendChild(opt);
});
if (!el.displayLabel.value) {
const first = state.available[0];
if (first) el.displayLabel.value = first.label || first.name || '';
}
}
function renderIcons() {
if (el.iconSelect) {
el.iconSelect.innerHTML = '';
const none = document.createElement('option');
none.value = '';
none.textContent = 'No icon';
el.iconSelect.appendChild(none);
state.icons.forEach((iconPath) => {
const opt = document.createElement('option');
opt.value = iconPath;
opt.textContent = iconPath.replace('/assets/', '');
el.iconSelect.appendChild(opt);
});
}
if (el.iconList) {
el.iconList.innerHTML = '';
if (!state.icons.length) {
const div = document.createElement('div');
div.className = 'muted';
div.textContent = 'Add icons to /chat/public/assets to see them here.';
el.iconList.appendChild(div);
return;
}
state.icons.forEach((iconPath) => {
const row = document.createElement('div');
row.className = 'admin-row';
const chip = document.createElement('div');
chip.className = 'model-chip';
const img = document.createElement('img');
img.src = iconPath;
img.alt = '';
chip.appendChild(img);
const span = document.createElement('span');
span.textContent = iconPath.replace('/assets/', '');
chip.appendChild(span);
row.appendChild(chip);
el.iconList.appendChild(row);
});
}
}
function renderConfigured() {
if (!el.configuredList) return;
el.configuredList.innerHTML = '';
if (el.configuredCount) el.configuredCount.textContent = state.configured.length.toString();
if (!state.configured.length) {
const empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = 'No models published to users yet.';
el.configuredList.appendChild(empty);
return;
}
function normalizeProviders(model) {
const providers = Array.isArray(model.providers) ? model.providers : [];
if (!providers.length) return [{ provider: 'opencode', model: model.name, primary: true }];
return providers.map((p, idx) => ({
provider: p.provider || 'opencode',
model: p.model || model.name,
primary: idx === 0 ? true : !!p.primary,
})).map((p, idx) => ({ ...p, primary: idx === 0 }));
}
function reorderProviders(list, from, to) {
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next.map((p, idx) => ({ ...p, primary: idx === 0 }));
}
async function persistProviderChanges(model, nextProviders, nextIcon, nextSupportsMedia, nextTier) {
setStatus('Saving provider order...');
const currentModel = state.configured.find((m) => m.id === model.id) || model;
const payload = {
id: model.id,
name: currentModel.name,
label: currentModel.label || currentModel.name,
icon: nextIcon !== undefined ? nextIcon : (currentModel.icon || ''),
cli: currentModel.cli || 'opencode',
providers: nextProviders.map((p, idx) => ({
provider: p.provider || 'opencode',
model: p.model || currentModel.name,
primary: idx === 0,
})),
tier: nextTier !== undefined ? nextTier : (currentModel.tier || 'free'),
supportsMedia: nextSupportsMedia !== undefined ? nextSupportsMedia : (currentModel.supportsMedia ?? false),
};
try {
const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
const updated = data.model || payload;
const idx = state.configured.findIndex((cm) => cm.id === model.id);
if (idx >= 0) state.configured[idx] = { ...state.configured[idx], ...updated };
else state.configured.push(updated);
renderConfigured();
setStatus('Saved');
setTimeout(() => setStatus(''), 1500);
} catch (err) {
setStatus(err.message, true);
}
}
function formatLimitSummary(provider, modelName) {
const cfg = state.providerLimits && state.providerLimits[provider];
if (!cfg) return 'No limits set';
const isPerModelScope = cfg.scope === 'model';
const hasModelSpecificLimit = isPerModelScope && modelName && cfg.perModel && cfg.perModel[modelName];
const target = hasModelSpecificLimit ? cfg.perModel[modelName] : cfg;
const parts = [];
if (target.requestsPerMinute) parts.push(`${target.requestsPerMinute}/m`);
if (target.tokensPerMinute) parts.push(`${target.tokensPerMinute} tpm`);
if (target.requestsPerDay) parts.push(`${target.requestsPerDay}/day`);
if (target.tokensPerDay) parts.push(`${target.tokensPerDay} tpd`);
if (!parts.length) {
if (isPerModelScope && !hasModelSpecificLimit) {
return 'No limit for this model';
}
return isPerModelScope ? 'Provider limits apply' : 'Unlimited';
}
const limitStr = parts.join(' · ');
return hasModelSpecificLimit ? `${limitStr} (per-model)` : limitStr;
}
state.configured.forEach((m) => {
const providers = normalizeProviders(m);
const row = document.createElement('div');
row.className = 'provider-row slim';
const header = document.createElement('div');
header.className = 'provider-row-header';
const info = document.createElement('div');
info.className = 'model-chip';
if (m.icon) {
const img = document.createElement('img');
img.src = m.icon;
img.alt = '';
info.appendChild(img);
}
const label = document.createElement('span');
label.textContent = m.label || m.name;
info.appendChild(label);
const namePill = document.createElement('span');
namePill.className = 'pill';
namePill.textContent = m.name;
info.appendChild(namePill);
const tierMeta = document.createElement('span');
tierMeta.className = 'pill';
const tierName = (m.tier || 'free').toUpperCase();
const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1);
tierMeta.textContent = `${tierName} (${multiplier}x)`;
info.appendChild(tierMeta);
if (m.supportsMedia) {
const mediaBadge = document.createElement('span');
mediaBadge.className = 'pill';
mediaBadge.style.background = 'var(--shopify-green)';
mediaBadge.textContent = 'Media';
info.appendChild(mediaBadge);
}
header.appendChild(info);
const headerActions = document.createElement('div');
headerActions.className = 'provider-row-actions';
const fallbackBadge = document.createElement('div');
fallbackBadge.className = 'pill';
fallbackBadge.textContent = 'Auto fallback on error/rate limit';
headerActions.appendChild(fallbackBadge);
const delBtn = document.createElement('button');
delBtn.className = 'ghost';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
delBtn.disabled = true;
try {
await api(`/api/admin/models/${m.id}`, { method: 'DELETE' });
await loadConfigured();
} catch (err) {
setStatus(err.message, true);
}
delBtn.disabled = false;
});
headerActions.appendChild(delBtn);
// Inline icon editor button
const editIconBtn = document.createElement('button');
editIconBtn.className = 'ghost';
editIconBtn.textContent = 'Edit icon';
editIconBtn.addEventListener('click', () => {
// Toggle editor
let editor = header.querySelector('.icon-editor');
if (editor) return editor.remove();
editor = document.createElement('div');
editor.className = 'icon-editor';
const sel = document.createElement('select');
const none = document.createElement('option');
none.value = '';
none.textContent = 'No icon';
sel.appendChild(none);
(state.icons || []).forEach((iconPath) => {
const o = document.createElement('option');
o.value = iconPath;
o.textContent = iconPath.replace('/assets/', '');
sel.appendChild(o);
});
sel.value = m.icon || '';
editor.appendChild(sel);
const saveBtn = document.createElement('button');
saveBtn.className = 'primary';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
await persistProviderChanges(m, providers, sel.value, undefined);
} catch (err) { setStatus(err.message, true); }
saveBtn.disabled = false;
});
editor.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ghost';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => editor.remove());
editor.appendChild(cancelBtn);
headerActions.appendChild(editor);
});
headerActions.appendChild(editIconBtn);
// Supports media checkbox
const mediaToggle = document.createElement('label');
mediaToggle.style.display = 'flex';
mediaToggle.style.alignItems = 'center';
mediaToggle.style.gap = '6px';
mediaToggle.style.marginLeft = '8px';
const mediaCheckbox = document.createElement('input');
mediaCheckbox.type = 'checkbox';
mediaCheckbox.checked = m.supportsMedia ?? false;
mediaCheckbox.addEventListener('change', async () => {
mediaCheckbox.disabled = true;
try {
await persistProviderChanges(m, providers, undefined, mediaCheckbox.checked);
} catch (err) { setStatus(err.message, true); }
mediaCheckbox.disabled = false;
});
mediaToggle.appendChild(mediaCheckbox);
const mediaLabel = document.createElement('span');
mediaLabel.textContent = 'Supports image uploads';
mediaLabel.style.fontSize = '12px';
mediaLabel.style.color = 'var(--muted)';
mediaToggle.appendChild(mediaLabel);
headerActions.appendChild(mediaToggle);
// Tier editor button
const editTierBtn = document.createElement('button');
editTierBtn.className = 'ghost';
editTierBtn.textContent = 'Edit tier/multiplier';
editTierBtn.addEventListener('click', () => {
let editor = header.querySelector('.tier-editor');
if (editor) return editor.remove();
editor = document.createElement('div');
editor.className = 'tier-editor';
const sel = document.createElement('select');
const options = [
{ value: 'free', label: 'Free (1x)' },
{ value: 'plus', label: 'Plus (2x)' },
{ value: 'pro', label: 'Pro (3x)' }
];
options.forEach((opt) => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
sel.appendChild(o);
});
sel.value = m.tier || 'free';
editor.appendChild(sel);
const saveBtn = document.createElement('button');
saveBtn.className = 'primary';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
await persistProviderChanges(m, providers, undefined, undefined, sel.value);
} catch (err) { setStatus(err.message, true); }
saveBtn.disabled = false;
});
editor.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ghost';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => editor.remove());
editor.appendChild(cancelBtn);
headerActions.appendChild(editor);
});
headerActions.appendChild(editTierBtn);
header.appendChild(headerActions);
row.appendChild(header);
const providerList = document.createElement('div');
providerList.className = 'provider-pill-row';
providers.forEach((p, idx) => {
const card = document.createElement('div');
card.className = 'provider-card compact';
card.draggable = true;
card.dataset.index = idx.toString();
const stack = document.createElement('div');
stack.className = 'model-chip';
const order = document.createElement('span');
order.className = 'pill';
order.textContent = `#${idx + 1}`;
stack.appendChild(order);
const providerPill = document.createElement('span');
providerPill.textContent = p.provider;
stack.appendChild(providerPill);
const modelPill = document.createElement('span');
modelPill.className = 'pill';
modelPill.textContent = p.model || m.name;
stack.appendChild(modelPill);
const limitPill = document.createElement('span');
limitPill.className = 'pill';
limitPill.textContent = formatLimitSummary(p.provider, p.model || m.name);
stack.appendChild(limitPill);
card.appendChild(stack);
const actions = document.createElement('div');
actions.className = 'provider-row-actions';
const upBtn = document.createElement('button');
upBtn.className = 'ghost';
upBtn.textContent = '↑';
upBtn.title = 'Move up';
upBtn.disabled = idx === 0;
upBtn.addEventListener('click', async () => {
const next = reorderProviders(providers, idx, Math.max(0, idx - 1));
await persistProviderChanges(m, next, undefined);
});
actions.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.className = 'ghost';
downBtn.textContent = '↓';
downBtn.title = 'Move down';
downBtn.disabled = idx === providers.length - 1;
downBtn.addEventListener('click', async () => {
const next = reorderProviders(providers, idx, Math.min(providers.length - 1, idx + 1));
await persistProviderChanges(m, next, undefined);
});
actions.appendChild(downBtn);
const removeBtn = document.createElement('button');
removeBtn.className = 'ghost';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', async () => {
if (providers.length <= 1) {
const ok = window.confirm('Deleting the last provider will fall back to the default `opencode` provider. Continue?');
if (!ok) return;
}
const next = providers.filter((_, i) => i !== idx);
await persistProviderChanges(m, next, undefined);
});
actions.appendChild(removeBtn);
// Drag & drop support for build page only
if (pageType === 'build') {
card.addEventListener('dragstart', (ev) => {
card.classList.add('dragging');
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', JSON.stringify({ modelId: m.id, index: idx }));
});
card.addEventListener('dragend', () => card.classList.remove('dragging'));
}
card.appendChild(actions);
providerList.appendChild(card);
});
row.appendChild(providerList);
// Enable dropping into the provider list (build page only)
if (pageType === 'build') {
providerList.addEventListener('dragover', (ev) => { ev.preventDefault(); providerList.classList.add('drag-over'); });
providerList.addEventListener('dragleave', () => providerList.classList.remove('drag-over'));
providerList.addEventListener('drop', async (ev) => {
ev.preventDefault();
providerList.classList.remove('drag-over');
const raw = ev.dataTransfer.getData('text/plain');
let payload = null;
try { payload = raw ? JSON.parse(raw) : null; } catch (_) { }
if (!payload || payload.modelId !== m.id || typeof payload.index !== 'number') return;
const cards = Array.from(providerList.querySelectorAll('.provider-card'));
const destEl = ev.target.closest('.provider-card');
let destIndex = cards.length - 1;
if (destEl) destIndex = cards.indexOf(destEl);
const next = reorderProviders(providers, payload.index, destIndex);
await persistProviderChanges(m, next, undefined);
});
}
const addRow = document.createElement('div');
addRow.className = 'provider-add-row';
const providerSelect = document.createElement('select');
DEFAULT_PROVIDERS.forEach((provider) => {
const opt = document.createElement('option');
opt.value = provider;
opt.textContent = provider;
providerSelect.appendChild(opt);
});
providerSelect.value = providers[0]?.provider && DEFAULT_PROVIDERS.includes(providers[0].provider)
? providers[0].provider
: 'openrouter';
addRow.appendChild(providerSelect);
const modelInput = document.createElement('input');
modelInput.type = 'text';
modelInput.placeholder = 'Model name (use discovered list)';
modelInput.value = m.name;
modelInput.setAttribute('list', ensureAvailableDatalist().id);
addRow.appendChild(modelInput);
// Inline icon selector shown when adding a second provider (i.e. initial add)
const iconInlineSelect = document.createElement('select');
iconInlineSelect.className = 'icon-select-inline';
const noneOpt = document.createElement('option');
noneOpt.value = '';
noneOpt.textContent = 'No icon';
iconInlineSelect.appendChild(noneOpt);
(state.icons || []).forEach((iconPath) => {
const opt = document.createElement('option');
opt.value = iconPath;
opt.textContent = iconPath.replace('/assets/', '');
iconInlineSelect.appendChild(opt);
});
iconInlineSelect.value = m.icon || '';
// Only show when this is the initial add (adding a second provider)
if (providers.length <= 1) addRow.appendChild(iconInlineSelect);
const addBtn = document.createElement('button');
addBtn.className = 'ghost';
addBtn.textContent = 'Add provider';
addBtn.addEventListener('click', async () => {
const providerVal = providerSelect.value.trim() || 'opencode';
const modelVal = modelInput.value.trim() || m.name;
const nextProviders = [...providers, { provider: providerVal, model: modelVal, primary: false }];
const iconVal = iconInlineSelect ? iconInlineSelect.value : undefined;
await persistProviderChanges(m, nextProviders, iconVal, undefined);
});
addRow.appendChild(addBtn);
row.appendChild(addRow);
el.configuredList.appendChild(row);
});
}
function normalizePlanChainLocal(chain) {
if (!Array.isArray(chain)) return [];
const seen = new Set();
const out = [];
chain.forEach((entry) => {
const provider = (entry?.provider || '').toString().trim().toLowerCase();
if (!PLANNING_PROVIDERS.includes(provider)) return;
// `model` is the normalized string used at runtime; `raw` preserves the
// exact admin input for display (e.g., "groq/compound-mini"). Prefer
// `raw` for showing in inputs, but dedupe keys using the normalized model.
const normalizedModel = typeof entry?.model === 'string' ? entry.model.trim() : '';
const displayModel = (typeof entry?.raw === 'string' && entry.raw.trim()) ? entry.raw.trim() : normalizedModel;
const key = `${provider}::${normalizedModel || '__any__'}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ provider, model: normalizedModel, raw: displayModel });
});
return out;
}
function planLimitSummary(provider, modelName) {
const cfg = state.providerLimits && state.providerLimits[provider];
if (!cfg) return 'Unlimited';
const isPerModelScope = cfg.scope === 'model';
const hasModelSpecificLimit = isPerModelScope && modelName && cfg.perModel && cfg.perModel[modelName];
const target = hasModelSpecificLimit ? cfg.perModel[modelName] : cfg;
const parts = [];
if (target.requestsPerMinute) parts.push(`${target.requestsPerMinute}/m`);
if (target.tokensPerMinute) parts.push(`${target.tokensPerMinute} tpm`);
if (target.requestsPerDay) parts.push(`${target.requestsPerDay}/day`);
if (target.tokensPerDay) parts.push(`${target.tokensPerDay} tpd`);
if (!parts.length) {
if (isPerModelScope && !hasModelSpecificLimit) {
return 'No limit for this model';
}
return 'Unlimited';
}
const limitStr = parts.join(' · ');
return hasModelSpecificLimit ? `${limitStr} (per-model)` : limitStr;
}
async function persistPlanChain(nextChain) {
if (!el.planPriorityList) return;
setPlanChainStatus('Saving...');
try {
const payload = { planningChain: nextChain };
const res = await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify(payload) });
const normalized = normalizePlanChainLocal(res.settings?.planningChain || nextChain);
state.planSettings = { ...state.planSettings, ...(res.settings || {}), planningChain: normalized };
renderPlanPriority();
setPlanChainStatus('Saved');
setTimeout(() => setPlanChainStatus(''), 1500);
} catch (err) {
setPlanChainStatus(err.message, true);
}
}
function renderPlanPriority() {
if (!el.planPriorityList) return;
const chain = normalizePlanChainLocal(state.planSettings?.planningChain || []);
el.planPriorityList.innerHTML = '';
if (!chain.length) {
const empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = 'No planning models configured. Add one to enable planning fallbacks.';
el.planPriorityList.appendChild(empty);
}
chain.forEach((entry, idx) => {
const row = document.createElement('div');
row.className = 'provider-row slim';
const header = document.createElement('div');
header.className = 'provider-row-header';
const info = document.createElement('div');
info.className = 'model-chip';
const order = document.createElement('span');
order.className = 'pill';
order.textContent = `Priority #${idx + 1}`;
info.appendChild(order);
const providerSelect = document.createElement('select');
PLANNING_PROVIDERS.forEach((provider) => {
const opt = document.createElement('option');
opt.value = provider;
opt.textContent = provider;
providerSelect.appendChild(opt);
});
providerSelect.value = entry.provider;
providerSelect.addEventListener('change', () => {
const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, provider: providerSelect.value } : c)));
persistPlanChain(next);
});
info.appendChild(providerSelect);
const modelInput = document.createElement('input');
modelInput.type = 'text';
modelInput.placeholder = 'Model id (supports OpenRouter, Mistral, Google, Groq, NVIDIA)';
// Prefer showing the exact user input (`raw`) when available, otherwise show
// the normalized `model` value.
modelInput.value = entry.raw || entry.model;
modelInput.setAttribute('list', ensureAvailableDatalist().id);
modelInput.addEventListener('blur', () => {
const val = modelInput.value.trim();
const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, model: val, raw: val } : c)));
persistPlanChain(next);
});
info.appendChild(modelInput);
const limitPill = document.createElement('span');
limitPill.className = 'pill';
limitPill.textContent = planLimitSummary(entry.provider, entry.model);
info.appendChild(limitPill);
header.appendChild(info);
const actions = document.createElement('div');
actions.className = 'provider-row-actions';
const upBtn = document.createElement('button');
upBtn.className = 'ghost';
upBtn.textContent = '↑';
upBtn.title = 'Move up';
upBtn.disabled = idx === 0;
upBtn.addEventListener('click', () => {
const next = [...chain];
const [item] = next.splice(idx, 1);
next.splice(Math.max(0, idx - 1), 0, item);
persistPlanChain(next);
});
actions.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.className = 'ghost';
downBtn.textContent = '↓';
downBtn.title = 'Move down';
downBtn.disabled = idx === chain.length - 1;
downBtn.addEventListener('click', () => {
const next = [...chain];
const [item] = next.splice(idx, 1);
next.splice(Math.min(chain.length, idx + 1), 0, item);
persistPlanChain(next);
});
actions.appendChild(downBtn);
const removeBtn = document.createElement('button');
removeBtn.className = 'ghost';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => {
const next = chain.filter((_, i) => i !== idx);
persistPlanChain(next);
});
actions.appendChild(removeBtn);
header.appendChild(actions);
row.appendChild(header);
el.planPriorityList.appendChild(row);
});
}
function renderProviderUsage() {
if (!el.providerUsage) return;
el.providerUsage.innerHTML = '';
if (!state.providerUsage.length) {
const empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = 'No usage recorded yet.';
el.providerUsage.appendChild(empty);
return;
}
function createUsageRow(provider, label, limits, usage) {
const row = document.createElement('div');
row.className = 'admin-row';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.flexDirection = 'column';
left.style.minWidth = '200px';
left.innerHTML = `<strong>${provider}</strong> <span class="pill">${label}</span>`;
const progress = document.createElement('div');
progress.style.display = 'flex';
progress.style.flexDirection = 'column';
progress.style.gap = '6px';
progress.style.flex = '1';
const rows = [
['Tokens (1m)', usage.tokensLastMinute || 0, limits.tokensPerMinute || 0],
['Tokens (24h)', usage.tokensLastDay || 0, limits.tokensPerDay || 0],
['Requests (1m)', usage.requestsLastMinute || 0, limits.requestsPerMinute || 0],
['Requests (24h)', usage.requestsLastDay || 0, limits.requestsPerDay || 0],
];
rows.forEach(([labelText, used, limit]) => {
const wrap = document.createElement('div');
wrap.style.display = 'flex';
wrap.style.flexDirection = 'column';
const labelEl = document.createElement('div');
labelEl.style.display = 'flex';
labelEl.style.justifyContent = 'space-between';
labelEl.style.fontSize = '12px';
labelEl.innerHTML = `<span>${labelText}</span><span>${used}${limit > 0 ? ` / ${limit}` : ''}</span>`;
wrap.appendChild(labelEl);
if (limit > 0) {
const barOuter = document.createElement('div');
barOuter.style.background = 'var(--border)';
barOuter.style.height = '6px';
barOuter.style.borderRadius = '6px';
const barInner = document.createElement('div');
barInner.style.height = '6px';
barInner.style.borderRadius = '6px';
const pct = Math.min(100, parseFloat(((used / limit) * 100).toFixed(1)));
barInner.style.width = `${pct}%`;
barInner.style.background = pct > 90 ? 'var(--danger)' : 'var(--primary)';
barOuter.appendChild(barInner);
wrap.appendChild(barOuter);
}
progress.appendChild(wrap);
});
row.appendChild(left);
row.appendChild(progress);
return row;
}
state.providerUsage.forEach((entry) => {
const isPerModel = entry.scope === 'model';
const perModelLimits = entry.perModelLimits || {};
const perModelUsage = (entry.usage && entry.usage.perModel) || {};
const modelNames = isPerModel ? [...new Set([...Object.keys(perModelLimits), ...Object.keys(perModelUsage)])] : [];
// If per-model scope is enabled and we have models, show them individually
if (isPerModel && modelNames.length > 0) {
modelNames.forEach((modelName) => {
const limits = perModelLimits[modelName] || {};
const usage = perModelUsage[modelName] || {};
const row = createUsageRow(entry.provider, modelName, limits, usage);
el.providerUsage.appendChild(row);
});
} else {
// Provider-level scope or no models yet - show aggregate
const limits = entry.limits || {};
const usage = entry.usage || {};
const row = createUsageRow(entry.provider, entry.scope, limits, usage);
el.providerUsage.appendChild(row);
}
});
}
function renderProviderOptions() {
if (!el.limitProvider) return;
const providersFromState = Array.isArray(state.providerOptions) && state.providerOptions.length
? [...state.providerOptions]
: null;
let providers = providersFromState || Object.keys(state.providerLimits || {});
const current = el.limitProvider.value;
el.limitProvider.innerHTML = '';
if (!providers.length) providers = [...DEFAULT_PROVIDERS];
providers.forEach((provider, idx) => {
const opt = document.createElement('option');
opt.value = provider;
opt.textContent = provider;
if (provider === current || (!current && idx === 0)) opt.selected = true;
el.limitProvider.appendChild(opt);
});
}
function renderLimitModelOptions(provider) {
// If we're on the plan page, allow free-text model entry (admins can type any model id)
if (pageType === 'plan') {
if (!el.limitModelInput) return;
// show input and hide the select
if (el.limitModel) el.limitModel.style.display = 'none';
el.limitModelInput.style.display = '';
// Populate datalist with discovered models to help typing
syncAvailableModelDatalist();
// set current value from configured per-model limit if present
const cfg = state.providerLimits && state.providerLimits[provider] ? state.providerLimits[provider] : {};
const modelKey = el.limitModelInput.value || '';
// Do not overwrite user's typing; keep existing value
if (!el.limitModelInput.value && cfg.perModel) {
// nothing to prefill unless there's exactly one per-model configured
const keys = Object.keys(cfg.perModel || {});
if (keys.length === 1) el.limitModelInput.value = keys[0];
}
return;
}
if (!el.limitModel) return;
el.limitModel.style.display = '';
if (el.limitModelInput) el.limitModelInput.style.display = 'none';
const current = el.limitModel.value;
const modelsFromProvider = (state.providerModels && state.providerModels[provider]) ? state.providerModels[provider] : [];
const combined = new Set(modelsFromProvider);
getAvailableModelNames().forEach((m) => combined.add(m));
const sorted = Array.from(combined).filter(Boolean).sort((a, b) => a.localeCompare(b));
el.limitModel.innerHTML = '';
const anyOpt = document.createElement('option');
anyOpt.value = '';
anyOpt.textContent = 'Any model';
el.limitModel.appendChild(anyOpt);
sorted.forEach((model) => {
const opt = document.createElement('option');
opt.value = model;
opt.textContent = model;
el.limitModel.appendChild(opt);
});
if (current && sorted.includes(current)) el.limitModel.value = current;
}
async function loadAvailable() {
const data = await api('/api/admin/available-models');
state.available = data.models || [];
renderAvailable();
syncAvailableModelDatalist();
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
? state.planSettings.freePlanModel
: (el.autoModelSelect ? el.autoModelSelect.value : '');
populateAutoModelOptions(selectedAutoModel);
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
}
async function loadIcons() {
const data = await api('/api/admin/icons');
state.icons = data.icons || [];
renderIcons();
}
async function loadConfigured() {
const data = await api('/api/admin/models');
state.configured = data.models || [];
renderConfigured();
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
? state.planSettings.freePlanModel
: (el.autoModelSelect ? el.autoModelSelect.value : '');
populateAutoModelOptions(selectedAutoModel);
populateFreePlanModelOptions(selectedAutoModel);
syncAvailableModelDatalist();
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
}
async function loadOpenRouterSettings() {
if (!el.orForm) return;
try {
const data = await api('/api/admin/openrouter-settings');
if (el.orPrimary) el.orPrimary.value = data.primaryModel || '';
if (el.orBackup1) el.orBackup1.value = data.backupModel1 || '';
if (el.orBackup2) el.orBackup2.value = data.backupModel2 || '';
if (el.orBackup3) el.orBackup3.value = data.backupModel3 || '';
} catch (err) {
setOrStatus(err.message, true);
}
}
async function loadMistralSettings() {
if (!el.mistralForm) return;
try {
const data = await api('/api/admin/mistral-settings');
if (el.mistralPrimary) el.mistralPrimary.value = data.primaryModel || '';
if (el.mistralBackup1) el.mistralBackup1.value = data.backupModel1 || '';
if (el.mistralBackup2) el.mistralBackup2.value = data.backupModel2 || '';
if (el.mistralBackup3) el.mistralBackup3.value = data.backupModel3 || '';
} catch (err) {
setMistralStatus(err.message, true);
}
}
function populateLimitForm(provider, scope = 'provider') {
if (!el.limitProvider) return;
const selectedProvider = provider || el.limitProvider.value || 'openrouter';
const selectedScope = scope || el.limitScope?.value || 'provider';
const cfg = state.providerLimits[selectedProvider] || {};
renderLimitModelOptions(selectedProvider);
// prefer the free-text input on the plan page
const modelKey = (pageType === 'plan' && el.limitModelInput) ? (el.limitModelInput.value || '') : (el.limitModel ? el.limitModel.value : '');
const target = selectedScope === 'model' && modelKey && cfg.perModel && cfg.perModel[modelKey]
? cfg.perModel[modelKey]
: cfg;
if (el.limitProvider) el.limitProvider.value = selectedProvider;
if (el.limitScope) el.limitScope.value = selectedScope;
if (pageType === 'plan' && el.limitModelInput) {
el.limitModelInput.value = selectedScope === 'model' ? (modelKey || '') : '';
} else if (el.limitModel) {
el.limitModel.value = selectedScope === 'model' ? (modelKey || '') : '';
}
if (el.limitTpm) el.limitTpm.value = target.tokensPerMinute ?? '';
if (el.limitTpd) el.limitTpd.value = target.tokensPerDay ?? '';
if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? '';
if (el.limitRpd) el.limitRpd.value = target.requestsPerDay ?? '';
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
}
async function loadProviderLimits() {
if (!el.providerUsage && !el.providerLimitForm) return;
try {
const data = await api('/api/admin/provider-limits');
state.providerLimits = data.limits || {};
state.providerOptions = data.providers || [];
state.providerModels = data.providerModels || {};
(state.providerOptions || []).forEach((provider) => {
if (provider && !state.providerLimits[provider]) state.providerLimits[provider] = {};
});
DEFAULT_PROVIDERS.forEach((p) => {
if (!state.providerLimits[p]) state.providerLimits[p] = {};
});
state.providerUsage = data.usage || [];
state.opencodeBackupModel = data.opencodeBackupModel || '';
renderProviderOptions();
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider');
renderProviderUsage();
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
populateOpencodeBackupOptions(state.opencodeBackupModel);
// refresh datalist with provider-specific models
syncAvailableModelDatalist();
renderPlanPriority();
} catch (err) {
setProviderLimitStatus(err.message, true);
}
}
// --- Plan tokens UI ---
async function loadPlanTokens() {
if (!el.planTokensTable) return;
try {
const data = await api('/api/admin/plan-tokens');
state.planTokens = data.limits || {};
renderPlanTokens();
} catch (err) {
if (el.planTokensStatus) el.planTokensStatus.textContent = String(err.message || err);
}
}
function renderPlanTokens() {
if (!el.planTokensTable) return;
el.planTokensTable.innerHTML = '';
const plansOrder = ['hobby', 'starter', 'business', 'enterprise'];
plansOrder.forEach((plan) => {
const card = document.createElement('div');
card.className = 'admin-row';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.flexDirection = 'column';
left.style.minWidth = '180px';
const title = document.createElement('strong');
title.textContent = plan;
left.appendChild(title);
left.style.marginBottom = '8px';
const input = document.createElement('input');
input.type = 'number';
input.min = '0';
input.step = '1';
input.value = (state.planTokens && typeof state.planTokens[plan] === 'number') ? String(state.planTokens[plan]) : '';
input.dataset.plan = plan;
input.placeholder = 'Token limit';
input.style.width = '200px';
const wrapper = document.createElement('div');
wrapper.style.display = 'flex';
wrapper.style.flexDirection = 'column';
wrapper.style.gap = '4px';
const label = document.createElement('div');
label.textContent = 'Token Limit';
label.style.fontSize = '12px';
label.style.color = 'var(--muted)';
wrapper.appendChild(label);
wrapper.appendChild(input);
card.appendChild(left);
card.appendChild(wrapper);
el.planTokensTable.appendChild(card);
});
}
async function savePlanTokens() {
if (!el.planTokensTable) return;
if (el.savePlanTokens) el.savePlanTokens.disabled = true;
if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saving...';
const rows = el.planTokensTable.querySelectorAll('input[data-plan]');
const payload = {};
rows.forEach((input) => {
const plan = input.dataset.plan;
const num = input.value ? Number(input.value) : 0;
payload[plan] = Number.isFinite(num) ? Math.max(0, Math.round(num)) : 0;
});
try {
const res = await api('/api/admin/plan-tokens', { method: 'POST', body: JSON.stringify({ limits: payload }) });
state.planTokens = res.limits || payload;
renderPlanTokens();
if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saved';
setTimeout(() => { if (el.planTokensStatus) el.planTokensStatus.textContent = ''; }, 1400);
} catch (err) {
if (el.planTokensStatus) el.planTokensStatus.textContent = err.message || String(err);
} finally {
if (el.savePlanTokens) el.savePlanTokens.disabled = false;
}
}
// --- Token rates UI ---
async function loadTokenRates() {
if (!el.tokenRateUsd && !el.tokenRateGbp && !el.tokenRateEur) return;
try {
const data = await api('/api/admin/token-rates');
state.tokenRates = data.rates || {};
renderTokenRates();
} catch (err) {
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = String(err.message || err);
}
}
function renderTokenRates() {
if (el.tokenRateUsd) el.tokenRateUsd.value = String(state.tokenRates?.usd ?? '');
if (el.tokenRateGbp) el.tokenRateGbp.value = String(state.tokenRates?.gbp ?? '');
if (el.tokenRateEur) el.tokenRateEur.value = String(state.tokenRates?.eur ?? '');
}
async function saveTokenRates() {
if (!el.saveTokenRates) return;
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saving...';
el.saveTokenRates.disabled = true;
const rates = {
usd: Number(el.tokenRateUsd?.value || 0),
gbp: Number(el.tokenRateGbp?.value || 0),
eur: Number(el.tokenRateEur?.value || 0),
};
Object.keys(rates).forEach((key) => {
const value = rates[key];
rates[key] = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
});
try {
const res = await api('/api/admin/token-rates', { method: 'POST', body: JSON.stringify({ rates }) });
state.tokenRates = res.rates || rates;
renderTokenRates();
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saved';
setTimeout(() => { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = ''; }, 1400);
} catch (err) {
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = err.message || String(err);
} finally {
el.saveTokenRates.disabled = false;
}
}
async function loadPlanProviderSettings() {
if (!el.planProviderForm && !el.autoModelForm && !el.planPriorityList) return;
try {
const data = await api('/api/admin/plan-settings');
state.planSettings = {
provider: 'openrouter',
freePlanModel: '',
planningChain: [],
...(data || {}),
};
if (el.planProvider) el.planProvider.value = state.planSettings.provider || 'openrouter';
populateAutoModelOptions(state.planSettings.freePlanModel || '');
populateFreePlanModelOptions(state.planSettings.freePlanModel || '');
renderPlanPriority();
} catch (err) {
if (el.planProviderForm) setPlanProviderStatus(err.message, true);
if (el.autoModelForm) setAutoModelStatus(err.message, true);
if (el.planPriorityList) setPlanChainStatus(err.message, true);
}
}
function populateAutoModelOptions(selectedValue) {
if (!el.autoModelSelect) return;
const normalizeTier = (tier) => {
const normalized = String(tier || 'free').trim().toLowerCase();
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
};
const configured = Array.isArray(state.configured) ? state.configured : [];
const configuredByName = new Map();
configured.forEach((m) => {
const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '';
if (name) configuredByName.set(name, m);
});
const current = typeof selectedValue === 'string' ? selectedValue : el.autoModelSelect.value;
el.autoModelSelect.innerHTML = '';
const auto = document.createElement('option');
auto.value = '';
auto.textContent = 'Auto (first free model)';
el.autoModelSelect.appendChild(auto);
const freeModels = configured
.filter((m) => normalizeTier(m.tier) === 'free')
.map((m) => ({
name: (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '',
label: (m && (m.label || m.name || m.id)) ? String(m.label || m.name || m.id).trim() : '',
}))
.filter((m) => m.name);
const freeGroup = document.createElement('optgroup');
freeGroup.label = 'Free-tier models';
const freeNames = new Set();
freeModels
.sort((a, b) => a.label.localeCompare(b.label))
.forEach((m) => {
freeNames.add(m.name);
const opt = document.createElement('option');
opt.value = m.name;
opt.textContent = m.label || m.name;
freeGroup.appendChild(opt);
});
const discoveredNames = getAvailableModelNames()
.map((name) => String(name || '').trim())
.filter(Boolean)
.filter((name, idx, arr) => arr.indexOf(name) === idx)
.filter((name) => !freeNames.has(name));
const discoveredGroup = document.createElement('optgroup');
discoveredGroup.label = 'Other discovered models';
discoveredNames
.sort((a, b) => a.localeCompare(b))
.forEach((name) => {
const configuredModel = configuredByName.get(name);
const tier = configuredModel ? normalizeTier(configuredModel.tier) : null;
const opt = document.createElement('option');
opt.value = name;
if (!configuredModel) {
opt.textContent = `${name} (unpublished)`;
} else {
opt.textContent = `${name} (${tier.toUpperCase()})`;
if (tier !== 'free') opt.disabled = true;
}
discoveredGroup.appendChild(opt);
});
const hasFree = freeGroup.children.length > 0;
const hasDiscovered = discoveredGroup.children.length > 0;
if (hasFree) {
el.autoModelSelect.appendChild(freeGroup);
}
if (hasDiscovered) {
el.autoModelSelect.appendChild(discoveredGroup);
}
if (!hasFree && !hasDiscovered) {
const note = document.createElement('option');
note.value = '__none__';
note.textContent = '(No models discovered yet)';
note.disabled = true;
el.autoModelSelect.appendChild(note);
}
const currentName = (current || '').trim();
if (currentName && !Array.from(el.autoModelSelect.options).some((opt) => opt.value === currentName)) {
const orphan = document.createElement('option');
orphan.value = currentName;
orphan.textContent = `${currentName} (current selection)`;
el.autoModelSelect.appendChild(orphan);
}
el.autoModelSelect.value = currentName;
}
function populateFreePlanModelOptions(selectedValue) {
if (!el.freePlanModel) return;
const current = selectedValue || el.freePlanModel.value;
el.freePlanModel.innerHTML = '';
const auto = document.createElement('option');
auto.value = '';
auto.textContent = 'Auto (use default)';
el.freePlanModel.appendChild(auto);
(state.configured || []).forEach((m) => {
const opt = document.createElement('option');
opt.value = m.name || m.id || '';
opt.textContent = m.label || m.name || m.id || '';
el.freePlanModel.appendChild(opt);
});
if (current !== undefined && current !== null) {
el.freePlanModel.value = current;
}
}
function populateOpencodeBackupOptions(selectedValue) {
console.log('populateOpencodeBackupOptions called with:', selectedValue);
if (!el.opencodeBackup) {
console.log('el.opencodeBackup is null, returning early');
return;
}
console.log('el.opencodeBackup found, populating...');
const current = selectedValue || el.opencodeBackup.value;
el.opencodeBackup.innerHTML = '';
const allModels = new Set();
(state.available || []).forEach((m) => {
const name = m.name || m.id || m;
if (name) allModels.add(name);
});
(state.configured || []).forEach((m) => {
if (m.name) allModels.add(m.name);
});
Object.values(state.providerModels || {}).forEach((arr) => {
(arr || []).forEach((name) => { if (name) allModels.add(name); });
});
console.log('Found models:', Array.from(allModels));
const sorted = Array.from(allModels).filter(Boolean).sort((a, b) => a.localeCompare(b));
const none = document.createElement('option');
none.value = '';
none.textContent = 'None (no backup)';
el.opencodeBackup.appendChild(none);
sorted.forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
el.opencodeBackup.appendChild(opt);
});
if (current) el.opencodeBackup.value = current;
console.log('Dropdown populated with', sorted.length + 1, 'options');
}
function formatDisplayDate(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function renderAccounts() {
if (!el.accountsTable) return;
el.accountsTable.innerHTML = '';
if (el.accountsCount) el.accountsCount.textContent = `${state.accounts.length} accounts`;
if (!state.accounts.length) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 8;
cell.textContent = 'No accounts found.';
cell.className = 'muted';
cell.style.padding = '12px';
row.appendChild(cell);
el.accountsTable.appendChild(row);
return;
}
state.accounts.forEach((acct) => {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid var(--border)';
[
acct.email || 'Unknown',
acct.plan || 'starter',
acct.billingStatus || 'active',
acct.billingEmail || '—',
formatDisplayDate(acct.subscriptionRenewsAt),
formatDisplayDate(acct.createdAt),
formatDisplayDate(acct.lastLoginAt),
].forEach((value) => {
const cell = document.createElement('td');
cell.style.padding = '10px 8px';
cell.textContent = value;
row.appendChild(cell);
});
const actionsCell = document.createElement('td');
actionsCell.style.padding = '10px 8px';
actionsCell.style.display = 'flex';
actionsCell.style.gap = '8px';
const changeBtn = document.createElement('button');
changeBtn.className = 'ghost';
changeBtn.textContent = 'Change Plan';
changeBtn.title = 'Change user plan without payment';
changeBtn.addEventListener('click', () => changePlan(acct));
actionsCell.appendChild(changeBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'danger';
deleteBtn.textContent = 'Delete';
deleteBtn.title = 'Permanently delete user and all data';
deleteBtn.addEventListener('click', () => deleteUser(acct));
actionsCell.appendChild(deleteBtn);
row.appendChild(actionsCell);
el.accountsTable.appendChild(row);
});
}
async function changePlan(acct) {
const plans = ['hobby', 'starter', 'business', 'enterprise'];
const currentPlan = acct.plan || 'hobby';
const nextPlan = prompt(`Change plan for ${acct.email}\nCurrent plan: ${currentPlan}\n\nEnter new plan (hobby, starter, business, enterprise):`, currentPlan);
if (nextPlan === null) return;
const normalized = nextPlan.trim().toLowerCase();
if (!plans.includes(normalized)) {
alert('Invalid plan. Please enter hobby, starter, business, or enterprise.');
return;
}
if (normalized === currentPlan) return;
if (!confirm(`Are you sure you want to change ${acct.email}'s plan to ${normalized.toUpperCase()}? This will take effect immediately without charging them.`)) {
return;
}
setStatus(`Updating plan for ${acct.email}...`);
try {
await api('/api/admin/accounts/plan', {
method: 'POST',
body: JSON.stringify({ userId: acct.id, plan: normalized })
});
setStatus('Plan updated successfully');
await loadAccounts();
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setStatus(err.message, true);
}
}
async function deleteUser(acct) {
const confirmation = window.confirm(
`Are you absolutely sure you want to permanently delete ${acct.email}?\n\n` +
`This will:\n` +
`• Delete the user account permanently\n` +
`• Remove all their apps/sessions\n` +
`• Delete all their workspace data\n` +
`• This action CANNOT be undone!\n\n` +
`Type DELETE to confirm:`
);
if (!confirmation) return;
const confirmationText = window.prompt(
'This action cannot be undone. Type DELETE to confirm permanently deleting this user:'
);
if (confirmationText !== 'DELETE') {
alert('Deletion cancelled. You must type DELETE to confirm.');
return;
}
setStatus(`Permanently deleting ${acct.email}...`);
try {
await api('/api/admin/accounts', {
method: 'DELETE',
body: JSON.stringify({ userId: acct.id })
});
setStatus('User permanently deleted');
await loadAccounts();
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setStatus(err.message, true);
}
}
async function loadAccounts() {
if (!el.accountsTable) return;
setStatus('Loading accounts...');
try {
const data = await api('/api/admin/accounts');
state.accounts = data.accounts || [];
renderAccounts();
setStatus('');
} catch (err) {
setStatus(err.message, true);
}
}
function renderAffiliates() {
if (!el.affiliatesTable) return;
el.affiliatesTable.innerHTML = '';
if (el.affiliatesCount) el.affiliatesCount.textContent = `${state.affiliates.length} affiliates`;
if (!state.affiliates.length) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 8;
cell.textContent = 'No affiliate accounts found.';
cell.className = 'muted';
cell.style.padding = '12px';
row.appendChild(cell);
el.affiliatesTable.appendChild(row);
return;
}
state.affiliates.forEach((aff) => {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid var(--border)';
const emailCell = document.createElement('td');
emailCell.style.padding = '10px 8px';
emailCell.textContent = aff.email || 'Unknown';
row.appendChild(emailCell);
const nameCell = document.createElement('td');
nameCell.style.padding = '10px 8px';
nameCell.textContent = aff.name || '—';
row.appendChild(nameCell);
const commissionCell = document.createElement('td');
commissionCell.style.padding = '10px 8px';
commissionCell.textContent = `${((aff.commissionRate ?? 0.075) * 100).toFixed(1)}%`;
row.appendChild(commissionCell);
const earningsCell = document.createElement('td');
earningsCell.style.padding = '10px 8px';
const earningsTotal = aff.earnings?.total || 0;
earningsCell.textContent = `$${earningsTotal.toFixed(2)}`;
row.appendChild(earningsCell);
const linksCell = document.createElement('td');
linksCell.style.padding = '10px 8px';
const linksCount = Array.isArray(aff.trackingLinks) ? aff.trackingLinks.length : 0;
linksCell.textContent = linksCount > 0 ? `${linksCount} link${linksCount > 1 ? 's' : ''}` : '—';
row.appendChild(linksCell);
const createdCell = document.createElement('td');
createdCell.style.padding = '10px 8px';
createdCell.textContent = formatDisplayDate(aff.createdAt);
row.appendChild(createdCell);
const lastLoginCell = document.createElement('td');
lastLoginCell.style.padding = '10px 8px';
lastLoginCell.textContent = formatDisplayDate(aff.lastLoginAt);
row.appendChild(lastLoginCell);
const actionsCell = document.createElement('td');
actionsCell.style.padding = '10px 8px';
actionsCell.style.display = 'flex';
actionsCell.style.gap = '8px';
const viewLinksBtn = document.createElement('button');
viewLinksBtn.className = 'ghost';
viewLinksBtn.textContent = 'View Links';
viewLinksBtn.title = 'View tracking links';
viewLinksBtn.addEventListener('click', () => viewAffiliateLinks(aff));
actionsCell.appendChild(viewLinksBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'danger';
deleteBtn.textContent = 'Delete';
deleteBtn.title = 'Permanently delete affiliate account';
deleteBtn.addEventListener('click', () => deleteAffiliate(aff));
actionsCell.appendChild(deleteBtn);
row.appendChild(actionsCell);
el.affiliatesTable.appendChild(row);
});
}
function viewAffiliateLinks(aff) {
if (!aff.trackingLinks || !aff.trackingLinks.length) {
alert('No tracking links for this affiliate.');
return;
}
const linksList = aff.trackingLinks.map(l => `${l.code}${l.targetPath || '/'}`).join('\n');
alert(`Tracking links for ${aff.email}:\n\n${linksList}`);
}
async function deleteAffiliate(aff) {
const confirmation = window.confirm(
`Are you absolutely sure you want to permanently delete affiliate ${aff.email}?\n\n` +
`This will:\n` +
`• Delete the affiliate account permanently\n` +
`• All tracking links will stop working\n` +
`• Commission tracking for past referrals will remain\n` +
`• This action CANNOT be undone!\n\n` +
`Type DELETE to confirm:`
);
if (!confirmation) return;
const confirmationText = window.prompt(
'This action cannot be undone. Type DELETE to confirm permanently deleting this affiliate:'
);
if (confirmationText !== 'DELETE') {
alert('Deletion cancelled. You must type DELETE to confirm.');
return;
}
setStatus(`Permanently deleting affiliate ${aff.email}...`);
try {
await api('/api/admin/affiliates', {
method: 'DELETE',
body: JSON.stringify({ affiliateId: aff.id })
});
setStatus('Affiliate permanently deleted');
await loadAffiliates();
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setStatus(err.message, true);
}
}
async function loadAffiliates() {
if (!el.affiliatesTable) return;
setStatus('Loading affiliates...');
try {
const data = await api('/api/admin/affiliates');
state.affiliates = data.affiliates || [];
renderAffiliates();
setStatus('');
} catch (err) {
setStatus(err.message, true);
}
}
async function loadWithdrawals() {
if (!el.withdrawalsTable) return;
setStatus('Loading withdrawals...');
try {
const data = await api('/api/admin/withdrawals');
state.withdrawals = data.withdrawals || [];
renderWithdrawals();
setStatus('');
} catch (err) {
setStatus(err.message, true);
}
}
function renderWithdrawals() {
if (!el.withdrawalsTable) return;
el.withdrawalsTable.innerHTML = '';
if (el.withdrawalsCount) el.withdrawalsCount.textContent = `${state.withdrawals.length} requests`;
if (!state.withdrawals.length) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 7;
cell.textContent = 'No withdrawal requests found.';
cell.className = 'muted';
cell.style.padding = '12px';
row.appendChild(cell);
el.withdrawalsTable.appendChild(row);
return;
}
state.withdrawals.forEach((w) => {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid var(--border)';
const dateCell = document.createElement('td');
dateCell.style.padding = '10px 8px';
dateCell.textContent = formatDisplayDate(w.createdAt);
row.appendChild(dateCell);
const affiliateCell = document.createElement('td');
affiliateCell.style.padding = '10px 8px';
affiliateCell.textContent = w.affiliateEmail || 'Unknown';
row.appendChild(affiliateCell);
const paypalCell = document.createElement('td');
paypalCell.style.padding = '10px 8px';
paypalCell.textContent = w.paypalEmail || '—';
row.appendChild(paypalCell);
const amountCell = document.createElement('td');
amountCell.style.padding = '10px 8px';
amountCell.textContent = `$${Number(w.amount || 0).toFixed(2)}`;
row.appendChild(amountCell);
const currencyCell = document.createElement('td');
currencyCell.style.padding = '10px 8px';
currencyCell.textContent = w.currency || 'USD';
row.appendChild(currencyCell);
const statusCell = document.createElement('td');
statusCell.style.padding = '10px 8px';
const statusBadge = document.createElement('span');
statusBadge.className = 'pill';
statusBadge.textContent = w.status || 'pending';
statusBadge.style.background = w.status === 'done' ? 'var(--success)' : 'var(--warning)';
statusCell.appendChild(statusBadge);
row.appendChild(statusCell);
const actionsCell = document.createElement('td');
actionsCell.style.padding = '10px 8px';
actionsCell.style.display = 'flex';
actionsCell.style.gap = '8px';
if (w.status === 'pending') {
const markDoneBtn = document.createElement('button');
markDoneBtn.className = 'ghost';
markDoneBtn.textContent = 'Mark Done';
markDoneBtn.title = 'Mark withdrawal as completed';
markDoneBtn.addEventListener('click', () => updateWithdrawalStatus(w, 'done'));
actionsCell.appendChild(markDoneBtn);
}
const detailsBtn = document.createElement('button');
detailsBtn.className = 'ghost';
detailsBtn.textContent = 'Details';
detailsBtn.title = 'View withdrawal details';
detailsBtn.addEventListener('click', () => viewWithdrawalDetails(w));
actionsCell.appendChild(detailsBtn);
row.appendChild(actionsCell);
el.withdrawalsTable.appendChild(row);
});
}
function viewWithdrawalDetails(w) {
alert(`Withdrawal Details:\n\n` +
`Date: ${new Date(w.createdAt).toLocaleString()}\n` +
`Affiliate: ${w.affiliateEmail || 'Unknown'}\n` +
`PayPal Email: ${w.paypalEmail || '—'}\n` +
`Amount: $${Number(w.amount || 0).toFixed(2)}\n` +
`Currency: ${w.currency || 'USD'}\n` +
`Status: ${w.status || 'pending'}`);
}
async function updateWithdrawalStatus(withdrawal, newStatus) {
setStatus('Updating withdrawal status...');
try {
await api('/api/admin/withdrawals', {
method: 'PUT',
body: JSON.stringify({ withdrawalId: withdrawal.id, status: newStatus })
});
setStatus(`Withdrawal marked as ${newStatus}`);
await loadWithdrawals();
setTimeout(() => setStatus(''), 3000);
} catch (err) {
setStatus(err.message, true);
}
}
async function init() {
console.log('init() called');
try {
const loaders = [
() => ((el.availableModels || el.planPriorityList) ? loadAvailable() : null),
() => ((el.iconSelect || el.iconList) ? loadIcons() : null),
() => (el.configuredList ? loadConfigured() : null),
() => (el.orForm ? loadOpenRouterSettings() : null),
() => (el.mistralForm ? loadMistralSettings() : null),
() => ((el.autoModelForm || el.planProviderForm || el.planPriorityList) ? loadPlanProviderSettings() : null),
() => (el.accountsTable ? loadAccounts() : null),
() => (el.affiliatesTable ? loadAffiliates() : null),
() => (el.withdrawalsTable ? loadWithdrawals() : null),
() => (el.planTokensTable ? loadPlanTokens() : null),
() => ((el.tokenRateUsd || el.tokenRateGbp || el.tokenRateEur) ? loadTokenRates() : null),
() => ((el.providerUsage || el.providerLimitForm) ? loadProviderLimits() : null),
() => (el.externalTestingConfig ? loadExternalTestingStatus() : null),
];
await Promise.all(loaders.map((fn) => fn()).filter(Boolean));
// Always try to load provider limits if not already loaded (needed for backup dropdown)
if (!state.providerModels || Object.keys(state.providerModels).length === 0) {
try {
const data = await api('/api/admin/provider-limits');
state.providerLimits = data.limits || {};
state.providerOptions = data.providers || [];
state.providerModels = data.providerModels || {};
state.opencodeBackupModel = data.opencodeBackupModel || '';
} catch (e) {
console.warn('Failed to load provider limits for backup dropdown:', e);
}
}
// Ensure opencode backup dropdown is populated
if (el.opencodeBackup) {
populateOpencodeBackupOptions(state.opencodeBackupModel);
}
} catch (err) {
setStatus(err.message, true);
}
}
if (el.modelForm) {
el.modelForm.addEventListener('submit', async (e) => {
e.preventDefault();
const model = el.availableModels.value;
const label = el.displayLabel.value.trim();
const icon = el.iconSelect.value;
const tier = el.modelTier ? el.modelTier.value : 'free';
if (!model) {
setStatus('Pick a model to add.', true);
return;
}
if (!label) {
setStatus('Add a display name.', true);
return;
}
const providers = parseProviderOrderInput(el.providerOrder ? el.providerOrder.value : '', model);
const supportsMedia = el.supportsMedia ? el.supportsMedia.checked : false;
setStatus('Saving...');
try {
await api('/api/admin/models', {
method: 'POST',
body: JSON.stringify({ model, label, icon, providers, tier, supportsMedia }),
});
setStatus('Saved');
await loadConfigured();
} catch (err) {
setStatus(err.message, true);
}
});
}
if (el.orForm) {
el.orForm.addEventListener('submit', async (e) => {
e.preventDefault();
const primaryModel = el.orPrimary.value.trim();
const backupModel1 = el.orBackup1.value.trim();
const backupModel2 = el.orBackup2.value.trim();
const backupModel3 = el.orBackup3.value.trim();
if (!primaryModel) {
setOrStatus('Primary model is required.', true);
return;
}
setOrStatus('Saving...');
try {
await api('/api/admin/openrouter-settings', {
method: 'POST',
body: JSON.stringify({ primaryModel, backupModel1, backupModel2, backupModel3 }),
});
setOrStatus('Saved');
setTimeout(() => setOrStatus(''), 3000);
} catch (err) {
setOrStatus(err.message, true);
}
});
}
if (el.mistralForm) {
el.mistralForm.addEventListener('submit', async (e) => {
e.preventDefault();
const primaryModel = el.mistralPrimary.value.trim();
const backupModel1 = el.mistralBackup1.value.trim();
const backupModel2 = el.mistralBackup2.value.trim();
const backupModel3 = el.mistralBackup3.value.trim();
if (!primaryModel) {
setMistralStatus('Primary model is required.', true);
return;
}
setMistralStatus('Saving...');
try {
await api('/api/admin/mistral-settings', {
method: 'POST',
body: JSON.stringify({ primaryModel, backupModel1, backupModel2, backupModel3 }),
});
setMistralStatus('Saved');
setTimeout(() => setMistralStatus(''), 3000);
} catch (err) {
setMistralStatus(err.message, true);
}
});
}
if (el.opencodeBackupForm) {
el.opencodeBackupForm.addEventListener('submit', async (e) => {
e.preventDefault();
const opencodeBackupModel = el.opencodeBackup ? el.opencodeBackup.value.trim() : '';
setOpencodeBackupStatus('Saving...');
try {
const res = await api('/api/admin/provider-limits', {
method: 'POST',
body: JSON.stringify({ provider: 'opencode', scope: 'provider', model: '', tokensPerMinute: '', tokensPerDay: '', requestsPerMinute: '', requestsPerDay: '', opencodeBackupModel }),
});
// update local state and refresh dropdowns
state.opencodeBackupModel = res.opencodeBackupModel || opencodeBackupModel || '';
populateOpencodeBackupOptions(state.opencodeBackupModel);
setOpencodeBackupStatus('Saved');
setTimeout(() => setOpencodeBackupStatus(''), 3000);
} catch (err) {
setOpencodeBackupStatus(err.message, true);
}
});
}
if (el.autoModelForm) {
el.autoModelForm.addEventListener('submit', async (e) => {
e.preventDefault();
const freePlanModel = el.autoModelSelect ? el.autoModelSelect.value.trim() : '';
setAutoModelStatus('Saving...');
try {
await api('/api/admin/plan-settings', {
method: 'POST',
body: JSON.stringify({ freePlanModel }),
});
setAutoModelStatus('Saved! Free plan users will use this model.');
setTimeout(() => setAutoModelStatus(''), 3000);
} catch (err) {
setAutoModelStatus(err.message, true);
}
});
}
if (el.planProviderForm) {
el.planProviderForm.addEventListener('submit', async (e) => {
e.preventDefault();
const provider = el.planProvider.value.trim();
if (!provider || !PLANNING_PROVIDERS.includes(provider)) {
setPlanProviderStatus('Invalid provider selected.', true);
return;
}
setPlanProviderStatus('Saving...');
try {
await api('/api/admin/plan-settings', {
method: 'POST',
body: JSON.stringify({ provider }),
});
setPlanProviderStatus('Saved');
setTimeout(() => setPlanProviderStatus(''), 3000);
} catch (err) {
setPlanProviderStatus(err.message, true);
}
});
}
if (el.addPlanRow) {
el.addPlanRow.addEventListener('click', async () => {
const current = normalizePlanChainLocal(state.planSettings?.planningChain || []);
const next = [...current, { provider: state.planSettings?.provider || 'openrouter', model: '' }];
await persistPlanChain(next);
});
}
if (el.providerLimitForm) {
el.providerLimitForm.addEventListener('submit', async (e) => {
e.preventDefault();
const provider = el.limitProvider.value;
const scope = el.limitScope.value;
const payload = {
provider,
scope,
model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(),
tokensPerMinute: Number(el.limitTpm.value || 0),
tokensPerDay: Number(el.limitTpd.value || 0),
requestsPerMinute: Number(el.limitRpm.value || 0),
requestsPerDay: Number(el.limitRpd.value || 0),
opencodeBackupModel: el.limitBackup.value.trim(),
};
setProviderLimitStatus('Saving...');
try {
await api('/api/admin/provider-limits', { method: 'POST', body: JSON.stringify(payload) });
setProviderLimitStatus('Saved');
await loadProviderLimits();
setTimeout(() => setProviderLimitStatus(''), 3000);
} catch (err) {
setProviderLimitStatus(err.message, true);
}
});
}
if (el.limitProvider) {
el.limitProvider.addEventListener('change', () => {
populateLimitForm(el.limitProvider.value, el.limitScope ? el.limitScope.value : 'provider');
});
}
if (el.limitScope) {
el.limitScope.addEventListener('change', () => {
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope.value);
});
}
if (el.limitModel) {
el.limitModel.addEventListener('change', () => {
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider');
});
}
if (el.availableModels) {
el.availableModels.addEventListener('change', () => {
const selected = state.available.find((m) => (m.name || m.id || m) === el.availableModels.value);
if (selected && !el.displayLabel.value) el.displayLabel.value = selected.label || selected.name || '';
});
}
if (el.reloadAvailable) {
el.reloadAvailable.addEventListener('click', async () => {
setStatus('Refreshing available models...');
await loadAvailable();
setStatus('');
});
}
if (el.refresh) {
el.refresh.addEventListener('click', async () => {
setStatus('Refreshing...');
await init();
setStatus('');
});
}
// Plan tokens save button
if (el.savePlanTokens) {
el.savePlanTokens.addEventListener('click', async () => {
await savePlanTokens();
});
}
// Token rates save button
if (el.saveTokenRates) {
el.saveTokenRates.addEventListener('click', async () => {
await saveTokenRates();
});
}
// Cancel all messages button
if (el.cancelAllMessages) {
el.cancelAllMessages.addEventListener('click', async () => {
const confirmed = window.confirm('Are you sure you want to cancel ALL running and queued messages? This action cannot be undone.');
if (!confirmed) return;
el.cancelAllMessages.disabled = true;
if (el.cancelMessagesStatus) el.cancelMessagesStatus.textContent = 'Cancelling...';
try {
const data = await api('/api/admin/cancel-messages', { method: 'POST' });
if (el.cancelMessagesStatus) {
el.cancelMessagesStatus.textContent = `Cancelled ${data.totalCancelled} messages (${data.runningCancelled} running, ${data.queuedCancelled} queued) across ${data.sessionsAffected} sessions`;
el.cancelMessagesStatus.style.color = 'var(--accent)';
}
setTimeout(() => {
if (el.cancelMessagesStatus) {
el.cancelMessagesStatus.textContent = '';
el.cancelMessagesStatus.style.color = 'inherit';
}
}, 5000);
} catch (err) {
if (el.cancelMessagesStatus) {
el.cancelMessagesStatus.textContent = err.message || 'Failed to cancel messages';
el.cancelMessagesStatus.style.color = 'var(--danger)';
}
} finally {
el.cancelAllMessages.disabled = false;
}
});
}
if (el.externalTestingRun) {
el.externalTestingRun.addEventListener('click', async () => {
el.externalTestingRun.disabled = true;
setExternalTestingStatus('Running self-test...');
try {
const data = await api('/api/admin/external-testing-self-test', { method: 'POST' });
renderExternalTestingOutput(data.result || null);
setExternalTestingStatus(data.result && data.result.ok ? 'Self-test passed.' : 'Self-test failed.', !data.result || !data.result.ok);
} catch (err) {
setExternalTestingStatus(err.message || 'Self-test failed.', true);
} finally {
el.externalTestingRun.disabled = false;
}
});
}
// Ollama Test button handler
if (el.ollamaTestRun) {
el.ollamaTestRun.addEventListener('click', async () => {
el.ollamaTestRun.disabled = true;
setOllamaTestStatus('Running Ollama test...');
if (el.ollamaTestOutput) el.ollamaTestOutput.innerHTML = '';
try {
const data = await api('/api/admin/ollama-test', { method: 'POST' });
renderOllamaTestOutput(data);
if (data.ok) {
setOllamaTestStatus(`Test passed! Response time: ${data.duration}ms`);
} else {
setOllamaTestStatus(`Test failed: ${data.error?.message || 'Unknown error'}`, true);
}
} catch (err) {
setOllamaTestStatus(err.message || 'Test failed', true);
if (el.ollamaTestOutput) {
el.ollamaTestOutput.innerHTML = `<div style="color: var(--danger); padding: 12px;">Error: ${err.message || 'Request failed'}</div>`;
}
} finally {
el.ollamaTestRun.disabled = false;
}
});
}
if (el.logout) {
el.logout.addEventListener('click', async () => {
await api('/api/admin/logout', { method: 'POST' }).catch(() => { });
window.location.href = '/admin/login';
});
}
// Mobile sidebar toggle
const menuToggle = document.getElementById('menu-toggle');
const closeSidebar = document.getElementById('close-sidebar');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.querySelector('.sidebar-overlay');
if (menuToggle && sidebar) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('active');
if (sidebarOverlay) {
sidebarOverlay.classList.toggle('active');
}
document.body.classList.toggle('sidebar-open');
});
}
if (closeSidebar && sidebar) {
closeSidebar.addEventListener('click', () => {
sidebar.classList.remove('active');
if (sidebarOverlay) {
sidebarOverlay.classList.remove('active');
}
document.body.classList.remove('sidebar-open');
});
}
// Close sidebar when clicking on overlay
if (sidebarOverlay && sidebar) {
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('active');
sidebarOverlay.classList.remove('active');
document.body.classList.remove('sidebar-open');
});
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => {
if (sidebar && sidebar.classList.contains('active')) {
if (!sidebar.contains(e.target) && (!menuToggle || !menuToggle.contains(e.target))) {
sidebar.classList.remove('active');
if (sidebarOverlay) {
sidebarOverlay.classList.remove('active');
}
document.body.classList.remove('sidebar-open');
}
}
});
// Highlight active link in sidebar
try {
const navLinks = document.querySelectorAll('.sidebar-section a');
navLinks.forEach((a) => {
const href = a.getAttribute('href');
const current = window.location.pathname;
const isMatch = href === current || (href === '/admin/build' && current === '/admin');
if (isMatch) {
a.classList.add('active');
a.setAttribute('aria-current', 'page');
}
});
} catch (err) { }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();