- Add publicModels and providerChain data structures for unified fallback - Add two separate model adding sections in admin panel (public-facing and provider models) - Add up/down buttons to reorder provider chain order - Update server to use unified chain for all model fallbacks - Auto-migrate legacy data on first load - Update admin.js to handle new model structure and forms
2693 lines
102 KiB
JavaScript
2693 lines
102 KiB
JavaScript
(() => {
|
|
const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode', 'cohere'];
|
|
const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'cohere'];
|
|
const pageType = document?.body?.dataset?.page || 'build';
|
|
console.log('Admin JS loaded, pageType:', pageType);
|
|
const state = {
|
|
available: [],
|
|
configured: [],
|
|
publicModels: [], // New unified structure - public facing models
|
|
providerChain: [], // New unified structure - provider fallback chain
|
|
icons: [],
|
|
accounts: [],
|
|
affiliates: [],
|
|
withdrawals: [],
|
|
planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] },
|
|
providerLimits: {},
|
|
providerUsage: [],
|
|
opencodeBackupModel: '',
|
|
providerOptions: [],
|
|
providerModels: {},
|
|
tokenRates: {},
|
|
};
|
|
|
|
const el = {
|
|
// New unified model elements
|
|
publicModelForm: document.getElementById('public-model-form'),
|
|
publicModelName: document.getElementById('public-model-name'),
|
|
publicModelLabel: document.getElementById('public-model-label'),
|
|
publicModelTier: document.getElementById('public-model-tier'),
|
|
publicModelIcon: document.getElementById('public-model-icon'),
|
|
publicModelMedia: document.getElementById('public-model-media'),
|
|
publicModelStatus: document.getElementById('public-model-status'),
|
|
providerChainForm: document.getElementById('provider-chain-form'),
|
|
chainProvider: document.getElementById('chain-provider'),
|
|
chainModel: document.getElementById('chain-model'),
|
|
providerChainStatus: document.getElementById('provider-chain-status'),
|
|
providerChainList: document.getElementById('provider-chain-list'),
|
|
providerChainCount: document.getElementById('provider-chain-count'),
|
|
// Legacy elements
|
|
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'),
|
|
limitTph: document.getElementById('limit-tph'),
|
|
limitTpd: document.getElementById('limit-tpd'),
|
|
limitRpm: document.getElementById('limit-rpm'),
|
|
limitRph: document.getElementById('limit-rph'),
|
|
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 setPublicModelStatus(msg, isError = false) {
|
|
if (!el.publicModelStatus) return;
|
|
el.publicModelStatus.textContent = msg || '';
|
|
el.publicModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setProviderChainStatus(msg, isError = false) {
|
|
if (!el.providerChainStatus) return;
|
|
el.providerChainStatus.textContent = msg || '';
|
|
el.providerChainStatus.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() {
|
|
// Populate legacy icon select
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Populate new public model icon select
|
|
if (el.publicModelIcon) {
|
|
el.publicModelIcon.innerHTML = '';
|
|
const none = document.createElement('option');
|
|
none.value = '';
|
|
none.textContent = 'No icon';
|
|
el.publicModelIcon.appendChild(none);
|
|
state.icons.forEach((iconPath) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = iconPath;
|
|
opt.textContent = iconPath.replace('/assets/', '');
|
|
el.publicModelIcon.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);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Simplified renderConfigured for new publicModels structure
|
|
function renderConfigured() {
|
|
if (!el.configuredList) return;
|
|
el.configuredList.innerHTML = '';
|
|
|
|
// Use publicModels if available, otherwise fall back to configured
|
|
const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured;
|
|
|
|
if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString();
|
|
if (!modelsToRender.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'muted';
|
|
empty.textContent = 'No public-facing models configured yet.';
|
|
el.configuredList.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
modelsToRender.forEach((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);
|
|
}
|
|
|
|
// Show chain info badge
|
|
const chainBadge = document.createElement('span');
|
|
chainBadge.className = 'pill';
|
|
chainBadge.style.background = 'var(--primary)';
|
|
chainBadge.textContent = `Uses unified chain (${state.providerChain.length} providers)`;
|
|
info.appendChild(chainBadge);
|
|
|
|
header.appendChild(info);
|
|
|
|
const headerActions = document.createElement('div');
|
|
headerActions.className = 'provider-row-actions';
|
|
|
|
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', () => {
|
|
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 persistPublicModelChanges(m.id, { icon: 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(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 persistPublicModelChanges(m.id, { supportsMedia: 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 persistPublicModelChanges(m.id, { tier: 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);
|
|
el.configuredList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function persistPublicModelChanges(modelId, changes) {
|
|
setStatus('Saving...');
|
|
const model = state.publicModels.find((m) => m.id === modelId);
|
|
if (!model) {
|
|
setStatus('Model not found', true);
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
type: 'publicModel',
|
|
id: modelId,
|
|
name: model.name,
|
|
label: model.label || model.name,
|
|
icon: changes.icon !== undefined ? changes.icon : model.icon,
|
|
tier: changes.tier !== undefined ? changes.tier : model.tier,
|
|
supportsMedia: changes.supportsMedia !== undefined ? changes.supportsMedia : model.supportsMedia,
|
|
};
|
|
|
|
try {
|
|
const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
|
|
const idx = state.publicModels.findIndex((m) => m.id === modelId);
|
|
if (idx >= 0) state.publicModels[idx] = { ...state.publicModels[idx], ...data.publicModel };
|
|
renderConfigured();
|
|
setStatus('Saved');
|
|
setTimeout(() => setStatus(''), 1500);
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
// Render the unified provider chain with up/down controls
|
|
function renderProviderChain() {
|
|
if (!el.providerChainList) return;
|
|
el.providerChainList.innerHTML = '';
|
|
if (el.providerChainCount) el.providerChainCount.textContent = state.providerChain.length.toString();
|
|
|
|
if (!state.providerChain.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'muted';
|
|
empty.textContent = 'No provider chain configured. Add providers to enable automatic fallback.';
|
|
el.providerChainList.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
state.providerChain.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';
|
|
|
|
// Priority badge
|
|
const order = document.createElement('span');
|
|
order.className = 'pill';
|
|
order.textContent = idx === 0 ? 'Primary' : `#${idx + 1}`;
|
|
if (idx === 0) order.style.background = 'var(--shopify-green)';
|
|
info.appendChild(order);
|
|
|
|
// Provider badge
|
|
const providerPill = document.createElement('span');
|
|
providerPill.className = 'pill';
|
|
providerPill.textContent = entry.provider;
|
|
providerPill.style.background = 'var(--primary)';
|
|
info.appendChild(providerPill);
|
|
|
|
// Model name
|
|
const modelPill = document.createElement('span');
|
|
modelPill.textContent = entry.model;
|
|
info.appendChild(modelPill);
|
|
|
|
// Limit summary
|
|
const limitPill = document.createElement('span');
|
|
limitPill.className = 'pill';
|
|
limitPill.textContent = formatLimitSummary(entry.provider, entry.model);
|
|
info.appendChild(limitPill);
|
|
|
|
header.appendChild(info);
|
|
|
|
// Actions
|
|
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 = [...state.providerChain];
|
|
const [item] = next.splice(idx, 1);
|
|
next.splice(Math.max(0, idx - 1), 0, item);
|
|
await persistProviderChainOrder(next);
|
|
});
|
|
actions.appendChild(upBtn);
|
|
|
|
const downBtn = document.createElement('button');
|
|
downBtn.className = 'ghost';
|
|
downBtn.textContent = '↓';
|
|
downBtn.title = 'Move down';
|
|
downBtn.disabled = idx === state.providerChain.length - 1;
|
|
downBtn.addEventListener('click', async () => {
|
|
const next = [...state.providerChain];
|
|
const [item] = next.splice(idx, 1);
|
|
next.splice(Math.min(state.providerChain.length, idx + 1), 0, item);
|
|
await persistProviderChainOrder(next);
|
|
});
|
|
actions.appendChild(downBtn);
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'ghost';
|
|
removeBtn.textContent = 'Remove';
|
|
removeBtn.addEventListener('click', async () => {
|
|
if (state.providerChain.length <= 1) {
|
|
alert('Cannot remove the last provider. Add another provider first.');
|
|
return;
|
|
}
|
|
const next = state.providerChain.filter((_, i) => i !== idx);
|
|
await persistProviderChainOrder(next);
|
|
});
|
|
actions.appendChild(removeBtn);
|
|
|
|
header.appendChild(actions);
|
|
row.appendChild(header);
|
|
el.providerChainList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function persistProviderChainOrder(nextChain) {
|
|
setProviderChainStatus('Saving order...');
|
|
try {
|
|
const payload = { type: 'providerChain', chain: nextChain };
|
|
const res = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
|
|
state.providerChain = res.providerChain || nextChain;
|
|
renderProviderChain();
|
|
setProviderChainStatus('Saved');
|
|
setTimeout(() => setProviderChainStatus(''), 1500);
|
|
} catch (err) {
|
|
setProviderChainStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
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');
|
|
// Handle new unified structure
|
|
state.publicModels = data.publicModels || [];
|
|
state.providerChain = data.providerChain || [];
|
|
state.configured = data.models || []; // Legacy support
|
|
renderConfigured();
|
|
renderProviderChain();
|
|
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.limitTph) el.limitTph.value = target.tokensPerHour ?? '';
|
|
if (el.limitTpd) el.limitTpd.value = target.tokensPerDay ?? '';
|
|
if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? '';
|
|
if (el.limitRph) el.limitRph.value = target.requestsPerHour ?? '';
|
|
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 = 9;
|
|
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 tokenUsage = acct.tokenUsage || {};
|
|
const tokensUsed = tokenUsage.used || 0;
|
|
const tokensLimit = tokenUsage.limit || 0;
|
|
const tokenOverride = tokenUsage.tokenOverride;
|
|
const tokensDisplay = `${tokensUsed.toLocaleString()} / ${tokensLimit.toLocaleString()}${tokenOverride !== null && tokenOverride !== undefined ? ' (override)' : ''}`;
|
|
|
|
const tokenCell = document.createElement('td');
|
|
tokenCell.style.padding = '10px 8px';
|
|
tokenCell.textContent = tokensDisplay;
|
|
tokenCell.title = tokenOverride !== null && tokenOverride !== undefined ? `Manual override: ${tokenOverride.toLocaleString()} tokens` : 'Plan-based limit';
|
|
row.appendChild(tokenCell);
|
|
|
|
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 tokensBtn = document.createElement('button');
|
|
tokensBtn.className = 'ghost';
|
|
tokensBtn.textContent = 'Set Tokens';
|
|
tokensBtn.title = 'Manually set token limit for this user';
|
|
tokensBtn.addEventListener('click', () => setTokens(acct));
|
|
actionsCell.appendChild(tokensBtn);
|
|
|
|
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 setTokens(acct) {
|
|
const tokenUsage = acct.tokenUsage || {};
|
|
const currentLimit = tokenUsage.limit || 0;
|
|
const currentUsed = tokenUsage.used || 0;
|
|
const currentRemaining = tokenUsage.remaining || 0;
|
|
const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined;
|
|
const currentOverride = hasOverride ? tokenUsage.tokenOverride : '';
|
|
|
|
const modeMessage = `Manage tokens for ${acct.email}\n\n` +
|
|
`Current Status:\n` +
|
|
` Plan: ${acct.plan || 'starter'}\n` +
|
|
` Limit: ${currentLimit.toLocaleString()} tokens\n` +
|
|
` Used: ${currentUsed.toLocaleString()} tokens\n` +
|
|
` Remaining: ${currentRemaining.toLocaleString()} tokens\n` +
|
|
`${hasOverride ? ` Override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` +
|
|
`Choose mode:\n` +
|
|
` 1 = Set LIMIT (override plan limit)\n` +
|
|
` 2 = Set USED (directly set tokens consumed)\n` +
|
|
` 3 = Set REMAINING (set tokens left to use)`;
|
|
|
|
const modeInput = prompt(modeMessage, '1');
|
|
if (modeInput === null) return;
|
|
|
|
const mode = parseInt(modeInput.trim(), 10);
|
|
if (![1, 2, 3].includes(mode)) {
|
|
alert('Invalid mode. Please enter 1, 2, or 3.');
|
|
return;
|
|
}
|
|
|
|
let promptText = '';
|
|
let defaultValue = '';
|
|
let confirmText = '';
|
|
|
|
if (mode === 1) {
|
|
promptText = `Set token LIMIT for ${acct.email}\n\n` +
|
|
`Current limit: ${currentLimit.toLocaleString()} tokens\n` +
|
|
`${hasOverride ? `Current override: ${currentOverride.toLocaleString()}\n` : ''}\n` +
|
|
`Enter new limit (0 to remove override):`;
|
|
defaultValue = currentOverride;
|
|
} else if (mode === 2) {
|
|
promptText = `Set tokens USED for ${acct.email}\n\n` +
|
|
`Current used: ${currentUsed.toLocaleString()} tokens\n` +
|
|
`Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
|
|
`Enter new usage amount:`;
|
|
defaultValue = currentUsed;
|
|
} else {
|
|
promptText = `Set tokens REMAINING for ${acct.email}\n\n` +
|
|
`Current remaining: ${currentRemaining.toLocaleString()} tokens\n` +
|
|
`Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
|
|
`Enter remaining tokens (will calculate usage):`;
|
|
defaultValue = currentRemaining;
|
|
}
|
|
|
|
const tokenInput = prompt(promptText, String(defaultValue));
|
|
if (tokenInput === null) return;
|
|
|
|
const tokens = parseInt(tokenInput.trim(), 10);
|
|
if (isNaN(tokens) || tokens < 0) {
|
|
alert('Invalid token amount. Please enter a non-negative number.');
|
|
return;
|
|
}
|
|
|
|
if (mode === 1) {
|
|
confirmText = `Set ${acct.email}'s token LIMIT to ${tokens.toLocaleString()}?`;
|
|
} else if (mode === 2) {
|
|
confirmText = `Set ${acct.email}'s tokens USED to ${tokens.toLocaleString()}?`;
|
|
} else {
|
|
confirmText = `Set ${acct.email}'s tokens REMAINING to ${tokens.toLocaleString()}?`;
|
|
}
|
|
|
|
if (!confirm(confirmText)) {
|
|
return;
|
|
}
|
|
|
|
const modeNames = { 1: 'limit', 2: 'usage', 3: 'remaining' };
|
|
setStatus(`Updating ${modeNames[mode]} for ${acct.email}...`);
|
|
try {
|
|
await api('/api/admin/accounts/tokens', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ userId: acct.id, tokens: tokens, mode: modeNames[mode] })
|
|
});
|
|
setStatus(`Token ${modeNames[mode]} updated successfully`);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// New public model form handler
|
|
if (el.publicModelForm) {
|
|
el.publicModelForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const name = el.publicModelName.value.trim();
|
|
const label = el.publicModelLabel.value.trim();
|
|
const icon = el.publicModelIcon ? el.publicModelIcon.value : '';
|
|
const tier = el.publicModelTier ? el.publicModelTier.value : 'free';
|
|
const supportsMedia = el.publicModelMedia ? el.publicModelMedia.checked : false;
|
|
|
|
if (!name) {
|
|
setPublicModelStatus('Model ID is required.', true);
|
|
return;
|
|
}
|
|
if (!label) {
|
|
setPublicModelStatus('Display name is required.', true);
|
|
return;
|
|
}
|
|
|
|
setPublicModelStatus('Saving...');
|
|
try {
|
|
await api('/api/admin/models', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'publicModel',
|
|
name,
|
|
label,
|
|
icon,
|
|
tier,
|
|
supportsMedia
|
|
}),
|
|
});
|
|
setPublicModelStatus('Saved');
|
|
el.publicModelName.value = '';
|
|
el.publicModelLabel.value = '';
|
|
await loadConfigured();
|
|
} catch (err) {
|
|
setPublicModelStatus(err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
// New provider chain form handler
|
|
if (el.providerChainForm) {
|
|
el.providerChainForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const provider = el.chainProvider.value;
|
|
const model = el.chainModel.value.trim();
|
|
|
|
if (!model) {
|
|
setProviderChainStatus('Model name is required.', true);
|
|
return;
|
|
}
|
|
|
|
setProviderChainStatus('Adding to chain...');
|
|
try {
|
|
const newChain = [...state.providerChain, { provider, model }];
|
|
await api('/api/admin/models', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'providerChain',
|
|
chain: newChain
|
|
}),
|
|
});
|
|
setProviderChainStatus('Added to chain');
|
|
el.chainModel.value = '';
|
|
await loadConfigured();
|
|
} catch (err) {
|
|
setProviderChainStatus(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),
|
|
tokensPerHour: Number(el.limitTph.value || 0),
|
|
tokensPerDay: Number(el.limitTpd.value || 0),
|
|
requestsPerMinute: Number(el.limitRpm.value || 0),
|
|
requestsPerHour: Number(el.limitRph.value || 0),
|
|
requestsPerDay: Number(el.limitRpd.value || 0),
|
|
opencodeBackupModel: el.limitBackup ? 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();
|
|
}
|
|
})();
|