diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 05953c0e..c7553908 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -121,6 +121,29 @@ func TestStatic_ServesModuleJS(t *testing.T) { } } +func TestStatic_ServesAliasesModuleJS(t *testing.T) { + h, err := New() + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/admin/static/js/modules/aliases.js", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.Static(c); err != nil { + t.Fatalf("Static() returned error: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + if rec.Body.Len() == 0 { + t.Error("expected non-empty body for aliases module JS file") + } +} + func TestStatic_ServesFavicon(t *testing.T) { h, err := New() if err != nil { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 9fc87c7c..0478bbb4 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -27,6 +27,8 @@ --cal-level-2: #006d32; --cal-level-3: #26a641; --cal-level-4: #39d353; + --alias-row-valid-bg: color-mix(in srgb, var(--bg-surface-hover) 86%, #fff 14%); + --alias-row-valid-bg-hover: color-mix(in srgb, var(--bg-surface-hover) 72%, #fff 28%); color-scheme: dark; } @@ -53,6 +55,8 @@ --cal-level-2: #40c463; --cal-level-3: #30a14e; --cal-level-4: #216e39; + --alias-row-valid-bg: color-mix(in srgb, var(--bg-surface) 96%, var(--accent) 4%); + --alias-row-valid-bg-hover: color-mix(in srgb, var(--bg-surface) 90%, var(--accent) 10%); color-scheme: light; } @@ -80,6 +84,8 @@ --cal-level-2: #40c463; --cal-level-3: #30a14e; --cal-level-4: #216e39; + --alias-row-valid-bg: color-mix(in srgb, var(--bg-surface) 96%, var(--accent) 4%); + --alias-row-valid-bg-hover: color-mix(in srgb, var(--bg-surface) 90%, var(--accent) 10%); color-scheme: light; } } @@ -781,11 +787,29 @@ body { color: var(--warning); } +.alert-success { + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.28); + color: var(--success); +} + /* Table */ .table-toolbar { display: flex; gap: 12px; margin-bottom: 16px; + align-items: center; +} + +.table-toolbar-main { + flex: 1; + min-width: 0; +} + +.table-toolbar-actions { + margin-left: auto; + display: flex; + justify-content: flex-end; } .filter-input { @@ -867,6 +891,12 @@ td.col-price { font-weight: 500; } +.audit-alias-badge { + background: color-mix(in srgb, var(--accent) 14%, var(--bg)); + border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); + color: var(--accent-strong); +} + .empty-state { text-align: center; color: var(--text-muted); @@ -874,6 +904,289 @@ td.col-price { font-size: 14px; } +.table-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 12px; + font-family: inherit; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.table-action-btn:hover:not(:disabled) { + background: var(--bg-surface-hover); +} + +.table-action-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.table-action-btn-danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 50%, var(--border)); +} + +.model-alias-editor { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 16px; +} + +.model-name-cell { + display: flex; + flex-direction: column; + gap: 8px; +} + +.model-name-primary { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.model-name-secondary { + font-size: 12px; + color: var(--text-muted); +} + +.alias-kind-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + text-transform: uppercase; + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); +} + +.inline-link { + padding: 0; + border: none; + background: transparent; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.alias-form h3 { + font-size: 20px; + font-weight: 700; +} + +.alias-form-kicker, +.alias-form-hint { + color: var(--text-muted); + font-size: 13px; +} + +.alias-actions-cell { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.alias-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: all 0.15s; +} + +.alias-toggle:hover:not(:disabled) { + background: var(--bg-surface-hover); +} + +.alias-toggle:disabled { + opacity: 0.45; + cursor: default; +} + +.alias-toggle-track { + position: relative; + width: 34px; + height: 20px; + border-radius: 999px; + background: color-mix(in srgb, var(--border) 80%, var(--bg)); + transition: background 0.15s; +} + +.alias-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--text-muted); + transition: transform 0.15s, background 0.15s; +} + +.alias-toggle.enabled { + border-color: color-mix(in srgb, var(--success) 50%, var(--border)); +} + +.alias-toggle.enabled .alias-toggle-track { + background: color-mix(in srgb, var(--success) 55%, var(--bg)); +} + +.alias-toggle.enabled .alias-toggle-thumb { + transform: translateX(14px); + background: #fff; +} + +.aliases-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 20px; +} + +.alias-close-btn { + width: 36px; + min-width: 36px; + height: 36px; + padding: 0; + border-radius: 999px; + font-size: 14px; + font-weight: 700; +} + +.alias-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.alias-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.alias-form-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.alias-form-field > span { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.alias-form-textarea { + width: 100%; + min-height: 110px; + resize: vertical; + padding: 10px 12px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; +} + +.alias-form-textarea:focus { + border-color: var(--accent); +} + +.alias-checkbox { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; +} + +.alias-checkbox input { + width: 16px; + height: 16px; +} + +.alias-form-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.model-row-actions { + width: 170px; +} + +.model-row-actions-empty { + color: var(--text-muted); +} + +.data-table tr.alias-row.is-valid td { + background: var(--alias-row-valid-bg); +} + +.data-table tr.alias-row.is-valid:hover td { + background: var(--alias-row-valid-bg-hover); +} + +.data-table tr.alias-row:not(.is-valid) td { + background: color-mix(in srgb, var(--accent) 10%, var(--bg-surface)); +} + +.data-table tr.alias-row:not(.is-valid):hover td { + background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface-hover)); +} + +.data-table tr.alias-row.is-disabled td { + background: var(--bg-surface); + opacity: 0.58; +} + +.data-table tr.alias-row.is-disabled:hover td { + background: var(--bg-surface-hover); + opacity: 0.72; +} + +.data-table tr.masked-model-row td { + opacity: 0.58; +} + +.data-table tr.masked-model-row:hover td { + opacity: 0.72; +} + /* Usage Mode Toggle */ .usage-mode-toggle { display: inline-flex; @@ -1557,6 +1870,19 @@ body.conversation-drawer-open { background: var(--bg-surface-hover); } +.pagination-btn-primary { + background: var(--accent); + border-color: color-mix(in srgb, var(--accent) 70%, #000 10%); + color: #fff; + font-weight: 600; + box-shadow: 0 10px 22px rgba(59, 130, 246, 0.18); +} + +.pagination-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 90%, #fff 10%); + border-color: color-mix(in srgb, var(--accent) 78%, #000 12%); +} + .pagination-btn:disabled { opacity: 0.4; cursor: default; @@ -1751,6 +2077,34 @@ body.conversation-drawer-open { font-size: 11px; } + .alias-form-grid { + grid-template-columns: 1fr; + } + .table-toolbar { + flex-direction: column; + align-items: stretch; + } + .table-toolbar-actions { + margin-left: 0; + justify-content: stretch; + } + .table-toolbar-actions .pagination-btn { + width: 100%; + } + .model-alias-editor { + padding: 16px; + } + .alias-actions-cell, + .aliases-editor-header, + .model-name-primary { + flex-direction: column; + align-items: flex-start; + } + .alias-actions-cell .table-action-btn, + .aliases-editor-header .table-action-btn { + width: 100%; + } + /* Audit page mobile */ .audit-log-toolbar { gap: 8px; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 6e30206c..c3fd69a7 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -184,6 +184,9 @@ function dashboard() { this.authError = false; this.needsAuth = false; const requests = [this.fetchUsage(), this.fetchModels(), this.fetchCategories()]; + if (typeof this.fetchAliases === 'function') { + requests.push(this.fetchAliases()); + } if (this.hasCalendarModule && typeof this.fetchCalendarData === 'function') { requests.push(this.fetchCalendarData()); } @@ -322,6 +325,7 @@ function dashboard() { typeof dashboardDatePickerModule === 'function' ? dashboardDatePickerModule : null, typeof dashboardUsageModule === 'function' ? dashboardUsageModule : null, typeof dashboardAuditListModule === 'function' ? dashboardAuditListModule : null, + typeof dashboardAliasesModule === 'function' ? dashboardAliasesModule : null, typeof dashboardConversationDrawerModule === 'function' ? dashboardConversationDrawerModule : null, calendarModuleFactory, typeof dashboardChartsModule === 'function' ? dashboardChartsModule : null @@ -329,6 +333,7 @@ function dashboard() { return moduleFactories.reduce((app, factory) => { if (!factory) return app; - return Object.assign(app, factory()); + Object.defineProperties(app, Object.getOwnPropertyDescriptors(factory())); + return app; }, base); } diff --git a/internal/admin/dashboard/static/js/modules/aliases.js b/internal/admin/dashboard/static/js/modules/aliases.js new file mode 100644 index 00000000..1d716a1d --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/aliases.js @@ -0,0 +1,544 @@ +(function(global) { + function dashboardAliasesModule() { + return { + aliases: [], + aliasesAvailable: true, + aliasLoading: false, + aliasError: '', + aliasFormError: '', + aliasNotice: '', + aliasFormOpen: false, + aliasSubmitting: false, + aliasTogglingName: '', + aliasDeletingName: '', + aliasFormMode: 'create', + aliasFormOriginalName: '', + aliasForm: { + name: '', + target_model: '', + description: '', + enabled: true + }, + + get displayModels() { + const rows = this.models.map((model) => ({ + key: 'model:' + this.qualifiedModelName(model), + display_name: this.qualifiedModelName(model), + secondary_name: '', + provider_type: model.provider_type || '', + model: model.model, + is_alias: false, + alias: null, + kind_badge: '', + masking_alias: null, + alias_state_class: '', + alias_state_text: '' + })); + + if (!this.aliasesAvailable) { + return rows; + } + + const maskingAliases = new Map(); + for (const alias of this.aliases) { + const aliasName = String(alias && alias.name || '').trim().toLowerCase(); + if (!aliasName || alias.enabled === false || !alias.valid) { + continue; + } + maskingAliases.set(aliasName, alias); + } + + for (const row of rows) { + for (const key of this.modelIdentifierKeys(row.model && row.model.id, row.provider_type)) { + if (maskingAliases.has(key)) { + row.masking_alias = maskingAliases.get(key); + break; + } + } + } + + for (const alias of this.aliases) { + const targetModel = this.findConcreteModelForAlias(alias); + if (!targetModel && this.activeCategory && this.activeCategory !== 'all') { + continue; + } + + rows.push({ + key: 'alias:' + alias.name, + display_name: alias.name, + secondary_name: this.aliasTargetLabel(alias), + provider_type: targetModel ? (targetModel.provider_type || alias.provider_type || '') : (alias.provider_type || ''), + model: targetModel ? targetModel.model : { id: alias.name, object: 'model' }, + is_alias: true, + alias, + kind_badge: 'Alias', + masking_alias: null, + alias_state_class: this.aliasStateClass(alias), + alias_state_text: this.aliasStateText(alias) + }); + } + + return rows.sort((a, b) => { + if (a.is_alias !== b.is_alias) { + return a.is_alias ? -1 : 1; + } + return String(a.display_name || '').localeCompare(String(b.display_name || '')); + }); + }, + + get filteredDisplayModels() { + if (!this.modelFilter) return this.displayModels; + const filter = this.modelFilter.toLowerCase(); + return this.displayModels.filter((row) => { + const fields = [ + row.display_name, + row.secondary_name, + row.provider_type, + row.model && row.model.owned_by, + row.alias && row.alias.description, + row.alias && row.alias_state_text, + row.model && row.model.metadata && row.model.metadata.modes ? row.model.metadata.modes.join(',') : '', + row.model && row.model.metadata && row.model.metadata.categories ? row.model.metadata.categories.join(',') : '' + ]; + return fields.some((value) => String(value || '').toLowerCase().includes(filter)); + }); + }, + + defaultAliasForm() { + return { + name: '', + target_model: '', + description: '', + enabled: true + }; + }, + + async fetchAliases() { + this.aliasLoading = true; + this.aliasError = ''; + try { + const res = await fetch('/admin/api/v1/aliases', { headers: this.headers() }); + if (res.status === 503) { + this.aliasesAvailable = false; + this.aliases = []; + return; + } + this.aliasesAvailable = true; + if (!this.handleFetchResponse(res, 'aliases')) { + this.aliases = []; + return; + } + const payload = await res.json(); + this.aliases = Array.isArray(payload) ? payload : []; + } catch (e) { + console.error('Failed to fetch aliases:', e); + this.aliases = []; + this.aliasError = 'Unable to load aliases.'; + } finally { + this.aliasLoading = false; + } + }, + + qualifiedModelName(model) { + if (!model || !model.model || !model.model.id) { + return ''; + } + const modelID = String(model.model.id || '').trim(); + const providerType = String(model.provider_type || '').trim(); + if (!providerType || modelID.includes('/')) { + return modelID; + } + return providerType + '/' + modelID; + }, + + displayRowClass(row) { + if (!row) return ''; + const classes = []; + if (row.is_alias) { + classes.push('alias-row', this.aliasStateClass(row.alias)); + } + if (!row.is_alias && row.masking_alias) { + classes.push('masked-model-row'); + } + return classes.join(' '); + }, + + rowAnchorID(row) { + if (!row) return ''; + if (row.is_alias && row.alias && row.alias.name) { + return 'alias-row-' + String(row.alias.name).replace(/[^a-zA-Z0-9_-]+/g, '-'); + } + return ''; + }, + + filterByAlias(aliasName) { + this.modelFilter = String(aliasName || '').trim(); + }, + + openAliasCreate(model) { + this.aliasFormOpen = true; + this.aliasFormMode = 'create'; + this.aliasFormOriginalName = ''; + this.aliasFormError = ''; + this.aliasNotice = ''; + this.aliasForm = this.defaultAliasForm(); + if (model && model.model && model.model.id) { + this.aliasForm.target_model = this.qualifiedModelName(model); + } + }, + + openAliasEdit(alias) { + this.aliasFormOpen = true; + this.aliasFormMode = 'edit'; + this.aliasFormOriginalName = alias.name || ''; + this.aliasFormError = ''; + this.aliasNotice = ''; + this.aliasForm = { + name: alias.name || '', + target_model: alias.target_provider ? alias.target_provider + '/' + alias.target_model : (alias.target_model || ''), + description: alias.description || '', + enabled: alias.enabled !== false + }; + }, + + closeAliasForm() { + this.aliasFormOpen = false; + this.aliasFormMode = 'create'; + this.aliasFormOriginalName = ''; + this.aliasFormError = ''; + this.aliasForm = this.defaultAliasForm(); + }, + + async aliasResponseMessage(res, fallback) { + try { + const payload = await res.json(); + if (payload && payload.error && payload.error.message) { + return payload.error.message; + } + } catch (_) { + // Ignore invalid or empty responses and return the fallback message. + } + return fallback; + }, + + aliasTargetLabel(alias) { + if (!alias) return '\u2014'; + if (alias.resolved_model) return alias.resolved_model; + if (alias.target_provider) return alias.target_provider + '/' + alias.target_model; + return alias.target_model || '\u2014'; + }, + + aliasStateClass(alias) { + if (alias.enabled === false) return 'is-disabled'; + if (!alias.valid) return 'is-invalid'; + return 'is-valid'; + }, + + aliasStateText(alias) { + if (alias.enabled === false) return 'Disabled'; + if (!alias.valid) return 'Invalid'; + return 'Active'; + }, + + async toggleAliasEnabled(alias) { + if (!alias || !alias.name || this.aliasTogglingName === alias.name) { + return; + } + + this.aliasTogglingName = alias.name; + this.aliasError = ''; + this.aliasNotice = ''; + this.aliasFormError = ''; + + const payload = { + target_model: alias.target_provider ? alias.target_provider + '/' + alias.target_model : alias.target_model, + description: String(alias.description || '').trim(), + enabled: alias.enabled === false + }; + + try { + const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), { + method: 'PUT', + headers: this.headers(), + body: JSON.stringify(payload) + }); + if (res.status === 503) { + this.aliasesAvailable = false; + this.aliasError = 'Aliases feature is unavailable.'; + return; + } + if (res.status === 401) { + this.authError = true; + this.needsAuth = true; + this.aliasError = 'Authentication required.'; + return; + } + if (!res.ok) { + this.aliasError = await this.aliasResponseMessage(res, 'Failed to update alias state.'); + return; + } + + await this.fetchAliases(); + this.aliasNotice = payload.enabled ? 'Alias enabled.' : 'Alias disabled.'; + if (this.aliasFormOpen && this.aliasFormOriginalName === alias.name) { + this.closeAliasForm(); + } + } catch (e) { + console.error('Failed to toggle alias state:', e); + this.aliasError = 'Failed to update alias state.'; + } finally { + this.aliasTogglingName = ''; + } + }, + + modelKeys(model) { + return this.modelIdentifierKeys( + model && model.model ? model.model.id : '', + model ? model.provider_type : '' + ); + }, + + modelIdentifierKeys(modelID, providerType) { + const keys = new Set(); + const normalizedModelID = String(modelID || '').trim().toLowerCase(); + const provider = String(providerType || '').trim().toLowerCase(); + if (!normalizedModelID) { + return keys; + } + + keys.add(normalizedModelID); + if (provider && !normalizedModelID.includes('/')) { + keys.add(provider + '/' + normalizedModelID); + } + + const parts = normalizedModelID.split('/'); + if (parts.length === 2 && parts[1]) { + keys.add(parts[1]); + } + + return keys; + }, + + aliasKeys(alias) { + const keys = new Set(); + const resolved = String(alias.resolved_model || '').trim().toLowerCase(); + const targetModel = String(alias.target_model || '').trim().toLowerCase(); + const targetProvider = String(alias.target_provider || '').trim().toLowerCase(); + if (resolved) { + keys.add(resolved); + const resolvedParts = resolved.split('/'); + if (resolvedParts.length === 2 && resolvedParts[1]) { + keys.add(resolvedParts[1]); + } + } + if (targetModel) { + keys.add(targetModel); + const targetParts = targetModel.split('/'); + if (targetParts.length === 2 && targetParts[1]) { + keys.add(targetParts[1]); + } + } + if (targetModel && targetProvider) { + keys.add(targetProvider + '/' + targetModel); + } + return keys; + }, + + findConcreteModelForAlias(alias) { + for (const model of this.models) { + const modelKeys = this.modelKeys(model); + for (const key of this.aliasKeys(alias)) { + if (modelKeys.has(key)) { + return model; + } + } + } + return null; + }, + + normalizedAliasName(name) { + return String(name || '').trim().toLowerCase(); + }, + + sameAliasName(left, right) { + const normalizedLeft = this.normalizedAliasName(left); + const normalizedRight = this.normalizedAliasName(right); + return normalizedLeft !== '' && normalizedLeft === normalizedRight; + }, + + findExistingAliasByName(name) { + const normalizedName = this.normalizedAliasName(name); + if (!normalizedName) { + return null; + } + + for (const alias of this.aliases) { + if (this.sameAliasName(alias && alias.name, normalizedName)) { + return alias; + } + } + return null; + }, + + findConcreteModelByName(name) { + const normalizedName = this.normalizedAliasName(name); + if (!normalizedName) { + return null; + } + + for (const model of this.models) { + if (this.modelKeys(model).has(normalizedName)) { + return model; + } + } + return null; + }, + + async submitAliasForm() { + const name = String(this.aliasForm.name || '').trim(); + const targetModel = String(this.aliasForm.target_model || '').trim(); + let originalName = String(this.aliasFormOriginalName || '').trim(); + + if (!name) { + this.aliasFormError = 'Alias name is required.'; + return; + } + if (!targetModel) { + this.aliasFormError = 'Target model is required.'; + return; + } + + this.aliasFormError = ''; + this.aliasError = ''; + this.aliasNotice = ''; + + const existingAlias = this.findExistingAliasByName(name); + if (existingAlias && !this.sameAliasName(existingAlias.name, originalName)) { + const overwriteMessage = originalName + ? 'An alias named "' + existingAlias.name + '" already exists. Saving will overwrite it and remove "' + originalName + '". Continue?' + : 'An alias named "' + existingAlias.name + '" already exists. Saving will update that alias. Continue?'; + if (!window.confirm(overwriteMessage)) { + this.aliasFormError = originalName + ? 'Choose a different alias name to avoid overwriting an existing alias.' + : 'Choose a different alias name or use Edit on the existing alias.'; + return; + } + if (!originalName) { + this.aliasFormMode = 'edit'; + this.aliasFormOriginalName = existingAlias.name || name; + originalName = this.aliasFormOriginalName; + } + } + + const matchingModel = this.findConcreteModelByName(name); + if (!existingAlias && matchingModel && !this.sameAliasName(name, originalName)) { + const modelName = this.qualifiedModelName(matchingModel) || String(matchingModel.model && matchingModel.model.id || '').trim(); + if (!window.confirm('A model named "' + modelName + '" already exists. Creating this alias will mask that model in the list. Continue?')) { + this.aliasFormError = 'Choose a different alias name to avoid masking an existing model.'; + return; + } + } + + this.aliasSubmitting = true; + + const payload = { + target_model: targetModel, + description: String(this.aliasForm.description || '').trim(), + enabled: Boolean(this.aliasForm.enabled) + }; + + try { + const saveRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(name), { + method: 'PUT', + headers: this.headers(), + body: JSON.stringify(payload) + }); + + if (saveRes.status === 503) { + this.aliasesAvailable = false; + this.aliasFormError = 'Aliases feature is unavailable.'; + return; + } + if (saveRes.status === 401) { + this.authError = true; + this.needsAuth = true; + this.aliasFormError = 'Authentication required.'; + return; + } + if (!saveRes.ok) { + this.aliasFormError = await this.aliasResponseMessage(saveRes, 'Failed to save alias.'); + return; + } + + if (originalName && originalName !== name) { + const deleteRes = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(originalName), { + method: 'DELETE', + headers: this.headers() + }); + if (deleteRes.status !== 404 && !deleteRes.ok) { + this.aliasFormError = await this.aliasResponseMessage(deleteRes, 'The alias was saved with the new name, but the previous alias could not be removed.'); + await this.fetchAliases(); + return; + } + } + + await this.fetchAliases(); + this.closeAliasForm(); + this.aliasNotice = originalName && originalName !== name ? 'Alias renamed.' : 'Alias saved.'; + } catch (e) { + console.error('Failed to save alias:', e); + this.aliasFormError = 'Failed to save alias.'; + } finally { + this.aliasSubmitting = false; + } + }, + + async deleteAlias(alias) { + if (!alias || !alias.name) return; + if (!window.confirm('Delete alias "' + alias.name + '"? This cannot be undone.')) { + return; + } + + this.aliasDeletingName = alias.name; + this.aliasError = ''; + this.aliasNotice = ''; + this.aliasFormError = ''; + + try { + const res = await fetch('/admin/api/v1/aliases/' + encodeURIComponent(alias.name), { + method: 'DELETE', + headers: this.headers() + }); + if (res.status === 503) { + this.aliasesAvailable = false; + this.aliasError = 'Aliases feature is unavailable.'; + return; + } + if (res.status === 401) { + this.authError = true; + this.needsAuth = true; + this.aliasError = 'Authentication required.'; + return; + } + if (!res.ok) { + this.aliasError = await this.aliasResponseMessage(res, 'Failed to remove alias.'); + return; + } + + await this.fetchAliases(); + if (this.aliasFormOriginalName === alias.name) { + this.closeAliasForm(); + } + this.aliasNotice = 'Alias removed.'; + } catch (e) { + console.error('Failed to delete alias:', e); + this.aliasError = 'Failed to remove alias.'; + } finally { + this.aliasDeletingName = ''; + } + } + }; + } + + global.dashboardAliasesModule = dashboardAliasesModule; +})(window); diff --git a/internal/admin/dashboard/templates/index.html b/internal/admin/dashboard/templates/index.html index f2eed8e5..d93f8f55 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -210,8 +210,8 @@

Request Log