diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 042e354f..a688892c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: # Verify the symlink was created pkg-config --exists webkit2gtk-4.0 && echo "✓ webkit2gtk-4.0 found" || echo "✗ webkit2gtk-4.0 NOT found" pkg-config --modversion webkit2gtk-4.0 || true - go install github.com/wailsapp/wails/v2/cmd/wails@latest + go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - name: Install Linux dependencies (cached) if: matrix.platform == 'linux' && steps.cache-wails.outputs.cache-hit == 'true' @@ -107,12 +107,12 @@ jobs: - name: Install Wails (macOS) if: matrix.platform == 'darwin' && steps.cache-wails.outputs.cache-hit != 'true' run: | - go install github.com/wailsapp/wails/v2/cmd/wails@latest + go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - name: Install Wails (Windows) if: matrix.platform == 'windows' && steps.cache-wails.outputs.cache-hit != 'true' run: | - go install github.com/wailsapp/wails/v2/cmd/wails@latest + go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 shell: pwsh - name: Build application (Linux/macOS) diff --git a/cmd/desktop/app.go b/cmd/desktop/app.go index 6fd2e9d8..0edfb0c5 100644 --- a/cmd/desktop/app.go +++ b/cmd/desktop/app.go @@ -414,7 +414,10 @@ func (a *App) SwitchToEndpoint(endpointName string) error { } func (a *App) TestEndpoint(index int) string { return a.endpoint.TestEndpoint(index) } func (a *App) TestEndpointLight(index int) string { return a.endpoint.TestEndpointLight(index) } -func (a *App) TestAllEndpointsZeroCost() string { return a.endpoint.TestAllEndpointsZeroCost() } +func (a *App) TestEndpointLightWithModel(index int, model string) string { + return a.endpoint.TestEndpointLightWithModel(index, model) +} +func (a *App) TestAllEndpointsZeroCost() string { return a.endpoint.TestAllEndpointsZeroCost() } func (a *App) FetchModels(apiUrl, apiKey, transformer string) string { return a.endpoint.FetchModels(apiUrl, apiKey, transformer) } diff --git a/cmd/desktop/frontend/src/i18n/en.js b/cmd/desktop/frontend/src/i18n/en.js index 0cd6a6ec..cf0e7600 100644 --- a/cmd/desktop/frontend/src/i18n/en.js +++ b/cmd/desktop/frontend/src/i18n/en.js @@ -93,6 +93,9 @@ export default { transformerHelp: 'Select the API format for this endpoint', model: 'Model', modelPlaceholder: 'e.g., claude-sonnet-4-5-20250929', + testModel: 'Select Test Model', + testModelHelp: 'This model is used only for this test and will not be saved or affect normal requests.', + testModelRequired: 'Enter a model to test', modelHelp: 'Optional: Override the model specified in requests', modelHelpClaude: 'Optional: Override the model specified in requests', modelHelpOpenAI: 'Optional: Override the OpenAI model specified in requests', @@ -118,6 +121,7 @@ export default { confirmDelete: 'Are you sure you want to delete endpoint "{name}"?', deleteFailed: 'Failed to delete: {error}', fetchModels: 'Fetch Models', + selectModel: 'Select Model', fetchModelsBtn: 'Fetch', fetchModelsNoUrl: 'Please enter API URL first', fetchModelsNoKey: 'Please enter API Key first', diff --git a/cmd/desktop/frontend/src/i18n/zh-CN.js b/cmd/desktop/frontend/src/i18n/zh-CN.js index 615e4280..01e4e42b 100644 --- a/cmd/desktop/frontend/src/i18n/zh-CN.js +++ b/cmd/desktop/frontend/src/i18n/zh-CN.js @@ -93,6 +93,9 @@ export default { transformerHelp: '选择此端点的 API 格式', model: '模型', modelPlaceholder: '例如:claude-sonnet-4-5-20250929', + testModel: '选择测试模型', + testModelHelp: '该模型仅用于本次测试,不会保存或影响正式请求。', + testModelRequired: '请输入测试模型', modelHelp: '可选:覆盖请求中指定的模型', modelHelpClaude: '可选:覆盖请求中指定的模型', modelHelpOpenAI: '可选:覆盖请求中指定的 OpenAI 模型', @@ -118,6 +121,7 @@ export default { confirmDelete: '确认删除端点 "{name}" 吗?', deleteFailed: '删除失败:{error}', fetchModels: '获取模型列表', + selectModel: '选择模型', fetchModelsBtn: '检测', fetchModelsNoUrl: '请先填写 API 地址', fetchModelsNoKey: '请先填写 API 密钥', diff --git a/cmd/desktop/frontend/src/modules/config.js b/cmd/desktop/frontend/src/modules/config.js index e1e13132..e1b66e73 100644 --- a/cmd/desktop/frontend/src/modules/config.js +++ b/cmd/desktop/frontend/src/modules/config.js @@ -61,8 +61,8 @@ export async function testEndpoint(index) { return JSON.parse(resultStr); } -export async function testEndpointLight(index) { - const resultStr = await window.go.main.App.TestEndpointLight(index); +export async function testEndpointLight(index, model = '') { + const resultStr = await window.go.main.App.TestEndpointLightWithModel(index, model); return JSON.parse(resultStr); } diff --git a/cmd/desktop/frontend/src/modules/endpoints.js b/cmd/desktop/frontend/src/modules/endpoints.js index 9db97a76..dd5b3516 100644 --- a/cmd/desktop/frontend/src/modules/endpoints.js +++ b/cmd/desktop/frontend/src/modules/endpoints.js @@ -234,6 +234,7 @@ export async function renderEndpoints(endpoints) { item.className = 'endpoint-item'; item.dataset.name = ep.name; item.dataset.index = index; + item.dataset.model = model; // 筛选激活时禁用拖拽 if (isFiltered) { @@ -1615,6 +1616,7 @@ function renderCompactView(sortedEndpoints, container, currentEndpointName, isFi item.className = 'endpoint-item-compact'; item.dataset.name = ep.name; item.dataset.index = index; + item.dataset.model = model; // 筛选激活时禁用拖拽 if (isFiltered) { diff --git a/cmd/desktop/frontend/src/modules/modal.js b/cmd/desktop/frontend/src/modules/modal.js index d94e6f2d..d77799b1 100644 --- a/cmd/desktop/frontend/src/modules/modal.js +++ b/cmd/desktop/frontend/src/modules/modal.js @@ -630,13 +630,139 @@ function isTestNotSupported(statusCode, message) { return false; } +function requestTestModel(endpoint = {}) { + const configuredModel = endpoint.model?.trim() || ''; + const overlay = document.createElement('div'); + overlay.className = 'modal active'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + return new Promise(resolve => { + let settled = false; + const form = overlay.querySelector('form'); + const input = overlay.querySelector('#temporaryTestModel'); + const fetchButton = overlay.querySelector('.test-model-fetch'); + const modelSelect = overlay.querySelector('.test-model-select'); + input.value = configuredModel; + const finish = value => { + if (settled) { + return; + } + settled = true; + overlay.remove(); + resolve(value); + }; + + fetchButton.addEventListener('click', async () => { + fetchButton.disabled = true; + fetchButton.textContent = '⏳'; + try { + const resultStr = await window.go.main.App.FetchModels(endpoint.apiUrl, endpoint.apiKey, endpoint.transformer); + if (!overlay.isConnected) { + return; + } + const result = JSON.parse(resultStr); + const models = result.success && Array.isArray(result.models) ? result.models : []; + if (models.length === 0) { + showNotification(t('modal.fetchModelsEmpty'), 'error'); + return; + } + modelSelect.replaceChildren(); + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = t('modal.selectModel'); + modelSelect.appendChild(placeholder); + models.forEach(model => { + const option = document.createElement('option'); + option.value = String(model); + option.textContent = String(model); + modelSelect.appendChild(option); + }); + modelSelect.value = models.includes(input.value) ? input.value : ''; + modelSelect.hidden = false; + showNotification(t('modal.fetchModelsSuccess').replace('{count}', models.length), 'success'); + } catch (error) { + if (overlay.isConnected) { + showNotification(`${t('modal.fetchModelsFailed')}: ${error}`, 'error'); + } + } finally { + if (fetchButton.isConnected) { + fetchButton.disabled = false; + fetchButton.textContent = t('modal.fetchModels'); + } + } + }); + modelSelect.addEventListener('change', () => { + if (modelSelect.value) { + input.value = modelSelect.value; + input.setCustomValidity(''); + } + }); + form.addEventListener('submit', event => { + event.preventDefault(); + const value = input.value.trim(); + if (!value) { + input.setCustomValidity(t('modal.testModelRequired')); + input.reportValidity(); + return; + } + finish(value); + }); + input.addEventListener('input', () => input.setCustomValidity('')); + overlay.querySelector('.test-model-cancel').addEventListener('click', () => finish(null)); + overlay.addEventListener('mousedown', event => { + if (event.target === overlay) { + finish(null); + } + }); + overlay.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + finish(null); + } + }); + requestAnimationFrame(() => input.focus()); + }); +} + // Test Result Modal export async function testEndpointHandler(index, buttonElement) { - setTestState(buttonElement, index); - // 获取端点名称用于保存测试状态(兼容详情视图和简洁视图) const endpointItem = buttonElement.closest('.endpoint-item') || buttonElement.closest('.endpoint-item-compact'); const endpointName = endpointItem ? endpointItem.dataset.name : null; + const endpoint = { + ...(window.config?.endpoints?.[index] || {}), + model: endpointItem?.dataset.model || '' + }; + const testModel = await requestTestModel(endpoint); + if (testModel == null) { + return; + } + + setTestState(buttonElement, index); // 简洁视图:同时更新 moreBtn const moreBtn = endpointItem ? endpointItem.querySelector('[data-action="more"]') : null; @@ -650,7 +776,7 @@ export async function testEndpointHandler(index, buttonElement) { buttonElement.innerHTML = '⏳'; // 使用轻量级测试(优先零消耗方法) - const result = await testEndpointLight(index); + const result = await testEndpointLight(index, testModel); const resultContent = document.getElementById('testResultContent'); const resultTitle = document.getElementById('testResultTitle'); @@ -744,4 +870,4 @@ export function openArticle() { if (window.go?.main?.App) { window.go.main.App.OpenURL('https://mp.weixin.qq.com/s/ohtkyIMd5YC7So1q-gE0og'); } -} \ No newline at end of file +} diff --git a/cmd/desktop/frontend/wailsjs/go/main/App.d.ts b/cmd/desktop/frontend/wailsjs/go/main/App.d.ts index 5c9f5d80..1f43f4ae 100755 --- a/cmd/desktop/frontend/wailsjs/go/main/App.d.ts +++ b/cmd/desktop/frontend/wailsjs/go/main/App.d.ts @@ -199,6 +199,8 @@ export function TestEndpoint(arg1:number):Promise; export function TestEndpointLight(arg1:number):Promise; +export function TestEndpointLightWithModel(arg1:number,arg2:string):Promise; + export function TestS3Connection(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string,arg6:string,arg7:string,arg8:boolean,arg9:boolean):Promise; export function TestWebDAVConnection(arg1:string,arg2:string,arg3:string):Promise; diff --git a/cmd/desktop/frontend/wailsjs/go/main/App.js b/cmd/desktop/frontend/wailsjs/go/main/App.js index 725a4a96..59f50670 100755 --- a/cmd/desktop/frontend/wailsjs/go/main/App.js +++ b/cmd/desktop/frontend/wailsjs/go/main/App.js @@ -398,6 +398,10 @@ export function TestEndpointLight(arg1) { return window['go']['main']['App']['TestEndpointLight'](arg1); } +export function TestEndpointLightWithModel(arg1, arg2) { + return window['go']['main']['App']['TestEndpointLightWithModel'](arg1, arg2); +} + export function TestS3Connection(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) { return window['go']['main']['App']['TestS3Connection'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9); } diff --git a/cmd/desktop/frontend_contract_test.go b/cmd/desktop/frontend_contract_test.go new file mode 100644 index 00000000..33f6798b --- /dev/null +++ b/cmd/desktop/frontend_contract_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func readDesktopFrontend(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func TestDesktopEndpointTestUsesTemporaryModelPrompt(t *testing.T) { + endpoints := readDesktopFrontend(t, "frontend/src/modules/endpoints.js") + if strings.Count(endpoints, "item.dataset.model = model;") < 2 { + t.Error("detail and compact endpoint cards must expose their configured model to the test action") + } + + modal := readDesktopFrontend(t, "frontend/src/modules/modal.js") + for _, value := range []string{ + "requestTestModel", + "await testEndpointLight(index, testModel)", + "input.value = configuredModel;", + "test-model-fetch", + "FetchModels(endpoint.apiUrl, endpoint.apiKey, endpoint.transformer)", + } { + if !strings.Contains(modal, value) { + t.Errorf("desktop test flow must contain %q", value) + } + } + if strings.Contains(modal, "return Promise.resolve(model)") { + t.Error("desktop test flow must always open the model prompt") + } + + configJS := readDesktopFrontend(t, "frontend/src/modules/config.js") + if !strings.Contains(configJS, "TestEndpointLightWithModel(index, model)") { + t.Error("desktop frontend must pass the temporary model through the Wails binding") + } +} diff --git a/cmd/server/webui/api/config.go b/cmd/server/webui/api/config.go index 681116b8..13470f74 100644 --- a/cmd/server/webui/api/config.go +++ b/cmd/server/webui/api/config.go @@ -4,12 +4,16 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "net/http" + "strconv" + "strings" "github.com/lich0821/ccNexus/internal/logger" - "github.com/lich0821/ccNexus/internal/storage" ) +var errBasicAuthCredentialsRequired = errors.New("username and password are required when Basic Auth is enabled") + type BasicAuthConfigRequest struct { Enabled bool `json:"enabled"` Username string `json:"username"` @@ -31,10 +35,11 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleBasicAuthConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + enabled, username, password := h.config.GetBasicAuth() WriteSuccess(w, map[string]interface{}{ - "enabled": h.config.BasicAuthEnabled, - "username": h.config.BasicAuthUsername, - "password": "***", + "enabled": enabled, + "username": username, + "hasPassword": password != "", }) case http.MethodPut: var req BasicAuthConfigRequest @@ -43,25 +48,20 @@ func (h *Handler) handleBasicAuthConfig(w http.ResponseWriter, r *http.Request) return } - h.config.BasicAuthEnabled = req.Enabled - if req.Username != "" { - h.config.BasicAuthUsername = req.Username - } - if req.Password != "" && req.Password != "***" { - h.config.BasicAuthPassword = req.Password - } - - adapter := storage.NewConfigStorageAdapter(h.storage) - if err := h.config.SaveToStorage(adapter); err != nil { + if err := h.persistBasicAuth(&req.Enabled, req.Username, req.Password); err != nil { + if errors.Is(err, errBasicAuthCredentialsRequired) { + WriteError(w, http.StatusBadRequest, err.Error()) + return + } logger.Error("Failed to save config: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to save configuration") return } WriteSuccess(w, map[string]interface{}{ - "message": "Basic Auth configuration updated", - "enabled": h.config.BasicAuthEnabled, - "username": h.config.BasicAuthUsername, + "message": "Basic Auth configuration updated", + "enabled": h.config.GetBasicAuthEnabled(), + "username": h.config.GetBasicAuthUsername(), }) default: WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") @@ -81,10 +81,7 @@ func (h *Handler) handleResetBasicAuthPassword(w http.ResponseWriter, r *http.Re } newPassword := hex.EncodeToString(bytes)[:16] - h.config.BasicAuthPassword = newPassword - - adapter := storage.NewConfigStorageAdapter(h.storage) - if err := h.config.SaveToStorage(adapter); err != nil { + if err := h.persistBasicAuth(nil, "", newPassword); err != nil { logger.Error("Failed to save config: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to save configuration") return @@ -109,8 +106,8 @@ func (h *Handler) getConfig(w http.ResponseWriter, r *http.Request) { // updateConfig updates the full configuration func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { var req struct { - Port int `json:"port"` - LogLevel int `json:"logLevel"` + Port *int `json:"port"` + LogLevel *int `json:"logLevel"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -118,23 +115,30 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { return } - // Update port if provided - if req.Port > 0 { - h.config.UpdatePort(req.Port) + if req.Port != nil { + if h.config.IsPortLocked() { + WriteError(w, http.StatusForbidden, "Port is locked by CLI flag and cannot be changed") + return + } + if *req.Port < 1 || *req.Port > 65535 { + WriteError(w, http.StatusBadRequest, "Invalid port number") + return + } } - - // Update log level if provided - if req.LogLevel >= 0 { - h.config.UpdateLogLevel(req.LogLevel) + if req.LogLevel != nil && (*req.LogLevel < 0 || *req.LogLevel > 3) { + WriteError(w, http.StatusBadRequest, "Invalid log level (must be 0-3)") + return } - // Save to storage - adapter := storage.NewConfigStorageAdapter(h.storage) - if err := h.config.SaveToStorage(adapter); err != nil { + if err := h.persistRuntimeConfig(req.Port, req.LogLevel); err != nil { logger.Error("Failed to save config: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to save configuration") return } + if req.LogLevel != nil { + logger.GetLogger().SetMinLevel(logger.LogLevel(*req.LogLevel)) + logger.GetLogger().SetConsoleLevel(logger.LogLevel(*req.LogLevel)) + } WriteSuccess(w, map[string]interface{}{ "message": "Configuration updated successfully", @@ -169,11 +173,7 @@ func (h *Handler) handleConfigPort(w http.ResponseWriter, r *http.Request) { return } - h.config.UpdatePort(req.Port) - - // Save to storage - adapter := storage.NewConfigStorageAdapter(h.storage) - if err := h.config.SaveToStorage(adapter); err != nil { + if err := h.persistRuntimeConfig(&req.Port, nil); err != nil { logger.Error("Failed to save config: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to save configuration") return @@ -210,20 +210,15 @@ func (h *Handler) handleConfigLogLevel(w http.ResponseWriter, r *http.Request) { return } - h.config.UpdateLogLevel(req.LogLevel) - - // Update logger level - logger.GetLogger().SetMinLevel(logger.LogLevel(req.LogLevel)) - logger.GetLogger().SetConsoleLevel(logger.LogLevel(req.LogLevel)) - - // Save to storage - adapter := storage.NewConfigStorageAdapter(h.storage) - if err := h.config.SaveToStorage(adapter); err != nil { + if err := h.persistRuntimeConfig(nil, &req.LogLevel); err != nil { logger.Error("Failed to save config: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to save configuration") return } + logger.GetLogger().SetMinLevel(logger.LogLevel(req.LogLevel)) + logger.GetLogger().SetConsoleLevel(logger.LogLevel(req.LogLevel)) + WriteSuccess(w, map[string]interface{}{ "logLevel": req.LogLevel, "message": "Log level updated successfully", @@ -231,4 +226,66 @@ func (h *Handler) handleConfigLogLevel(w http.ResponseWriter, r *http.Request) { default: WriteError(w, http.StatusMethodNotAllowed, "Method not allowed") } -} \ No newline at end of file +} + +func (h *Handler) persistBasicAuth(enabled *bool, username, password string) error { + h.configMu.Lock() + defer h.configMu.Unlock() + + oldEnabled := h.config.GetBasicAuthEnabled() + oldUsername := h.config.GetBasicAuthUsername() + oldPassword := h.config.GetBasicAuthPassword() + newEnabled := oldEnabled + if enabled != nil { + newEnabled = *enabled + } + if username == "" { + username = oldUsername + } + if password == "" || password == "***" { + password = oldPassword + } + username = strings.TrimSpace(username) + if newEnabled && (username == "" || password == "") { + return errBasicAuthCredentialsRequired + } + + h.config.UpdateBasicAuth(newEnabled, username, password) + if err := h.storage.SetConfigs(map[string]string{ + "basicAuthEnabled": strconv.FormatBool(newEnabled), + "basicAuthUsername": username, + "basicAuthPassword": password, + }); err != nil { + h.config.UpdateBasicAuth(oldEnabled, oldUsername, oldPassword) + return err + } + return nil +} + +func (h *Handler) persistRuntimeConfig(port, logLevel *int) error { + h.configMu.Lock() + defer h.configMu.Unlock() + + oldPort := h.config.GetPort() + oldLogLevel := h.config.GetLogLevel() + if port != nil { + h.config.UpdatePort(*port) + } + if logLevel != nil { + h.config.UpdateLogLevel(*logLevel) + } + + values := make(map[string]string, 2) + if port != nil { + values["port"] = strconv.Itoa(*port) + } + if logLevel != nil { + values["logLevel"] = strconv.Itoa(*logLevel) + } + if err := h.storage.SetConfigs(values); err != nil { + h.config.UpdatePort(oldPort) + h.config.UpdateLogLevel(oldLogLevel) + return err + } + return nil +} diff --git a/cmd/server/webui/api/config_contract_test.go b/cmd/server/webui/api/config_contract_test.go new file mode 100644 index 00000000..f6ea7e7b --- /dev/null +++ b/cmd/server/webui/api/config_contract_test.go @@ -0,0 +1,241 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/storage" +) + +func newConfigContractHandler(t *testing.T) (*Handler, *config.Config, *storage.SQLiteStorage) { + t.Helper() + + db, err := storage.NewSQLiteStorage(filepath.Join(t.TempDir(), "webui.db")) + if err != nil { + t.Fatalf("create storage: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + cfg := config.DefaultConfig() + cfg.UpdateBasicAuth(false, "admin", "old-password") + return NewHandler(cfg, nil, db), cfg, db +} + +func performConfigRequest(h *Handler, method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestUpdateConfigTreatsFieldsAsOptional(t *testing.T) { + h, cfg, _ := newConfigContractHandler(t) + cfg.UpdateLogLevel(2) + + rec := performConfigRequest(h, http.MethodPut, "/api/config", `{"port":4321}`) + if rec.Code != http.StatusOK { + t.Fatalf("update port status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := cfg.GetPort(); got != 4321 { + t.Fatalf("port = %d, want 4321", got) + } + if got := cfg.GetLogLevel(); got != 2 { + t.Fatalf("omitted logLevel changed to %d", got) + } + + rec = performConfigRequest(h, http.MethodPut, "/api/config", `{"logLevel":3}`) + if rec.Code != http.StatusOK { + t.Fatalf("update log level status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := cfg.GetPort(); got != 4321 { + t.Fatalf("omitted port changed to %d", got) + } + if got := cfg.GetLogLevel(); got != 3 { + t.Fatalf("logLevel = %d, want 3", got) + } +} + +func TestBasicAuthConfigReturnsPasswordPresenceWithoutPlaceholder(t *testing.T) { + h, _, _ := newConfigContractHandler(t) + rec := performConfigRequest(h, http.MethodGet, "/api/config/basic-auth", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"password"`) || !strings.Contains(rec.Body.String(), `"hasPassword":true`) { + t.Fatalf("response must expose password presence, not a placeholder: %s", rec.Body.String()) + } +} + +func TestUpdateConfigValidatesAllFieldsBeforeMutation(t *testing.T) { + tests := []struct { + name string + body string + lockPort bool + wantStatus int + }{ + {name: "port too low", body: `{"port":0,"logLevel":3}`, wantStatus: http.StatusBadRequest}, + {name: "port too high", body: `{"port":65536,"logLevel":3}`, wantStatus: http.StatusBadRequest}, + {name: "log level too low", body: `{"port":4321,"logLevel":-1}`, wantStatus: http.StatusBadRequest}, + {name: "log level too high", body: `{"port":4321,"logLevel":4}`, wantStatus: http.StatusBadRequest}, + {name: "locked port", body: `{"port":4321,"logLevel":3}`, lockPort: true, wantStatus: http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, cfg, _ := newConfigContractHandler(t) + cfg.UpdateLogLevel(2) + if tt.lockPort { + cfg.LockPort() + } + + rec := performConfigRequest(h, http.MethodPut, "/api/config", tt.body) + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d, body = %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + if got := cfg.GetPort(); got != 3000 { + t.Fatalf("port mutated to %d", got) + } + if got := cfg.GetLogLevel(); got != 2 { + t.Fatalf("logLevel mutated to %d", got) + } + }) + } +} + +func TestBasicAuthUpdateRollsBackWhenPersistenceFails(t *testing.T) { + h, cfg, db := newConfigContractHandler(t) + if err := db.Close(); err != nil { + t.Fatalf("close storage: %v", err) + } + + rec := performConfigRequest(h, http.MethodPut, "/api/config/basic-auth", `{"enabled":true,"username":"new-user","password":"new-password"}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500, body = %s", rec.Code, rec.Body.String()) + } + if cfg.GetBasicAuthEnabled() { + t.Fatal("failed update left Basic Auth enabled in memory") + } + if got := cfg.GetBasicAuthUsername(); got != "admin" { + t.Fatalf("username = %q, want rollback to admin", got) + } + if got := cfg.GetBasicAuthPassword(); got != "old-password" { + t.Fatalf("password = %q, want rollback", got) + } +} + +func TestConfigUpdateRollsBackWhenPersistenceFails(t *testing.T) { + h, cfg, db := newConfigContractHandler(t) + cfg.UpdateLogLevel(2) + if err := db.Close(); err != nil { + t.Fatalf("close storage: %v", err) + } + + rec := performConfigRequest(h, http.MethodPut, "/api/config", `{"port":4321,"logLevel":3}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500, body = %s", rec.Code, rec.Body.String()) + } + if got := cfg.GetPort(); got != 3000 { + t.Fatalf("port = %d, want rollback to 3000", got) + } + if got := cfg.GetLogLevel(); got != 2 { + t.Fatalf("logLevel = %d, want rollback to 2", got) + } +} + +func TestDedicatedConfigUpdatesRollBackWhenPersistenceFails(t *testing.T) { + t.Run("port", func(t *testing.T) { + h, cfg, db := newConfigContractHandler(t) + if err := db.Close(); err != nil { + t.Fatalf("close storage: %v", err) + } + + rec := performConfigRequest(h, http.MethodPut, "/api/config/port", `{"port":4321}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500, body = %s", rec.Code, rec.Body.String()) + } + if got := cfg.GetPort(); got != 3000 { + t.Fatalf("port = %d, want rollback to 3000", got) + } + }) + + t.Run("log level", func(t *testing.T) { + h, cfg, db := newConfigContractHandler(t) + cfg.UpdateLogLevel(2) + if err := db.Close(); err != nil { + t.Fatalf("close storage: %v", err) + } + + rec := performConfigRequest(h, http.MethodPut, "/api/config/log-level", `{"logLevel":3}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500, body = %s", rec.Code, rec.Body.String()) + } + if got := cfg.GetLogLevel(); got != 2 { + t.Fatalf("logLevel = %d, want rollback to 2", got) + } + }) +} + +func TestBasicAuthResetRollsBackWhenPersistenceFails(t *testing.T) { + h, cfg, db := newConfigContractHandler(t) + if err := db.Close(); err != nil { + t.Fatalf("close storage: %v", err) + } + + rec := performConfigRequest(h, http.MethodPost, "/api/config/basic-auth/reset-password", "") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500, body = %s", rec.Code, rec.Body.String()) + } + if got := cfg.GetBasicAuthPassword(); got != "old-password" { + t.Fatalf("password = %q, want rollback", got) + } +} + +func TestConfigUpdatesDoNotOverwriteStoredEndpoints(t *testing.T) { + tests := []struct { + name string + path string + body string + }{ + {name: "runtime config", path: "/api/config", body: `{"logLevel":2}`}, + {name: "basic auth", path: "/api/config/basic-auth", body: `{"enabled":false,"username":"admin","password":""}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, _, db := newConfigContractHandler(t) + endpoint := storage.Endpoint{ + Name: "stored-endpoint", APIUrl: "https://example.com", APIKey: "secret", + AuthMode: config.AuthModeAPIKey, Enabled: true, Transformer: "openai", + } + if err := db.SaveEndpoint(&endpoint); err != nil { + t.Fatalf("save endpoint: %v", err) + } + + rec := performConfigRequest(h, http.MethodPut, tt.path, tt.body) + if rec.Code != http.StatusOK { + t.Fatalf("config update status=%d body=%s", rec.Code, rec.Body.String()) + } + endpoints, err := db.GetEndpoints() + if err != nil || len(endpoints) != 1 || endpoints[0].Name != "stored-endpoint" { + t.Fatalf("config update overwrote endpoints: endpoints=%v err=%v", endpoints, err) + } + }) + } +} + +func TestBasicAuthCannotBeEnabledWithoutCredentials(t *testing.T) { + h, cfg, _ := newConfigContractHandler(t) + cfg.UpdateBasicAuth(false, "", "") + + rec := performConfigRequest(h, http.MethodPut, "/api/config/basic-auth", `{"enabled":true,"username":"","password":""}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if cfg.GetBasicAuthEnabled() { + t.Fatal("invalid update enabled Basic Auth") + } +} diff --git a/cmd/server/webui/api/credentials.go b/cmd/server/webui/api/credentials.go index 540434e5..ddea6db1 100644 --- a/cmd/server/webui/api/credentials.go +++ b/cmd/server/webui/api/credentials.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -32,6 +33,85 @@ type importCredentialsRequest struct { Remark string `json:"remark"` } +type credentialResponse struct { + ID int64 `json:"id"` + EndpointName string `json:"endpointName"` + ProviderType string `json:"providerType"` + AccountID string `json:"accountId,omitempty"` + Email string `json:"email,omitempty"` + HasAccessToken bool `json:"hasAccessToken"` + HasRefreshToken bool `json:"hasRefreshToken"` + HasIDToken bool `json:"hasIdToken"` + LastRefresh *time.Time `json:"lastRefresh,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + FailureCount int `json:"failureCount"` + CooldownUntil *time.Time `json:"cooldownUntil,omitempty"` + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + LastError string `json:"lastError,omitempty"` + Remark string `json:"remark,omitempty"` + RateLimits *storage.CredentialRateLimits `json:"rateLimits,omitempty"` + Usage *storage.CredentialUsage `json:"usage,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type updateCredentialRequest struct { + AccessToken *string `json:"accessToken"` + RefreshToken *string `json:"refreshToken"` + IDToken *string `json:"idToken"` + ClearAccessToken bool `json:"clearAccessToken"` + ClearRefreshToken bool `json:"clearRefreshToken"` + ClearIDToken bool `json:"clearIdToken"` + Remark *string `json:"remark"` + Enabled *bool `json:"enabled"` + ExpiresAt *string `json:"expiresAt"` + LastRefresh *string `json:"lastRefresh"` + Status *string `json:"status"` +} + +func newCredentialResponse(credential storage.EndpointCredential) credentialResponse { + rateLimits := credential.RateLimits + if rateLimits != nil { + copy := *rateLimits + copy.Error = redactCredentialSecrets(copy.Error, credential) + rateLimits = © + } + return credentialResponse{ + ID: credential.ID, + EndpointName: credential.EndpointName, + ProviderType: credential.ProviderType, + AccountID: credential.AccountID, + Email: credential.Email, + HasAccessToken: strings.TrimSpace(credential.AccessToken) != "", + HasRefreshToken: strings.TrimSpace(credential.RefreshToken) != "", + HasIDToken: strings.TrimSpace(credential.IDToken) != "", + LastRefresh: credential.LastRefresh, + ExpiresAt: credential.ExpiresAt, + Status: credential.Status, + Enabled: credential.Enabled, + FailureCount: credential.FailureCount, + CooldownUntil: credential.CooldownUntil, + LastCheckedAt: credential.LastCheckedAt, + LastUsedAt: credential.LastUsedAt, + LastError: redactCredentialSecrets(credential.LastError, credential), + Remark: credential.Remark, + RateLimits: rateLimits, + Usage: credential.Usage, + CreatedAt: credential.CreatedAt, + UpdatedAt: credential.UpdatedAt, + } +} + +func redactCredentialSecrets(message string, credential storage.EndpointCredential) string { + for _, secret := range []string{credential.AccessToken, credential.RefreshToken, credential.IDToken} { + message = redactAPIKey(message, secret) + } + return message +} + func (h *Handler) handleEndpointCredentials(w http.ResponseWriter, r *http.Request, endpointName string, parts []string) { endpoint, err := h.getEndpointByName(endpointName) if err != nil { @@ -45,6 +125,10 @@ func (h *Handler) handleEndpointCredentials(w http.ResponseWriter, r *http.Reque } if len(parts) == 0 || parts[0] == "" { + if len(parts) > 1 { + http.NotFound(w, r) + return + } switch r.Method { case http.MethodGet: h.listEndpointCredentials(w, r, endpointName) @@ -55,6 +139,10 @@ func (h *Handler) handleEndpointCredentials(w http.ResponseWriter, r *http.Reque } return } + if len(parts) != 1 { + http.NotFound(w, r) + return + } switch parts[0] { case "import": @@ -107,19 +195,18 @@ func (h *Handler) listEndpointCredentials(w http.ResponseWriter, r *http.Request logger.Warn("Failed to get credential stats: %v", err) } + responseCredentials := make([]credentialResponse, len(credentials)) for i := range credentials { - credentials[i].AccessToken = maskToken(credentials[i].AccessToken) - credentials[i].RefreshToken = maskToken(credentials[i].RefreshToken) - credentials[i].IDToken = maskToken(credentials[i].IDToken) if rateLimits != nil { if entry, ok := rateLimits[credentials[i].ID]; ok { credentials[i].RateLimits = entry } } + responseCredentials[i] = newCredentialResponse(credentials[i]) } WriteSuccess(w, map[string]interface{}{ - "credentials": credentials, + "credentials": responseCredentials, "stats": stats, }) } @@ -269,16 +356,7 @@ func (h *Handler) importEndpointCredentials(w http.ResponseWriter, r *http.Reque } func (h *Handler) updateEndpointCredential(w http.ResponseWriter, r *http.Request, endpointName string, id int64) { - var req struct { - AccessToken *string `json:"accessToken"` - RefreshToken *string `json:"refreshToken"` - IDToken *string `json:"idToken"` - Remark *string `json:"remark"` - Enabled *bool `json:"enabled"` - ExpiresAt *string `json:"expiresAt"` - LastRefresh *string `json:"lastRefresh"` - Status *string `json:"status"` - } + var req updateCredentialRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { WriteError(w, http.StatusBadRequest, "Invalid request body") @@ -296,20 +374,26 @@ func (h *Handler) updateEndpointCredential(w http.ResponseWriter, r *http.Reques return } - if req.AccessToken != nil { + if req.ClearAccessToken { + WriteError(w, http.StatusBadRequest, "accessToken cannot be cleared") + return + } + if req.AccessToken != nil && strings.TrimSpace(*req.AccessToken) != "" { token := strings.TrimSpace(*req.AccessToken) - if token == "" { - WriteError(w, http.StatusBadRequest, "accessToken cannot be empty") - return - } cred.AccessToken = token } - if req.RefreshToken != nil { + if req.RefreshToken != nil && strings.TrimSpace(*req.RefreshToken) != "" { cred.RefreshToken = strings.TrimSpace(*req.RefreshToken) } - if req.IDToken != nil { + if req.IDToken != nil && strings.TrimSpace(*req.IDToken) != "" { cred.IDToken = strings.TrimSpace(*req.IDToken) } + if req.ClearRefreshToken { + cred.RefreshToken = "" + } + if req.ClearIDToken { + cred.IDToken = "" + } if req.Remark != nil { cred.Remark = strings.TrimSpace(*req.Remark) } @@ -339,6 +423,16 @@ func (h *Handler) updateEndpointCredential(w http.ResponseWriter, r *http.Reques WriteError(w, http.StatusBadRequest, "invalid status") return } + if status == "active" { + if cred.ExpiresAt != nil && !time.Now().UTC().Before(cred.ExpiresAt.UTC()) { + WriteError(w, http.StatusBadRequest, "expired credential requires a new token") + return + } + cred.Enabled = true + cred.FailureCount = 0 + cred.CooldownUntil = nil + cred.LastError = "" + } cred.Status = status } } @@ -359,14 +453,30 @@ func (h *Handler) updateEndpointCredential(w http.ResponseWriter, r *http.Reques WriteError(w, http.StatusNotFound, "Credential not found") return } - updated.AccessToken = maskToken(updated.AccessToken) - updated.RefreshToken = maskToken(updated.RefreshToken) - updated.IDToken = maskToken(updated.IDToken) - WriteSuccess(w, updated) + updated.RateLimits, _ = h.storage.GetCredentialRateLimits(id) + if usage, usageErr := h.storage.GetCredentialUsageByEndpoint(endpointName); usageErr == nil { + updated.Usage = usage[id] + } + WriteSuccess(w, newCredentialResponse(*updated)) } func (h *Handler) deleteEndpointCredential(w http.ResponseWriter, r *http.Request, endpointName string, id int64) { + credential, err := h.storage.GetCredentialByID(id) + if err != nil { + logger.Error("Failed to get credential: %v", err) + WriteError(w, http.StatusInternalServerError, "Failed to get credential") + return + } + if credential == nil || credential.EndpointName != endpointName { + WriteError(w, http.StatusNotFound, "Credential not found") + return + } + if err := h.storage.DeleteEndpointCredential(endpointName, id); err != nil { + if errors.Is(err, storage.ErrCredentialNotFound) { + WriteError(w, http.StatusNotFound, "Credential not found") + return + } logger.Error("Failed to delete credential: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to delete credential") return @@ -455,14 +565,3 @@ func isAllowedCredentialStatus(status string) bool { return false } } - -func maskToken(token string) string { - token = strings.TrimSpace(token) - if token == "" { - return "" - } - if len(token) <= 10 { - return "****" - } - return token[:6] + "..." + token[len(token)-4:] -} diff --git a/cmd/server/webui/api/credentials_contract_test.go b/cmd/server/webui/api/credentials_contract_test.go new file mode 100644 index 00000000..9469120e --- /dev/null +++ b/cmd/server/webui/api/credentials_contract_test.go @@ -0,0 +1,222 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/lich0821/ccNexus/internal/storage" +) + +func saveCredentialForTest(t *testing.T, h *Handler, endpointName string) storage.EndpointCredential { + t.Helper() + credential := storage.EndpointCredential{ + EndpointName: endpointName, + ProviderType: "codex", + AccountID: "account-1", + Email: "user@example.com", + AccessToken: "access-secret", + RefreshToken: "refresh-secret", + IDToken: "id-secret", + Status: "active", + Enabled: true, + Remark: "primary", + } + if err := h.storage.SaveEndpointCredential(&credential); err != nil { + t.Fatalf("save credential: %v", err) + } + return credential +} + +func assertCredentialResponseHasNoSecrets(t *testing.T, body string) { + t.Helper() + for _, field := range []string{`"accessToken"`, `"refreshToken"`, `"idToken"`} { + if strings.Contains(body, field) { + t.Fatalf("credential response contains secret field %s: %s", field, body) + } + } +} + +func TestListEndpointCredentialsReturnsTokenPresenceWithoutSecrets(t *testing.T) { + h := newEndpointTestHandler(t) + credential := saveCredentialForTest(t, h, "endpoint-a") + credential.LastError = "denied access-secret refresh-secret id-secret" + if err := h.storage.UpdateEndpointCredential(&credential); err != nil { + t.Fatalf("save credential error: %v", err) + } + if err := h.storage.UpsertCredentialRateLimits(credential.ID, &storage.CodexRateLimitsData{Source: "test"}, "error", "rate access-secret refresh-secret id-secret", time.Now()); err != nil { + t.Fatalf("save rate limits: %v", err) + } + + rec := httptest.NewRecorder() + h.listEndpointCredentials(rec, httptest.NewRequest(http.MethodGet, "/api/endpoints/endpoint-a/credentials", nil), "endpoint-a") + + if rec.Code != http.StatusOK { + t.Fatalf("list credentials failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + assertCredentialResponseHasNoSecrets(t, rec.Body.String()) + for _, secret := range []string{"access-secret", "refresh-secret", "id-secret"} { + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("credential response leaked %q: %s", secret, rec.Body.String()) + } + } + var response struct { + Data struct { + Credentials []map[string]interface{} `json:"credentials"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.Data.Credentials) != 1 { + t.Fatalf("expected one credential, got %d", len(response.Data.Credentials)) + } + got := response.Data.Credentials[0] + for _, field := range []string{"hasAccessToken", "hasRefreshToken", "hasIdToken"} { + if present, _ := got[field].(bool); !present { + t.Fatalf("expected %s=true: %s", field, rec.Body.String()) + } + } + if got["rateLimits"] == nil { + t.Fatalf("expected rateLimits in response: %s", rec.Body.String()) + } +} + +func TestCredentialRoutesRejectExtraSegments(t *testing.T) { + h := newEndpointTestHandler(t) + h.config.UpdateBasicAuth(false, "", "") + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "endpoint-a", APIUrl: "https://example.com", APIKey: "secret", AuthMode: "api_key", Transformer: "openai", + }) + credential := saveCredentialForTest(t, h, "endpoint-a") + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/endpoints/endpoint-a/credentials/1/garbage", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("extra credential path status=%d body=%s", rec.Code, rec.Body.String()) + } + if got, err := h.storage.GetCredentialByID(credential.ID); err != nil || got == nil { + t.Fatalf("extra path changed credential: credential=%v err=%v", got, err) + } +} + +func TestActivateCredentialClearsRecoverableFailureState(t *testing.T) { + h := newEndpointTestHandler(t) + credential := saveCredentialForTest(t, h, "endpoint-a") + cooldown := time.Now().Add(time.Hour) + credential.Enabled = false + credential.Status = "cooldown" + credential.FailureCount = 3 + credential.CooldownUntil = &cooldown + credential.LastError = "temporary failure" + if err := h.storage.UpdateEndpointCredential(&credential); err != nil { + t.Fatalf("prepare credential: %v", err) + } + + rec := httptest.NewRecorder() + h.updateEndpointCredential(rec, httptest.NewRequest(http.MethodPatch, "/api/endpoints/endpoint-a/credentials/1", strings.NewReader(`{"status":"active"}`)), "endpoint-a", credential.ID) + if rec.Code != http.StatusOK { + t.Fatalf("activate status=%d body=%s", rec.Code, rec.Body.String()) + } + got, err := h.storage.GetCredentialByID(credential.ID) + if err != nil || got == nil { + t.Fatalf("load activated credential: credential=%v err=%v", got, err) + } + if !got.Enabled || got.Status != "active" || got.FailureCount != 0 || got.CooldownUntil != nil || got.LastError != "" { + t.Fatalf("activation left failure state: %+v", got) + } + + expired := time.Now().Add(-time.Minute) + got.ExpiresAt = &expired + if err := h.storage.UpdateEndpointCredential(got); err != nil { + t.Fatalf("expire credential: %v", err) + } + rec = httptest.NewRecorder() + h.updateEndpointCredential(rec, httptest.NewRequest(http.MethodPatch, "/api/endpoints/endpoint-a/credentials/1", strings.NewReader(`{"status":"active"}`)), "endpoint-a", credential.ID) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expired activation status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestUpdateEndpointCredentialTokenContract(t *testing.T) { + h := newEndpointTestHandler(t) + credential := saveCredentialForTest(t, h, "endpoint-a") + + patch := func(body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.updateEndpointCredential(rec, httptest.NewRequest(http.MethodPatch, "/api/endpoints/endpoint-a/credentials/1", strings.NewReader(body)), "endpoint-a", credential.ID) + return rec + } + load := func() *storage.EndpointCredential { + t.Helper() + got, err := h.storage.GetCredentialByID(credential.ID) + if err != nil || got == nil { + t.Fatalf("load credential: credential=%v err=%v", got, err) + } + return got + } + + rec := patch(`{"accessToken":" ","refreshToken":"","idToken":" "}`) + if rec.Code != http.StatusOK { + t.Fatalf("blank token patch failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + got := load() + if got.AccessToken != "access-secret" || got.RefreshToken != "refresh-secret" || got.IDToken != "id-secret" { + t.Fatalf("blank token fields must preserve values: %+v", got) + } + + rec = patch(`{"accessToken":"new-access","refreshToken":"new-refresh","idToken":"new-id"}`) + if rec.Code != http.StatusOK { + t.Fatalf("token replacement failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + assertCredentialResponseHasNoSecrets(t, rec.Body.String()) + got = load() + if got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" || got.IDToken != "new-id" { + t.Fatalf("non-empty token fields must replace values: %+v", got) + } + + rec = patch(`{"clearRefreshToken":true,"clearIdToken":true}`) + if rec.Code != http.StatusOK { + t.Fatalf("explicit token clear failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + got = load() + if got.AccessToken != "new-access" || got.RefreshToken != "" || got.IDToken != "" { + t.Fatalf("explicit clear produced unexpected tokens: %+v", got) + } + + rec = patch(`{"clearAccessToken":true}`) + if rec.Code != http.StatusBadRequest || load().AccessToken != "new-access" { + t.Fatalf("access token clear must be rejected: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeleteEndpointCredentialRejectsCrossEndpointID(t *testing.T) { + h := newEndpointTestHandler(t) + credential := saveCredentialForTest(t, h, "endpoint-b") + if err := h.storage.UpsertCredentialRateLimits(credential.ID, &storage.CodexRateLimitsData{Source: "test"}, "ok", "", time.Now()); err != nil { + t.Fatalf("save rate limits: %v", err) + } + if err := h.storage.UpsertCredentialUsage(credential.ID, "endpoint-b", 1, 2, 3, 4, time.Now()); err != nil { + t.Fatalf("save usage: %v", err) + } + + rec := httptest.NewRecorder() + h.deleteEndpointCredential(rec, httptest.NewRequest(http.MethodDelete, "/api/endpoints/endpoint-a/credentials/1", nil), "endpoint-a", credential.ID) + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404, status=%d body=%s", rec.Code, rec.Body.String()) + } + if got, err := h.storage.GetCredentialByID(credential.ID); err != nil || got == nil { + t.Fatalf("credential changed after rejected delete: credential=%v err=%v", got, err) + } + if got, err := h.storage.GetCredentialRateLimits(credential.ID); err != nil || got == nil { + t.Fatalf("rate limits changed after rejected delete: rateLimits=%v err=%v", got, err) + } + usage, err := h.storage.GetCredentialUsageByEndpoint("endpoint-b") + if err != nil || usage[credential.ID] == nil { + t.Fatalf("usage changed after rejected delete: usage=%v err=%v", usage, err) + } +} diff --git a/cmd/server/webui/api/endpoints.go b/cmd/server/webui/api/endpoints.go index 857510bd..4744c45e 100644 --- a/cmd/server/webui/api/endpoints.go +++ b/cmd/server/webui/api/endpoints.go @@ -2,15 +2,148 @@ package api import ( "encoding/json" + "errors" + "fmt" "net/http" + "net/url" "strings" "time" "github.com/lich0821/ccNexus/internal/config" "github.com/lich0821/ccNexus/internal/logger" + "github.com/lich0821/ccNexus/internal/proxy" "github.com/lich0821/ccNexus/internal/storage" ) +type endpointResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + APIUrl string `json:"apiUrl"` + APIKey string `json:"apiKey"` + HasAPIKey bool `json:"hasApiKey"` + AuthMode string `json:"authMode"` + Enabled bool `json:"enabled"` + Transformer string `json:"transformer"` + Model string `json:"model"` + Remark string `json:"remark"` + SortOrder int `json:"sortOrder"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type createEndpointRequest struct { + Name string `json:"name"` + APIUrl string `json:"apiUrl"` + APIKey string `json:"apiKey"` + AuthMode string `json:"authMode"` + Enabled bool `json:"enabled"` + Transformer string `json:"transformer"` + Model string `json:"model"` + Remark string `json:"remark"` + CloneFrom string `json:"cloneFrom"` +} + +type updateEndpointRequest struct { + Name string `json:"name"` + APIUrl string `json:"apiUrl"` + APIKey string `json:"apiKey"` + ClearAPIKey bool `json:"clearApiKey"` + AuthMode string `json:"authMode"` + Enabled bool `json:"enabled"` + Transformer string `json:"transformer"` + Model *string `json:"model"` + Remark string `json:"remark"` +} + +func newEndpointResponse(endpoint storage.Endpoint) endpointResponse { + return endpointResponse{ + ID: endpoint.ID, + Name: endpoint.Name, + APIUrl: proxy.RedactURLSecrets(endpoint.APIUrl), + APIKey: endpoint.APIKey, + HasAPIKey: strings.TrimSpace(endpoint.APIKey) != "", + AuthMode: config.NormalizeAuthMode(endpoint.AuthMode), + Enabled: endpoint.Enabled, + Transformer: endpoint.Transformer, + Model: endpoint.Model, + Remark: endpoint.Remark, + SortOrder: endpoint.SortOrder, + CreatedAt: endpoint.CreatedAt, + UpdatedAt: endpoint.UpdatedAt, + } +} + +func normalizeEndpointForSave(endpoint *storage.Endpoint, allowEmptyAPIKey bool) error { + normalized := config.Endpoint{ + Name: endpoint.Name, + APIUrl: endpoint.APIUrl, + APIKey: endpoint.APIKey, + AuthMode: endpoint.AuthMode, + Enabled: endpoint.Enabled, + Transformer: endpoint.Transformer, + Model: endpoint.Model, + Remark: endpoint.Remark, + } + if normalized.Transformer == "" { + normalized.Transformer = "claude" + } + config.ApplyEndpointAuthModeRules(&normalized) + normalized.APIUrl = normalizeAPIUrl(normalized.APIUrl) + if !isSupportedEndpointTransformer(normalized.Transformer) { + return fmt.Errorf("unsupported transformer: %s", normalized.Transformer) + } + if normalized.AuthMode == config.AuthModeAPIKey && strings.TrimSpace(normalized.APIKey) == "" && !allowEmptyAPIKey { + return fmt.Errorf("apiKey is required in api_key mode") + } + + endpoint.APIUrl = normalized.APIUrl + endpoint.APIKey = normalized.APIKey + endpoint.AuthMode = normalized.AuthMode + endpoint.Transformer = normalized.Transformer + return nil +} + +func isSupportedEndpointTransformer(transformer string) bool { + switch transformer { + case "claude", "openai", "openai2", "gemini": + return true + default: + return false + } +} + +func mergeHiddenAPIURL(submitted, stored string) string { + submittedURL, submittedErr := url.Parse(strings.TrimSpace(submitted)) + storedURL, storedErr := url.Parse(strings.TrimSpace(stored)) + if submittedErr != nil || storedErr != nil || submittedURL == nil || storedURL == nil || + !strings.EqualFold(submittedURL.Scheme, storedURL.Scheme) || + !strings.EqualFold(submittedURL.Host, storedURL.Host) { + return submitted + } + + if submittedURL.User == nil { + submittedURL.User = storedURL.User + } + query := submittedURL.Query() + storedQuery := storedURL.Query() + for key, values := range query { + storedValues := storedQuery[key] + for i, value := range values { + if value != "[REDACTED]" || len(storedValues) == 0 { + continue + } + if i < len(storedValues) { + values[i] = storedValues[i] + } else { + values[i] = storedValues[0] + } + } + query[key] = values + } + submittedURL.RawQuery = query.Encode() + return submittedURL.String() +} + // handleEndpoints handles GET (list) and POST (create) for endpoints func (h *Handler) handleEndpoints(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -25,28 +158,43 @@ func (h *Handler) handleEndpoints(w http.ResponseWriter, r *http.Request) { // handleEndpointByName handles GET, PUT, DELETE, PATCH for specific endpoint func (h *Handler) handleEndpointByName(w http.ResponseWriter, r *http.Request) { - // Extract endpoint name from path - path := strings.TrimPrefix(r.URL.Path, "/api/endpoints/") + // Split the escaped path first so encoded slashes remain part of the name. + path := strings.TrimPrefix(r.URL.EscapedPath(), "/api/endpoints/") parts := strings.Split(path, "/") if len(parts) == 0 || parts[0] == "" { WriteError(w, http.StatusBadRequest, "Endpoint name required") return } - name := parts[0] + name, err := url.PathUnescape(parts[0]) + if err != nil || strings.TrimSpace(name) == "" { + WriteError(w, http.StatusBadRequest, "Invalid endpoint name") + return + } // Handle /test and /toggle sub-paths if len(parts) > 1 { switch parts[1] { case "test": + if len(parts) != 2 { + http.NotFound(w, r) + return + } h.testEndpoint(w, r, name) return case "toggle": + if len(parts) != 2 { + http.NotFound(w, r) + return + } h.toggleEndpoint(w, r, name) return case "credentials": h.handleEndpointCredentials(w, r, name, parts[2:]) return + default: + http.NotFound(w, r) + return } } @@ -71,9 +219,9 @@ func (h *Handler) listEndpoints(w http.ResponseWriter, r *http.Request) { return } - // Mask API keys + responseEndpoints := make([]endpointResponse, len(endpoints)) for i := range endpoints { - endpoints[i].APIKey = maskAPIKey(endpoints[i].APIKey) + responseEndpoints[i] = newEndpointResponse(endpoints[i]) } tokenPools, err := h.storage.GetAllTokenPoolStats() @@ -83,7 +231,7 @@ func (h *Handler) listEndpoints(w http.ResponseWriter, r *http.Request) { } WriteSuccess(w, map[string]interface{}{ - "endpoints": endpoints, + "endpoints": responseEndpoints, "tokenPools": tokenPools, }) } @@ -99,8 +247,7 @@ func (h *Handler) getEndpoint(w http.ResponseWriter, r *http.Request, name strin for _, ep := range endpoints { if ep.Name == name { - ep.APIKey = maskAPIKey(ep.APIKey) - WriteSuccess(w, ep) + WriteSuccess(w, newEndpointResponse(ep)) return } } @@ -110,65 +257,58 @@ func (h *Handler) getEndpoint(w http.ResponseWriter, r *http.Request, name strin // createEndpoint creates a new endpoint func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - APIUrl string `json:"apiUrl"` - APIKey string `json:"apiKey"` - AuthMode string `json:"authMode"` - Enabled bool `json:"enabled"` - Transformer string `json:"transformer"` - Model string `json:"model"` - Remark string `json:"remark"` - CloneFrom string `json:"cloneFrom"` // Clone from existing endpoint name - } + var req createEndpointRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { WriteError(w, http.StatusBadRequest, "Invalid request body") return } + h.endpointMu.Lock() + defer h.endpointMu.Unlock() - // If cloning, get API key from source endpoint - if req.CloneFrom != "" && req.APIKey == "" { + // Preserve secrets that are intentionally omitted from the source endpoint response. + if req.CloneFrom != "" { endpoints, err := h.storage.GetEndpoints() if err == nil { for _, ep := range endpoints { if ep.Name == req.CloneFrom { - req.APIKey = ep.APIKey + if req.APIKey == "" { + req.APIKey = ep.APIKey + } + req.APIUrl = mergeHiddenAPIURL(req.APIUrl, ep.APIUrl) break } } } } - authMode := config.NormalizeAuthMode(req.AuthMode) - normalizedEndpoint := config.Endpoint{ - APIUrl: normalizeAPIUrl(req.APIUrl), + endpoint := &storage.Endpoint{ + Name: strings.TrimSpace(req.Name), + APIUrl: req.APIUrl, APIKey: req.APIKey, - AuthMode: authMode, + AuthMode: req.AuthMode, + Enabled: req.Enabled, Transformer: req.Transformer, Model: req.Model, Remark: req.Remark, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } - if normalizedEndpoint.Transformer == "" { - normalizedEndpoint.Transformer = "claude" + if isReservedEndpointName(endpoint.Name) { + WriteError(w, http.StatusBadRequest, "Endpoint name is reserved") + return } - config.ApplyEndpointAuthModeRules(&normalizedEndpoint) - authMode = normalizedEndpoint.AuthMode - req.APIUrl = normalizedEndpoint.APIUrl - req.APIKey = normalizedEndpoint.APIKey - req.Transformer = normalizedEndpoint.Transformer - - // Validate required fields - if req.Name == "" || req.APIUrl == "" { + if endpoint.Name == "" || (strings.TrimSpace(endpoint.APIUrl) == "" && config.NormalizeAuthMode(endpoint.AuthMode) != config.AuthModeCodexTokenPool) { WriteError(w, http.StatusBadRequest, "Name and apiUrl are required") return } - if authMode == config.AuthModeAPIKey && req.APIKey == "" { - WriteError(w, http.StatusBadRequest, "apiKey is required in api_key mode") + if err := normalizeEndpointForSave(endpoint, false); err != nil { + WriteError(w, http.StatusBadRequest, err.Error()) return } - if config.IsTokenPoolAuthMode(authMode) { - req.APIKey = "" + if endpoint.APIUrl == "" { + WriteError(w, http.StatusBadRequest, "Name and apiUrl are required") + return } // Get current endpoints to determine sort order @@ -181,26 +321,13 @@ func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { // Check if endpoint with same name exists for _, ep := range endpoints { - if ep.Name == req.Name { + if ep.Name == endpoint.Name { WriteError(w, http.StatusConflict, "Endpoint with this name already exists") return } } - // Create new endpoint - endpoint := &storage.Endpoint{ - Name: req.Name, - APIUrl: normalizeAPIUrl(req.APIUrl), - APIKey: req.APIKey, - AuthMode: authMode, - Enabled: req.Enabled, - Transformer: req.Transformer, - Model: req.Model, - Remark: req.Remark, - SortOrder: len(endpoints), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } + endpoint.SortOrder = len(endpoints) if err := h.storage.SaveEndpoint(endpoint); err != nil { logger.Error("Failed to save endpoint: %v", err) @@ -213,27 +340,19 @@ func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) { logger.Error("Failed to reload config: %v", err) } - endpoint.APIKey = maskAPIKey(endpoint.APIKey) - WriteSuccess(w, endpoint) + WriteSuccess(w, newEndpointResponse(*endpoint)) } // updateEndpoint updates an existing endpoint func (h *Handler) updateEndpoint(w http.ResponseWriter, r *http.Request, name string) { - var req struct { - Name string `json:"name"` - APIUrl string `json:"apiUrl"` - APIKey string `json:"apiKey"` - AuthMode string `json:"authMode"` - Enabled bool `json:"enabled"` - Transformer string `json:"transformer"` - Model string `json:"model"` - Remark string `json:"remark"` - } + var req updateEndpointRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { WriteError(w, http.StatusBadRequest, "Invalid request body") return } + h.endpointMu.Lock() + defer h.endpointMu.Unlock() // Get existing endpoint endpoints, err := h.storage.GetEndpoints() @@ -257,54 +376,52 @@ func (h *Handler) updateEndpoint(w http.ResponseWriter, r *http.Request, name st } // Update fields - if req.Name != "" { - existing.Name = req.Name + if updatedName := strings.TrimSpace(req.Name); updatedName != "" { + if isReservedEndpointName(updatedName) { + WriteError(w, http.StatusBadRequest, "Endpoint name is reserved") + return + } + for _, endpoint := range endpoints { + if endpoint.Name == updatedName && endpoint.Name != name { + WriteError(w, http.StatusConflict, "Endpoint with this name already exists") + return + } + } + existing.Name = updatedName } if req.APIUrl != "" { - existing.APIUrl = normalizeAPIUrl(req.APIUrl) + existing.APIUrl = normalizeAPIUrl(mergeHiddenAPIURL(req.APIUrl, existing.APIUrl)) } - if req.APIKey != "" { + if req.ClearAPIKey { + existing.APIKey = "" + } else if req.APIKey != "" { existing.APIKey = req.APIKey } if req.AuthMode != "" { existing.AuthMode = config.NormalizeAuthMode(req.AuthMode) } - if existing.AuthMode == "" { - existing.AuthMode = config.AuthModeAPIKey - } - normalizedEndpoint := config.Endpoint{ - Name: existing.Name, - APIUrl: existing.APIUrl, - APIKey: existing.APIKey, - AuthMode: existing.AuthMode, - Enabled: existing.Enabled, - Transformer: existing.Transformer, - Model: existing.Model, - Remark: existing.Remark, - } - if normalizedEndpoint.Transformer == "" { - normalizedEndpoint.Transformer = "claude" - } - config.ApplyEndpointAuthModeRules(&normalizedEndpoint) - existing.APIUrl = normalizedEndpoint.APIUrl - existing.APIKey = normalizedEndpoint.APIKey - existing.AuthMode = normalizedEndpoint.AuthMode - existing.Transformer = normalizedEndpoint.Transformer - if existing.AuthMode == config.AuthModeAPIKey && existing.APIKey == "" { - WriteError(w, http.StatusBadRequest, "apiKey is required in api_key mode") - return - } existing.Enabled = req.Enabled if req.Transformer != "" { existing.Transformer = req.Transformer } - if req.Model != "" { - existing.Model = req.Model + if req.Model != nil { + existing.Model = *req.Model } existing.Remark = req.Remark + if req.ClearAPIKey && existing.AuthMode == config.AuthModeAPIKey { + existing.Enabled = false + } + if err := normalizeEndpointForSave(existing, req.ClearAPIKey); err != nil { + WriteError(w, http.StatusBadRequest, err.Error()) + return + } existing.UpdatedAt = time.Now() - if err := h.storage.UpdateEndpoint(existing); err != nil { + if err := h.storage.UpdateEndpointByName(name, existing); err != nil { + if errors.Is(err, storage.ErrEndpointNotFound) { + WriteError(w, http.StatusNotFound, "Endpoint not found") + return + } logger.Error("Failed to update endpoint: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to update endpoint") return @@ -315,13 +432,19 @@ func (h *Handler) updateEndpoint(w http.ResponseWriter, r *http.Request, name st logger.Error("Failed to reload config: %v", err) } - existing.APIKey = maskAPIKey(existing.APIKey) - WriteSuccess(w, existing) + WriteSuccess(w, newEndpointResponse(*existing)) } // deleteEndpoint deletes an endpoint func (h *Handler) deleteEndpoint(w http.ResponseWriter, r *http.Request, name string) { + h.endpointMu.Lock() + defer h.endpointMu.Unlock() + if err := h.storage.DeleteEndpoint(name); err != nil { + if errors.Is(err, storage.ErrEndpointNotFound) { + WriteError(w, http.StatusNotFound, "Endpoint not found") + return + } logger.Error("Failed to delete endpoint: %v", err) WriteError(w, http.StatusInternalServerError, "Failed to delete endpoint") return @@ -352,6 +475,8 @@ func (h *Handler) toggleEndpoint(w http.ResponseWriter, r *http.Request, name st WriteError(w, http.StatusBadRequest, "Invalid request body") return } + h.endpointMu.Lock() + defer h.endpointMu.Unlock() // Get existing endpoint endpoints, err := h.storage.GetEndpoints() @@ -373,6 +498,10 @@ func (h *Handler) toggleEndpoint(w http.ResponseWriter, r *http.Request, name st WriteError(w, http.StatusNotFound, "Endpoint not found") return } + if req.Enabled && config.NormalizeAuthMode(existing.AuthMode) == config.AuthModeAPIKey && strings.TrimSpace(existing.APIKey) == "" { + WriteError(w, http.StatusBadRequest, "apiKey is required to enable this endpoint") + return + } existing.Enabled = req.Enabled existing.UpdatedAt = time.Now() @@ -400,28 +529,14 @@ func (h *Handler) handleCurrentEndpoint(w http.ResponseWriter, r *http.Request) return } - endpoints := h.config.GetEndpoints() - if len(endpoints) == 0 { - WriteError(w, http.StatusNotFound, "No endpoints configured") - return - } - - // Get enabled endpoints - var enabledEndpoints []config.Endpoint - for _, ep := range endpoints { - if ep.Enabled { - enabledEndpoints = append(enabledEndpoints, ep) - } - } - - if len(enabledEndpoints) == 0 { + name := h.proxy.GetCurrentEndpointName() + if name == "" { WriteError(w, http.StatusNotFound, "No enabled endpoints") return } - // Return first enabled endpoint as current WriteSuccess(w, map[string]interface{}{ - "name": enabledEndpoints[0].Name, + "name": name, }) } @@ -440,18 +555,10 @@ func (h *Handler) handleSwitchEndpoint(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusBadRequest, "Invalid request body") return } + h.endpointMu.Lock() + defer h.endpointMu.Unlock() - // Verify endpoint exists - endpoints := h.config.GetEndpoints() - found := false - for _, ep := range endpoints { - if ep.Name == req.Name && ep.Enabled { - found = true - break - } - } - - if !found { + if err := h.proxy.SetCurrentEndpoint(req.Name); err != nil { WriteError(w, http.StatusNotFound, "Endpoint not found or not enabled") return } @@ -477,6 +584,8 @@ func (h *Handler) handleReorderEndpoints(w http.ResponseWriter, r *http.Request) WriteError(w, http.StatusBadRequest, "Invalid request body") return } + h.endpointMu.Lock() + defer h.endpointMu.Unlock() // Get all endpoints endpoints, err := h.storage.GetEndpoints() @@ -486,21 +595,31 @@ func (h *Handler) handleReorderEndpoints(w http.ResponseWriter, r *http.Request) return } - // Create a map for quick lookup - endpointMap := make(map[string]*storage.Endpoint) - for i := range endpoints { - endpointMap[endpoints[i].Name] = &endpoints[i] + if len(req.Names) != len(endpoints) { + WriteError(w, http.StatusBadRequest, "Endpoint order must contain every endpoint exactly once") + return } - - // Update sort order - for i, name := range req.Names { - if ep, ok := endpointMap[name]; ok { - ep.SortOrder = i - ep.UpdatedAt = time.Now() - if err := h.storage.UpdateEndpoint(ep); err != nil { - logger.Error("Failed to update endpoint sort order: %v", err) - } + existingNames := make(map[string]struct{}, len(endpoints)) + for _, endpoint := range endpoints { + existingNames[endpoint.Name] = struct{}{} + } + seen := make(map[string]struct{}, len(req.Names)) + for _, name := range req.Names { + if _, ok := existingNames[name]; !ok { + WriteError(w, http.StatusBadRequest, "Endpoint order contains an unknown endpoint") + return + } + if _, duplicate := seen[name]; duplicate { + WriteError(w, http.StatusBadRequest, "Endpoint order contains a duplicate endpoint") + return } + seen[name] = struct{}{} + } + + if err := h.storage.ReorderEndpoints(req.Names); err != nil { + logger.Error("Failed to reorder endpoints: %v", err) + WriteError(w, http.StatusInternalServerError, "Failed to reorder endpoints") + return } // Update proxy config @@ -516,27 +635,31 @@ func (h *Handler) handleReorderEndpoints(w http.ResponseWriter, r *http.Request) // reloadConfig reloads the configuration from storage and updates the proxy func (h *Handler) reloadConfig() error { adapter := storage.NewConfigStorageAdapter(h.storage) - cfg, err := config.LoadFromStorage(adapter) + loaded, err := config.LoadFromStorage(adapter) if err != nil { return err } - - h.config = cfg - return h.proxy.UpdateConfig(cfg) -} - -// maskAPIKey masks an API key, showing only the last 4 characters -func maskAPIKey(key string) string { - if key == "" { - return "" - } - if len(key) <= 4 { - return "****" + if err := h.proxy.UpdateConfig(loaded); err != nil { + return err } - return "****" + key[len(key)-4:] + h.config.UpdateEndpoints(loaded.GetEndpoints()) + return nil } // normalizeAPIUrl ensures the API URL has the correct format func normalizeAPIUrl(apiUrl string) string { - return strings.TrimSuffix(apiUrl, "/") -} \ No newline at end of file + apiUrl = strings.TrimSuffix(strings.TrimSpace(apiUrl), "/") + if apiUrl != "" && !strings.HasPrefix(apiUrl, "http://") && !strings.HasPrefix(apiUrl, "https://") { + apiUrl = "https://" + apiUrl + } + return apiUrl +} + +func isReservedEndpointName(name string) bool { + switch strings.TrimSpace(name) { + case "current", "switch", "reorder", "fetch-models": + return true + default: + return false + } +} diff --git a/cmd/server/webui/api/endpoints_contract_test.go b/cmd/server/webui/api/endpoints_contract_test.go new file mode 100644 index 00000000..b27664d1 --- /dev/null +++ b/cmd/server/webui/api/endpoints_contract_test.go @@ -0,0 +1,865 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/proxy" + "github.com/lich0821/ccNexus/internal/storage" +) + +func newEndpointTestHandler(t *testing.T) *Handler { + t.Helper() + + db, err := storage.NewSQLiteStorage(filepath.Join(t.TempDir(), "webui.db")) + if err != nil { + t.Fatalf("create test storage: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + cfg := config.DefaultConfig() + p := proxy.New(cfg, storage.NewStatsStorageAdapter(db), db, "test") + return NewHandler(cfg, p, db) +} + +func saveEndpointForTest(t *testing.T, h *Handler, endpoint storage.Endpoint) { + t.Helper() + if err := h.storage.SaveEndpoint(&endpoint); err != nil { + t.Fatalf("save endpoint: %v", err) + } +} + +func TestListEndpointsReturnsAPIKeyInPlaintext(t *testing.T) { + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", + APIUrl: "https://url-user:url-pass@example.com/v1?api_key=query-secret®ion=us", + APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, + Transformer: "openai", + }) + + rec := httptest.NewRecorder() + h.listEndpoints(rec, httptest.NewRequest(http.MethodGet, "/api/endpoints", nil)) + + var response struct { + Data struct { + Endpoints []map[string]interface{} `json:"endpoints"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.Data.Endpoints) != 1 { + t.Fatalf("expected one endpoint, got %d", len(response.Data.Endpoints)) + } + endpoint := response.Data.Endpoints[0] + if apiKey, _ := endpoint["apiKey"].(string); apiKey != "top-secret" { + t.Fatalf("expected plaintext apiKey, got %q: %s", apiKey, rec.Body.String()) + } + if hasAPIKey, _ := endpoint["hasApiKey"].(bool); !hasAPIKey { + t.Fatalf("expected hasApiKey=true: %s", rec.Body.String()) + } + for _, secret := range []string{"url-user", "url-pass", "query-secret"} { + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("response leaked URL secret %q: %s", secret, rec.Body.String()) + } + } + safeURL, err := url.Parse(endpoint["apiUrl"].(string)) + if err != nil { + t.Fatalf("parse redacted apiUrl: %v", err) + } + if safeURL.User != nil || safeURL.Query().Get("api_key") != "[REDACTED]" || safeURL.Query().Get("region") != "us" { + t.Fatalf("unexpected redacted apiUrl: %s", safeURL) + } +} + +func TestUpdateEndpointPreservesHiddenURLSecretsWhenEditingVisibleFields(t *testing.T) { + h := newEndpointTestHandler(t) + const storedURL = "https://url-user:url-pass@example.com/v1?api_key=query-secret®ion=us" + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", APIUrl: storedURL, APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, Enabled: true, Transformer: "openai", + }) + + rec := httptest.NewRecorder() + h.updateEndpoint(rec, httptest.NewRequest(http.MethodPut, "/api/endpoints/saved", strings.NewReader(`{"apiUrl":"https://example.com/v2?api_key=%5BREDACTED%5D®ion=eu","apiKey":"","authMode":"api_key","enabled":true,"transformer":"openai"}`)), "saved") + if rec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String()) + } + + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("load endpoint: count=%d err=%v", len(endpoints), err) + } + updatedURL, err := url.Parse(endpoints[0].APIUrl) + if err != nil { + t.Fatalf("parse updated URL: %v", err) + } + password, _ := updatedURL.User.Password() + if updatedURL.User.Username() != "url-user" || password != "url-pass" || updatedURL.Path != "/v2" || updatedURL.Query().Get("api_key") != "query-secret" || updatedURL.Query().Get("region") != "eu" { + t.Fatalf("unexpected merged URL: %s", updatedURL) + } +} + +func TestCloneEndpointPreservesHiddenURLSecretsWhenEditingVisibleFields(t *testing.T) { + h := newEndpointTestHandler(t) + const storedURL = "https://url-user:url-pass@example.com/v1?api_key=query-secret®ion=us" + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "source", APIUrl: storedURL, APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, Enabled: true, Transformer: "openai", + }) + + body := `{"name":"clone","apiUrl":"https://example.com/v2?api_key=%5BREDACTED%5D®ion=eu","apiKey":"","authMode":"api_key","enabled":true,"transformer":"openai","cloneFrom":"source"}` + rec := httptest.NewRecorder() + h.createEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints", strings.NewReader(body))) + if rec.Code != http.StatusOK { + t.Fatalf("clone status=%d body=%s", rec.Code, rec.Body.String()) + } + + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 2 { + t.Fatalf("load endpoints: count=%d err=%v", len(endpoints), err) + } + clonedURL, err := url.Parse(endpoints[1].APIUrl) + if err != nil { + t.Fatalf("parse cloned URL: %v", err) + } + password, _ := clonedURL.User.Password() + if clonedURL.User.Username() != "url-user" || password != "url-pass" || clonedURL.Path != "/v2" || clonedURL.Query().Get("api_key") != "query-secret" || clonedURL.Query().Get("region") != "eu" { + t.Fatalf("unexpected cloned URL: %s", clonedURL) + } +} + +func TestUpdateEndpointDoesNotCarryHiddenURLSecretsToDifferentHost(t *testing.T) { + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", APIUrl: "https://url-user:url-pass@example.com/v1?api_key=query-secret", APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, Enabled: true, Transformer: "openai", + }) + + rec := httptest.NewRecorder() + h.updateEndpoint(rec, httptest.NewRequest(http.MethodPut, "/api/endpoints/saved", strings.NewReader(`{"apiUrl":"https://other.example/v1?region=eu","apiKey":"","authMode":"api_key","enabled":true,"transformer":"openai"}`)), "saved") + if rec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String()) + } + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("load endpoint: count=%d err=%v", len(endpoints), err) + } + updatedURL, err := url.Parse(endpoints[0].APIUrl) + if err != nil { + t.Fatalf("parse updated URL: %v", err) + } + if updatedURL.Host != "other.example" || updatedURL.User != nil || updatedURL.Query().Get("api_key") != "" { + t.Fatalf("old URL secrets crossed hosts: %s", updatedURL) + } +} + +func TestEndpointResponseNormalizesLegacyAuthMode(t *testing.T) { + response := newEndpointResponse(storage.Endpoint{AuthMode: ""}) + if response.AuthMode != config.AuthModeAPIKey { + t.Fatalf("expected legacy authMode to normalize to %q, got %q", config.AuthModeAPIKey, response.AuthMode) + } +} + +func TestCurrentEndpointAndSwitchUseProxyState(t *testing.T) { + h := newEndpointTestHandler(t) + h.config.UpdateEndpoints([]config.Endpoint{ + {Name: "first", APIUrl: "https://first.example", Enabled: true}, + {Name: "second", APIUrl: "https://second.example", Enabled: true}, + }) + if err := h.proxy.SetCurrentEndpoint("second"); err != nil { + t.Fatalf("set initial current endpoint: %v", err) + } + + rec := httptest.NewRecorder() + h.handleCurrentEndpoint(rec, httptest.NewRequest(http.MethodGet, "/api/endpoints/current", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"name":"second"`) { + t.Fatalf("current endpoint ignored proxy state: status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.handleSwitchEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints/switch", strings.NewReader(`{"name":"first"}`))) + if rec.Code != http.StatusOK || h.proxy.GetCurrentEndpointName() != "first" { + t.Fatalf("switch did not update proxy state: status=%d body=%s current=%q", rec.Code, rec.Body.String(), h.proxy.GetCurrentEndpointName()) + } +} + +func TestReloadConfigKeepsSharedConfigPointer(t *testing.T) { + h := newEndpointTestHandler(t) + shared := h.config + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", + APIUrl: "https://example.com", + APIKey: "secret", + AuthMode: config.AuthModeAPIKey, + Enabled: true, + Transformer: "openai", + }) + + if err := h.reloadConfig(); err != nil { + t.Fatalf("reload config: %v", err) + } + if h.config != shared { + t.Fatal("reloadConfig replaced the shared config used by dynamic middleware") + } + if endpoints := shared.GetEndpoints(); len(endpoints) != 1 || endpoints[0].Name != "saved" { + t.Fatalf("shared config did not receive stored endpoints: %+v", endpoints) + } +} + +func TestUpdateEndpointAPIKeyContract(t *testing.T) { + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", + APIUrl: "https://example.com", + APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, + Transformer: "openai", + }) + + update := func(body string, wantStatus int) { + t.Helper() + rec := httptest.NewRecorder() + h.updateEndpoint(rec, httptest.NewRequest(http.MethodPut, "/api/endpoints/saved", strings.NewReader(body)), "saved") + if rec.Code != wantStatus { + t.Fatalf("update status=%d, want %d body=%s", rec.Code, wantStatus, rec.Body.String()) + } + } + storedKey := func() string { + t.Helper() + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("load endpoint: count=%d err=%v", len(endpoints), err) + } + return endpoints[0].APIKey + } + + update(`{"apiUrl":"https://example.com","apiKey":"","authMode":"api_key","enabled":true,"transformer":"openai"}`, http.StatusOK) + if got := storedKey(); got != "top-secret" { + t.Fatalf("empty apiKey must preserve stored key, got %q", got) + } + + update(`{"apiUrl":"https://example.com","apiKey":"","clearApiKey":true,"authMode":"api_key","enabled":true,"transformer":"openai"}`, http.StatusOK) + if got := storedKey(); got != "" { + t.Fatalf("clearApiKey must clear stored key, got %q", got) + } + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 || endpoints[0].Enabled { + t.Fatalf("clearing an api_key endpoint must disable it: endpoints=%+v err=%v", endpoints, err) + } + if err := h.reloadConfig(); err != nil { + t.Fatalf("cleared disabled endpoint must remain reloadable: %v", err) + } + rec := httptest.NewRecorder() + h.toggleEndpoint(rec, httptest.NewRequest(http.MethodPatch, "/api/endpoints/saved/toggle", strings.NewReader(`{"enabled":true}`)), "saved") + if rec.Code != http.StatusBadRequest { + t.Fatalf("enabling keyless api_key endpoint status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestEndpointRoutesRejectReservedNamesAndExtraSegments(t *testing.T) { + h := newEndpointTestHandler(t) + h.config.UpdateBasicAuth(false, "", "") + + for _, name := range []string{"current", "switch", "reorder", "fetch-models"} { + rec := httptest.NewRecorder() + body := fmt.Sprintf(`{"name":%q,"apiUrl":"https://example.com","apiKey":"secret","authMode":"api_key","enabled":true,"transformer":"openai"}`, name) + h.createEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints", strings.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("reserved name %q status=%d body=%s", name, rec.Code, rec.Body.String()) + } + } + + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "kept", APIUrl: "https://example.com", APIKey: "secret", + AuthMode: config.AuthModeAPIKey, Enabled: true, Transformer: "openai", + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/endpoints/kept/garbage", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("extra endpoint path status=%d body=%s", rec.Code, rec.Body.String()) + } + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 || endpoints[0].Name != "kept" { + t.Fatalf("extra path changed endpoint: endpoints=%+v err=%v", endpoints, err) + } +} + +func TestConcurrentEndpointCreatesKeepUniqueOrderAndConfigSnapshot(t *testing.T) { + h := newEndpointTestHandler(t) + const count = 24 + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make(chan string, count) + + for i := 0; i < count; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + body := fmt.Sprintf(`{"name":"endpoint-%02d","apiUrl":"https://example.com","apiKey":"secret","authMode":"api_key","enabled":true,"transformer":"openai"}`, i) + rec := httptest.NewRecorder() + h.createEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints", strings.NewReader(body))) + if rec.Code != http.StatusOK { + errs <- fmt.Sprintf("create %d status=%d body=%s", i, rec.Code, rec.Body.String()) + } + }(i) + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != count { + t.Fatalf("stored endpoints count=%d err=%v", len(endpoints), err) + } + for i, endpoint := range endpoints { + if endpoint.SortOrder != i { + t.Fatalf("endpoint %q sortOrder=%d, want %d", endpoint.Name, endpoint.SortOrder, i) + } + } + if configured := h.config.GetEndpoints(); len(configured) != count { + t.Fatalf("proxy config snapshot has %d endpoints, want %d", len(configured), count) + } +} + +func TestProviderURLNormalizesMissingScheme(t *testing.T) { + if got, want := providerURL("api.anthropic.com", "/v1/messages"), "https://api.anthropic.com/v1/messages"; got != want { + t.Fatalf("providerURL()=%q, want %q", got, want) + } +} + +func TestUpdateEndpointModelContract(t *testing.T) { + tests := []struct { + name string + body string + wantModel string + }{ + { + name: "omitted model preserves stored model", + body: `{"apiUrl":"https://example.com","authMode":"api_key","enabled":true,"transformer":"openai"}`, + wantModel: "gpt-old", + }, + { + name: "empty model clears stored model", + body: `{"apiUrl":"https://example.com","authMode":"api_key","enabled":true,"transformer":"openai","model":""}`, + wantModel: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", + APIUrl: "https://example.com", + APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, + Transformer: "openai", + Model: "gpt-old", + }) + + rec := httptest.NewRecorder() + h.updateEndpoint(rec, httptest.NewRequest(http.MethodPut, "/api/endpoints/saved", strings.NewReader(tt.body)), "saved") + if rec.Code != http.StatusOK { + t.Fatalf("update failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("load endpoint: count=%d err=%v", len(endpoints), err) + } + if got := endpoints[0].Model; got != tt.wantModel { + t.Fatalf("expected model %q, got %q", tt.wantModel, got) + } + }) + } +} + +func TestUpdateEndpointRenamesStoredEndpoint(t *testing.T) { + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "old-name", APIUrl: "https://example.com", APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, Enabled: true, Transformer: "openai", Model: "gpt-test", + }) + + rec := httptest.NewRecorder() + h.updateEndpoint(rec, httptest.NewRequest(http.MethodPut, "/api/endpoints/old-name", strings.NewReader( + `{"name":"new-name","apiUrl":"https://example.com","authMode":"api_key","enabled":true,"transformer":"openai","model":"gpt-test"}`, + )), "old-name") + if rec.Code != http.StatusOK { + t.Fatalf("rename failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("load endpoints: count=%d err=%v", len(endpoints), err) + } + if endpoints[0].Name != "new-name" { + t.Fatalf("stored endpoint name = %q, want new-name", endpoints[0].Name) + } +} + +func TestEncodedEndpointNameWithSlashIsAddressable(t *testing.T) { + h := newEndpointTestHandler(t) + h.config.UpdateBasicAuth(false, "", "") + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "team/primary", APIUrl: "https://example.com", APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, Transformer: "openai", + }) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/endpoints/team%2Fprimary", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"name":"team/primary"`) { + t.Fatalf("encoded endpoint name was not addressable: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeleteMissingEndpointReturnsNotFound(t *testing.T) { + h := newEndpointTestHandler(t) + rec := httptest.NewRecorder() + h.deleteEndpoint(rec, httptest.NewRequest(http.MethodDelete, "/api/endpoints/missing", nil), "missing") + if rec.Code != http.StatusNotFound { + t.Fatalf("delete missing endpoint status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestReorderEndpointsRequiresCompleteUniqueNames(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "partial", body: `{"names":["first"]}`}, + {name: "duplicate", body: `{"names":["first","first"]}`}, + {name: "unknown", body: `{"names":["first","missing"]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newEndpointTestHandler(t) + for _, name := range []string{"first", "second"} { + saveEndpointForTest(t, h, storage.Endpoint{ + Name: name, APIUrl: "https://example.com", APIKey: "key", + AuthMode: config.AuthModeAPIKey, Transformer: "openai", + }) + } + + rec := httptest.NewRecorder() + h.handleReorderEndpoints(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints/reorder", strings.NewReader(tt.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestCreateEndpointRejectsUnsupportedTransformer(t *testing.T) { + h := newEndpointTestHandler(t) + rec := httptest.NewRecorder() + h.createEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints", strings.NewReader( + `{"name":"deepseek","apiUrl":"https://example.com","apiKey":"secret","authMode":"api_key","enabled":true,"transformer":"deepseek"}`, + ))) + + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "unsupported transformer") { + t.Fatalf("expected explicit unsupported transformer error, status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCreateEndpointNormalizesMissingScheme(t *testing.T) { + h := newEndpointTestHandler(t) + rec := httptest.NewRecorder() + h.createEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints", strings.NewReader( + `{"name":"normalized","apiUrl":"api.example.com/","apiKey":"secret","authMode":"api_key","enabled":true,"transformer":"openai"}`, + ))) + if rec.Code != http.StatusOK { + t.Fatalf("create endpoint status=%d body=%s", rec.Code, rec.Body.String()) + } + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("load endpoints: count=%d err=%v", len(endpoints), err) + } + if endpoints[0].APIUrl != "https://api.example.com" { + t.Fatalf("apiUrl=%q, want normalized https URL", endpoints[0].APIUrl) + } +} + +func TestFetchModelsUsesStoredEndpointKey(t *testing.T) { + authorization := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization <- r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-test"}]}`)) + })) + defer upstream.Close() + + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "saved", + APIUrl: upstream.URL, + APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, + Transformer: "openai", + }) + + rec := httptest.NewRecorder() + h.handleFetchModels(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints/fetch-models", strings.NewReader(`{"endpointName":"saved"}`))) + + if rec.Code != http.StatusOK { + t.Fatalf("fetch models failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + select { + case got := <-authorization: + if got != "Bearer top-secret" { + t.Fatalf("unexpected authorization header %q", got) + } + default: + t.Fatal("provider did not receive request") + } + if strings.Contains(rec.Body.String(), "top-secret") { + t.Fatalf("response leaked stored key: %s", rec.Body.String()) + } +} + +func TestSendTestRequestUsesTransformerProtocol(t *testing.T) { + t.Run("openai2 responses", func(t *testing.T) { + type capture struct { + path string + body map[string]interface{} + } + requests := make(chan capture, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + requests <- capture{path: r.URL.Path, body: body} + _, _ = w.Write([]byte(`{"output_text":"ok"}`)) + })) + defer upstream.Close() + + h := newEndpointTestHandler(t) + got, err := h.sendTestRequest(&storage.Endpoint{ + Name: "responses", APIUrl: upstream.URL + "/v1", APIKey: "secret", + AuthMode: config.AuthModeAPIKey, Transformer: "openai2", Model: "gpt-test", + }) + if err != nil { + t.Fatalf("send test request: %v", err) + } + request := <-requests + if request.path != "/v1/responses" || request.body["input"] == nil { + t.Fatalf("unexpected Responses request: path=%s body=%v", request.path, request.body) + } + if got != "ok" { + t.Fatalf("unexpected response text %q", got) + } + }) + + t.Run("codex token pool headers and response redaction", func(t *testing.T) { + type capture struct { + authorization string + accountID string + originator string + version string + sessionID string + userAgent string + } + requests := make(chan capture, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- capture{ + authorization: r.Header.Get("Authorization"), + accountID: r.Header.Get("Chatgpt-Account-Id"), + originator: r.Header.Get("Originator"), + version: r.Header.Get("Version"), + sessionID: r.Header.Get("Session_id"), + userAgent: r.Header.Get("User-Agent"), + } + _, _ = w.Write([]byte(`{"output_text":"access-secret refresh-secret id-secret url-user url-pass"}`)) + })) + defer upstream.Close() + + h := newEndpointTestHandler(t) + saveCredentialForTest(t, h, "codex-pool") + got, err := h.sendTestRequest(&storage.Endpoint{ + Name: "codex-pool", APIUrl: strings.Replace(upstream.URL, "http://", "http://url-user:url-pass@", 1), AuthMode: config.AuthModeTokenPool, + Transformer: "openai2", Model: "gpt-test", + }) + if err != nil { + t.Fatalf("send token pool test request: %v", err) + } + request := <-requests + if request.authorization != "Bearer access-secret" || request.accountID != "account-1" || request.originator != "codex_cli_rs" || request.version == "" || request.sessionID == "" || request.userAgent == "" { + t.Fatalf("incomplete Codex request headers: %+v", request) + } + for _, secret := range []string{"access-secret", "refresh-secret", "id-secret", "url-user", "url-pass"} { + if strings.Contains(got, secret) { + t.Fatalf("successful test response leaked %q: %q", secret, got) + } + } + }) + + t.Run("claude configured model", func(t *testing.T) { + models := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + model, _ := body["model"].(string) + models <- model + _, _ = w.Write([]byte(`{"content":[{"text":"ok"}]}`)) + })) + defer upstream.Close() + + h := newEndpointTestHandler(t) + got, err := h.sendTestRequest(&storage.Endpoint{ + Name: "claude", APIUrl: upstream.URL, APIKey: "secret", + AuthMode: config.AuthModeAPIKey, Transformer: "claude", Model: "claude-test", + }) + if err != nil { + t.Fatalf("send test request: %v", err) + } + if model := <-models; model != "claude-test" { + t.Fatalf("expected configured Claude model, got %q", model) + } + if got != "ok" { + t.Fatalf("unexpected response text %q", got) + } + }) + + t.Run("gemini header authentication", func(t *testing.T) { + type capture struct { + path string + queryKey string + header string + } + requests := make(chan capture, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- capture{ + path: r.URL.Path, + queryKey: r.URL.Query().Get("key"), + header: r.Header.Get("x-goog-api-key"), + } + _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}`)) + })) + defer upstream.Close() + + h := newEndpointTestHandler(t) + got, err := h.sendTestRequest(&storage.Endpoint{ + Name: "gemini", APIUrl: upstream.URL, APIKey: "top-secret", + AuthMode: config.AuthModeAPIKey, Transformer: "gemini", Model: "gemini-test", + }) + if err != nil { + t.Fatalf("send test request: %v", err) + } + request := <-requests + if request.path != "/v1beta/models/gemini-test:generateContent" || request.header != "top-secret" || request.queryKey != "" { + t.Fatalf("unexpected Gemini request: %+v", request) + } + if got != "ok" { + t.Fatalf("unexpected response text %q", got) + } + }) +} + +func TestSendTestRequestRequiresConfiguredModel(t *testing.T) { + requests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + _, _ = w.Write([]byte(`{"content":[{"text":"unexpected"}]}`)) + })) + defer upstream.Close() + + _, err := newEndpointTestHandler(t).sendTestRequest(&storage.Endpoint{ + Name: "claude", APIUrl: upstream.URL, APIKey: "secret", + AuthMode: config.AuthModeAPIKey, Transformer: "claude", Model: " ", + }) + if err == nil || !strings.Contains(err.Error(), "model is required") { + t.Fatalf("expected missing model error, got %v", err) + } + if requests != 0 { + t.Fatalf("missing model must not send an upstream request, got %d", requests) + } +} + +func TestEndpointTestUsesTemporaryModelWithoutSaving(t *testing.T) { + models := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + models <- body["model"].(string) + _, _ = w.Write([]byte(`{"content":[{"text":"ok"}]}`)) + })) + defer upstream.Close() + + h := newEndpointTestHandler(t) + saveEndpointForTest(t, h, storage.Endpoint{ + Name: "temporary-model", APIUrl: upstream.URL, APIKey: "secret", + AuthMode: config.AuthModeAPIKey, Transformer: "claude", + }) + + rec := httptest.NewRecorder() + h.testEndpoint(rec, httptest.NewRequest(http.MethodPost, "/api/endpoints/temporary-model/test", strings.NewReader(`{"model":"claude-test-only"}`)), "temporary-model") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"success":true`) { + t.Fatalf("temporary-model test failed: status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := <-models; got != "claude-test-only" { + t.Fatalf("test model=%q, want claude-test-only", got) + } + endpoints, err := h.storage.GetEndpoints() + if err != nil || len(endpoints) != 1 { + t.Fatalf("reload endpoints: count=%d err=%v", len(endpoints), err) + } + if endpoints[0].Model != "" { + t.Fatalf("temporary test model was persisted: %q", endpoints[0].Model) + } +} + +func TestSendTestRequestRejectsUnexpectedSuccessBodies(t *testing.T) { + const secret = "top-secret" + tests := []struct { + name string + body string + wantError string + }{ + {name: "invalid JSON", body: secret, wantError: "failed to parse response"}, + {name: "missing content", body: `{"echo":"` + secret + `"}`, wantError: "missing expected response content"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(tt.body)) + })) + defer upstream.Close() + + got, err := newEndpointTestHandler(t).sendTestRequest(&storage.Endpoint{ + Name: "openai", APIUrl: upstream.URL, APIKey: "auth-key", + AuthMode: config.AuthModeAPIKey, Transformer: "openai", Model: "gpt-test", + }) + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected %q error, got response=%q error=%v", tt.wantError, got, err) + } + if strings.Contains(got, secret) || strings.Contains(err.Error(), secret) { + t.Fatalf("response leaked provider body: response=%q error=%v", got, err) + } + }) + } +} + +func TestFetchModelsFromProviderProtocols(t *testing.T) { + tests := []struct { + name string + transformer string + response string + baseSuffix string + wantPath string + wantHeader string + wantModels []string + }{ + { + name: "claude", + transformer: "claude", + response: `{"data":[{"id":"claude-a"},{"id":"claude-b"}]}`, + baseSuffix: "/v1", + wantPath: "/v1/models", + wantHeader: "x-api-key", + wantModels: []string{"claude-a", "claude-b"}, + }, + { + name: "gemini", + transformer: "gemini", + response: `{"models":[{"name":"models/gemini-a"},{"name":"gemini-b"}]}`, + baseSuffix: "/v1beta", + wantPath: "/v1beta/models", + wantHeader: "x-goog-api-key", + wantModels: []string{"gemini-a", "gemini-b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + type capture struct { + path string + rawQuery string + authHeader string + } + requests := make(chan capture, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- capture{ + path: r.URL.Path, + rawQuery: r.URL.RawQuery, + authHeader: r.Header.Get(tt.wantHeader), + } + _, _ = w.Write([]byte(tt.response)) + })) + defer upstream.Close() + + models, err := newEndpointTestHandler(t).fetchModelsFromProvider(upstream.URL+tt.baseSuffix, "top-secret", tt.transformer) + if err != nil { + t.Fatalf("fetch models: %v", err) + } + var request capture + select { + case request = <-requests: + default: + t.Fatal("provider did not receive request") + } + if request.path != tt.wantPath || request.rawQuery != "" || request.authHeader != "top-secret" { + t.Fatalf("unexpected provider request: %+v", request) + } + if strings.Join(models, ",") != strings.Join(tt.wantModels, ",") { + t.Fatalf("unexpected models: %v", models) + } + }) + } +} + +func TestFetchModelsErrorsRedactKnownSecrets(t *testing.T) { + const apiKey = "top-secret" + t.Run("provider body", func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("denied " + apiKey)) + })) + defer upstream.Close() + + _, err := newEndpointTestHandler(t).fetchModelsFromProvider(upstream.URL, apiKey, "openai") + if err == nil || strings.Contains(err.Error(), apiKey) { + t.Fatalf("expected redacted provider error, got %v", err) + } + }) + + t.Run("client error", func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + apiURL := strings.Replace(upstream.URL, "http://", "http://url-user:url-pass@", 1) + "/" + apiKey + "?access_token=query-secret" + upstream.Close() + + _, err := newEndpointTestHandler(t).fetchModelsFromProvider(apiURL, apiKey, "openai") + if err == nil { + t.Fatal("expected client error") + } + for _, secret := range []string{apiKey, "url-user", "url-pass", "query-secret"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("client error leaked %q: %v", secret, err) + } + } + }) +} + +func TestFetchModelsRequestDoesNotEchoAPIKey(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/endpoints/fetch-models", bytes.NewBufferString(`{"apiUrl":"bad-url","apiKey":"top-secret","transformer":"openai"}`)) + rec := httptest.NewRecorder() + newEndpointTestHandler(t).handleFetchModels(rec, req) + if strings.Contains(rec.Body.String(), "top-secret") { + t.Fatalf("response leaked request key: %s", rec.Body.String()) + } +} diff --git a/cmd/server/webui/api/events.go b/cmd/server/webui/api/events.go index 95cfe71b..df2b4b4d 100644 --- a/cmd/server/webui/api/events.go +++ b/cmd/server/webui/api/events.go @@ -20,7 +20,6 @@ func (h *Handler) handleEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") // Create a flusher flusher, ok := w.(http.Flusher) @@ -49,29 +48,7 @@ func (h *Handler) handleEvents(w http.ResponseWriter, r *http.Request) { logger.Debug("[SSE] Client disconnected") return case <-ticker.C: - // Send stats update - stats := h.proxy.GetStats() - - // Get current endpoint - endpoints := h.config.GetEndpoints() - var currentEndpoint string - if len(endpoints) > 0 { - for _, ep := range endpoints { - if ep.Enabled { - currentEndpoint = ep.Name - break - } - } - } - - event := map[string]interface{}{ - "type": "stats", - "timestamp": time.Now().Unix(), - "stats": stats, - "currentEndpoint": currentEndpoint, - } - - data, err := json.Marshal(event) + data, err := json.Marshal(h.newStatsEvent(time.Now())) if err != nil { logger.Error("[SSE] Failed to marshal event: %v", err) continue @@ -83,3 +60,12 @@ func (h *Handler) handleEvents(w http.ResponseWriter, r *http.Request) { } } } + +func (h *Handler) newStatsEvent(now time.Time) map[string]interface{} { + return map[string]interface{}{ + "type": "stats", + "timestamp": now.Unix(), + "stats": h.statsSummary(), + "currentEndpoint": h.proxy.GetCurrentEndpointName(), + } +} diff --git a/cmd/server/webui/api/events_contract_test.go b/cmd/server/webui/api/events_contract_test.go new file mode 100644 index 00000000..a9242d08 --- /dev/null +++ b/cmd/server/webui/api/events_contract_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/storage" +) + +func TestStatsEventUsesSnapshotAndActualCurrentEndpoint(t *testing.T) { + h := newEndpointTestHandler(t) + h.config.UpdateEndpoints([]config.Endpoint{ + {Name: "first", APIUrl: "https://first.example", Enabled: true}, + {Name: "current", APIUrl: "https://current.example", Enabled: true}, + }) + if err := h.proxy.SetCurrentEndpoint("current"); err != nil { + t.Fatalf("set current endpoint: %v", err) + } + if err := h.storage.RecordDailyStat(&storage.DailyStat{ + EndpointName: "current", + Date: time.Now().Format("2006-01-02"), + Requests: 3, + Errors: 1, + InputTokens: 11, + OutputTokens: 7, + DeviceID: "test", + }); err != nil { + t.Fatalf("record stats: %v", err) + } + + event := h.newStatsEvent(time.Unix(123, 0)) + data, err := json.Marshal(event) + if err != nil { + t.Fatalf("marshal event: %v", err) + } + encoded := string(data) + + if !strings.Contains(encoded, `"currentEndpoint":"current"`) { + t.Fatalf("event did not use proxy current endpoint: %s", encoded) + } + if !strings.Contains(encoded, `"TotalRequests":4`) || !strings.Contains(encoded, `"TotalSuccess":3`) || !strings.Contains(encoded, `"requests":4`) { + t.Fatalf("event did not contain the serializable stats snapshot: %s", encoded) + } + if strings.Contains(encoded, `"lastUsed"`) { + t.Fatalf("event must not fabricate a last-used timestamp: %s", encoded) + } +} + +func TestPeriodStatsIncludeErrorOnlyEndpoints(t *testing.T) { + h := newEndpointTestHandler(t) + today := time.Now().Format("2006-01-02") + if err := h.storage.RecordDailyStat(&storage.DailyStat{ + EndpointName: "failed-only", Date: today, Errors: 2, DeviceID: "test", + }); err != nil { + t.Fatalf("record error stats: %v", err) + } + + stats, err := h.getStatsForPeriod(today, today) + if err != nil { + t.Fatalf("get period stats: %v", err) + } + if stats["totalRequests"] != 2 || stats["totalSuccess"] != 0 || stats["totalErrors"] != 2 { + t.Fatalf("unexpected totals: %+v", stats) + } + endpoints := stats["endpoints"].(map[string]interface{}) + if endpoints["failed-only"] == nil { + t.Fatalf("error-only endpoint missing: %+v", stats) + } +} diff --git a/cmd/server/webui/api/handler.go b/cmd/server/webui/api/handler.go index 09be81d0..9c9af1ce 100644 --- a/cmd/server/webui/api/handler.go +++ b/cmd/server/webui/api/handler.go @@ -3,6 +3,7 @@ package api import ( "net/http" "strings" + "sync" "github.com/lich0821/ccNexus/internal/config" "github.com/lich0821/ccNexus/internal/proxy" @@ -11,10 +12,12 @@ import ( // Handler handles API requests type Handler struct { - config *config.Config - proxy *proxy.Proxy - storage *storage.SQLiteStorage - auth AuthConfig + config *config.Config + proxy *proxy.Proxy + storage *storage.SQLiteStorage + providerHTTPClient *http.Client + configMu sync.Mutex + endpointMu sync.Mutex } // NewHandler creates a new API handler @@ -23,11 +26,6 @@ func NewHandler(cfg *config.Config, p *proxy.Proxy, s *storage.SQLiteStorage) *H config: cfg, proxy: p, storage: s, - auth: AuthConfig{ - Enabled: cfg.BasicAuthEnabled, - Username: cfg.BasicAuthUsername, - Password: cfg.BasicAuthPassword, - }, } } @@ -38,7 +36,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path = "/" + path } - authMiddleware := BasicAuthMiddleware(h.auth) + authMiddleware := BasicAuthMiddleware(h.config) switch path { case "/api/endpoints": @@ -80,4 +78,4 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } http.NotFound(w, r) } -} \ No newline at end of file +} diff --git a/cmd/server/webui/api/middleware.go b/cmd/server/webui/api/middleware.go index 3d04a35e..a01418c9 100644 --- a/cmd/server/webui/api/middleware.go +++ b/cmd/server/webui/api/middleware.go @@ -5,8 +5,10 @@ import ( "encoding/base64" "encoding/json" "net/http" + "net/url" "strings" + "github.com/lich0821/ccNexus/internal/config" "github.com/lich0821/ccNexus/internal/logger" ) @@ -47,9 +49,20 @@ func WriteSuccess(w http.ResponseWriter, data interface{}) { // CORSMiddleware adds CORS headers to responses func CORSMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + origin := r.Header.Get("Origin") + if origin != "" { + originURL, err := url.Parse(origin) + if err != nil || (originURL.Scheme != "http" && originURL.Scheme != "https") || + originURL.Host == "" || !strings.EqualFold(originURL.Host, r.Host) { + WriteError(w, http.StatusForbidden, "Cross-origin requests are not allowed") + return + } + + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Add("Vary", "Origin") + } if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) @@ -81,16 +94,11 @@ func LoggingMiddleware(next http.Handler) http.Handler { }) } -type AuthConfig struct { - Enabled bool - Username string - Password string -} - -func BasicAuthMiddleware(auth AuthConfig) func(http.Handler) http.Handler { +func BasicAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !auth.Enabled { + enabled, expectedUsername, expectedPassword := cfg.GetBasicAuth() + if !enabled { next.ServeHTTP(w, r) return } @@ -128,8 +136,8 @@ func BasicAuthMiddleware(auth AuthConfig) func(http.Handler) http.Handler { username := credentials[:colonIndex] password := credentials[colonIndex+1:] - if subtle.ConstantTimeCompare([]byte(auth.Username), []byte(username)) != 1 || - subtle.ConstantTimeCompare([]byte(auth.Password), []byte(password)) != 1 { + if subtle.ConstantTimeCompare([]byte(expectedUsername), []byte(username)) != 1 || + subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(password)) != 1 { w.Header().Set("WWW-Authenticate", `Basic realm="ccNexus"`) w.WriteHeader(http.StatusUnauthorized) return @@ -138,4 +146,4 @@ func BasicAuthMiddleware(auth AuthConfig) func(http.Handler) http.Handler { next.ServeHTTP(w, r) }) } -} \ No newline at end of file +} diff --git a/cmd/server/webui/api/middleware_test.go b/cmd/server/webui/api/middleware_test.go new file mode 100644 index 00000000..b8698c9e --- /dev/null +++ b/cmd/server/webui/api/middleware_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCORSMiddlewareRejectsCrossOriginRequests(t *testing.T) { + called := false + handler := CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:6677/api/endpoints", nil) + req.Header.Set("Origin", "https://attacker.example") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } + if called { + t.Fatal("cross-origin request reached the API handler") + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got) + } +} + +func TestCORSMiddlewareAllowsSameOriginRequests(t *testing.T) { + handler := CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:6677/api/endpoints", nil) + req.Header.Set("Origin", "http://127.0.0.1:6677") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:6677" { + t.Fatalf("Access-Control-Allow-Origin = %q", got) + } + if got := rec.Header().Get("Vary"); got != "Origin" { + t.Fatalf("Vary = %q, want Origin", got) + } +} + +func TestCORSMiddlewareAllowsRequestsWithoutOrigin(t *testing.T) { + handler := CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:6677/api/endpoints", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got) + } +} diff --git a/cmd/server/webui/api/stats.go b/cmd/server/webui/api/stats.go index e16f86dd..b2b2b53b 100644 --- a/cmd/server/webui/api/stats.go +++ b/cmd/server/webui/api/stats.go @@ -14,26 +14,30 @@ func (h *Handler) handleStatsSummary(w http.ResponseWriter, r *http.Request) { return } - totalRequests, endpointStats := h.proxy.GetStats().GetStats() + WriteSuccess(w, h.statsSummary()) +} - // Calculate totals +func (h *Handler) statsSummary() map[string]interface{} { + totalSuccess, endpointStats := h.proxy.GetStats().GetStats() totalErrors := 0 var totalInputTokens int64 = 0 var totalOutputTokens int64 = 0 for _, stats := range endpointStats { totalErrors += stats.Errors + stats.Requests += stats.Errors totalInputTokens += int64(stats.InputTokens) totalOutputTokens += int64(stats.OutputTokens) } - WriteSuccess(w, map[string]interface{}{ - "TotalRequests": totalRequests, + return map[string]interface{}{ + "TotalRequests": totalSuccess + totalErrors, + "TotalSuccess": totalSuccess, "TotalErrors": totalErrors, "TotalInputTokens": totalInputTokens, "TotalOutputTokens": totalOutputTokens, "Endpoints": endpointStats, - }) + } } // handleStatsDaily returns today's statistics @@ -181,27 +185,29 @@ func (h *Handler) getStatsForPeriod(startDate, endDate string) (map[string]inter } totalRequests := 0 + totalSuccess := 0 totalErrors := 0 var totalInputTokens int64 = 0 var totalOutputTokens int64 = 0 endpointStats := make(map[string]interface{}) for endpointName, stats := range allStats { - epRequests := 0 + epSuccess := 0 epErrors := 0 var epInputTokens int64 = 0 var epOutputTokens int64 = 0 for _, stat := range stats { if stat.Date >= startDate && stat.Date <= endDate { - epRequests += stat.Requests + epSuccess += stat.Requests epErrors += stat.Errors epInputTokens += int64(stat.InputTokens) epOutputTokens += int64(stat.OutputTokens) } } - if epRequests > 0 { + epRequests := epSuccess + epErrors + if epRequests > 0 || epInputTokens > 0 || epOutputTokens > 0 { endpointStats[endpointName] = map[string]interface{}{ "requests": epRequests, "errors": epErrors, @@ -211,6 +217,7 @@ func (h *Handler) getStatsForPeriod(startDate, endDate string) (map[string]inter totalRequests += epRequests totalErrors += epErrors + totalSuccess += epSuccess totalInputTokens += epInputTokens totalOutputTokens += epOutputTokens } @@ -219,7 +226,7 @@ func (h *Handler) getStatsForPeriod(startDate, endDate string) (map[string]inter return map[string]interface{}{ "totalRequests": totalRequests, "totalErrors": totalErrors, - "totalSuccess": totalRequests - totalErrors, + "totalSuccess": totalSuccess, "totalInputTokens": totalInputTokens, "totalOutputTokens": totalOutputTokens, "endpoints": endpointStats, diff --git a/cmd/server/webui/api/testing.go b/cmd/server/webui/api/testing.go index 689745c8..2e8779a1 100644 --- a/cmd/server/webui/api/testing.go +++ b/cmd/server/webui/api/testing.go @@ -11,6 +11,7 @@ import ( "github.com/lich0821/ccNexus/internal/config" "github.com/lich0821/ccNexus/internal/logger" + "github.com/lich0821/ccNexus/internal/proxy" "github.com/lich0821/ccNexus/internal/storage" ) @@ -42,6 +43,17 @@ func (h *Handler) testEndpoint(w http.ResponseWriter, r *http.Request, name stri return } + var request struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil && err != io.EOF { + WriteError(w, http.StatusBadRequest, "Invalid request body") + return + } + if model := strings.TrimSpace(request.Model); model != "" { + endpoint.Model = model + } + // Test the endpoint start := time.Now() response, err := h.sendTestRequest(endpoint) @@ -65,10 +77,22 @@ func (h *Handler) testEndpoint(w http.ResponseWriter, r *http.Request, name stri // sendTestRequest sends a test request to an endpoint func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { - apiKey, authErr := h.resolveEndpointAPIKey(endpoint) + apiKey, credential, authErr := h.resolveEndpointAuth(endpoint) if authErr != nil { return "", authErr } + model := strings.TrimSpace(endpoint.Model) + if model == "" { + return "", fmt.Errorf("model is required to test endpoint") + } + codexTokenPool := config.NormalizeAuthMode(endpoint.AuthMode) == config.AuthModeCodexTokenPool + redact := func(message string) string { + secrets := []string{apiKey, endpoint.APIKey} + if credential != nil { + secrets = append(secrets, credential.AccessToken, credential.RefreshToken, credential.IDToken) + } + return proxy.RedactProviderMessage(message, endpoint.APIUrl, secrets...) + } var reqBody []byte var url string @@ -76,9 +100,9 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { switch endpoint.Transformer { case "claude": - url = fmt.Sprintf("%s/v1/messages", endpoint.APIUrl) + url = providerURL(endpoint.APIUrl, "/v1/messages") reqBody, err = json.Marshal(map[string]interface{}{ - "model": "claude-3-5-sonnet-20241022", + "model": model, "messages": []map[string]interface{}{ { "role": "user", @@ -87,12 +111,8 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { }, "max_tokens": 16, }) - case "openai", "openai2": - url = fmt.Sprintf("%s/v1/chat/completions", endpoint.APIUrl) - model := endpoint.Model - if model == "" { - model = "gpt-4" - } + case "openai": + url = providerURL(endpoint.APIUrl, "/v1/chat/completions") reqBody, err = json.Marshal(map[string]interface{}{ "model": model, "messages": []map[string]interface{}{ @@ -103,12 +123,23 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { }, "max_tokens": 16, }) - case "gemini": - model := endpoint.Model - if model == "" { - model = "gemini-pro" + case "openai2": + requestPath := "/v1/responses" + payload := map[string]interface{}{ + "model": model, + "input": "你是什么模型?", + "max_output_tokens": 16, + } + if codexTokenPool { + requestPath = "/responses" + payload["instructions"] = "" + payload["store"] = false + payload["stream"] = true } - url = fmt.Sprintf("%s/v1beta/models/%s:generateContent", endpoint.APIUrl, model) + url = providerURL(endpoint.APIUrl, requestPath) + reqBody, err = json.Marshal(payload) + case "gemini": + url = providerURL(endpoint.APIUrl, fmt.Sprintf("/v1beta/models/%s:generateContent", model)) reqBody, err = json.Marshal(map[string]interface{}{ "contents": []map[string]interface{}{ { @@ -130,7 +161,7 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody)) if err != nil { - return "", fmt.Errorf("failed to create request: %v", err) + return "", fmt.Errorf("failed to create request: %s", redact(err.Error())) } req.Header.Set("Content-Type", "application/json") @@ -143,19 +174,13 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { case "openai", "openai2": req.Header.Set("Authorization", "Bearer "+apiKey) case "gemini": - // Gemini uses API key in URL query parameter - q := req.URL.Query() - q.Add("key", apiKey) - req.URL.RawQuery = q.Encode() + req.Header.Set("x-goog-api-key", apiKey) } + proxy.ApplyCodexCredentialHeaders(req, credential, reqBody) - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := h.providerClient(30 * time.Second).Do(req) if err != nil { - return "", fmt.Errorf("request failed: %v", err) + return "", fmt.Errorf("request failed: %s", redact(err.Error())) } defer resp.Body.Close() @@ -165,13 +190,22 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, redact(string(body))) + } + + if endpoint.Transformer == "openai2" && + (codexTokenPool || strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream")) { + text, err := extractResponsesSSEText(body) + if err != nil { + return "", err + } + return redact(text), nil } // Parse response to extract the actual message var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { - return string(body), nil + return "", fmt.Errorf("failed to parse response: %w", err) } // Extract message based on transformer @@ -180,20 +214,24 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { if content, ok := result["content"].([]interface{}); ok && len(content) > 0 { if block, ok := content[0].(map[string]interface{}); ok { if text, ok := block["text"].(string); ok { - return text, nil + return redact(text), nil } } } - case "openai", "openai2": + case "openai": if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 { if choice, ok := choices[0].(map[string]interface{}); ok { if message, ok := choice["message"].(map[string]interface{}); ok { if content, ok := message["content"].(string); ok { - return content, nil + return redact(content), nil } } } } + case "openai2": + if text := extractResponsesText(result); text != "" { + return redact(text), nil + } case "gemini": if candidates, ok := result["candidates"].([]interface{}); ok && len(candidates) > 0 { if candidate, ok := candidates[0].(map[string]interface{}); ok { @@ -201,7 +239,7 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { if parts, ok := content["parts"].([]interface{}); ok && len(parts) > 0 { if part, ok := parts[0].(map[string]interface{}); ok { if text, ok := part["text"].(string); ok { - return text, nil + return redact(text), nil } } } @@ -210,27 +248,32 @@ func (h *Handler) sendTestRequest(endpoint *storage.Endpoint) (string, error) { } } - return string(body), nil + return "", fmt.Errorf("missing expected response content") } func (h *Handler) resolveEndpointAPIKey(endpoint *storage.Endpoint) (string, error) { + apiKey, _, err := h.resolveEndpointAuth(endpoint) + return apiKey, err +} + +func (h *Handler) resolveEndpointAuth(endpoint *storage.Endpoint) (string, *storage.EndpointCredential, error) { authMode := config.NormalizeAuthMode(endpoint.AuthMode) if config.IsTokenPoolAuthMode(authMode) { cred, err := h.storage.GetUsableEndpointCredential(endpoint.Name, time.Now().UTC()) if err != nil { - return "", fmt.Errorf("failed to get token from pool: %w", err) + return "", nil, fmt.Errorf("failed to get token from pool: %w", err) } if cred == nil || strings.TrimSpace(cred.AccessToken) == "" { - return "", fmt.Errorf("no usable token in token pool") + return "", nil, fmt.Errorf("no usable token in token pool") } - return strings.TrimSpace(cred.AccessToken), nil + return strings.TrimSpace(cred.AccessToken), cred, nil } apiKey := strings.TrimSpace(endpoint.APIKey) if apiKey == "" { - return "", fmt.Errorf("apiKey is empty") + return "", nil, fmt.Errorf("apiKey is empty") } - return apiKey, nil + return apiKey, nil, nil } // handleFetchModels fetches available models from a provider @@ -241,9 +284,10 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { } var req struct { - APIUrl string `json:"apiUrl"` - APIKey string `json:"apiKey"` - Transformer string `json:"transformer"` + EndpointName string `json:"endpointName"` + APIUrl string `json:"apiUrl"` + APIKey string `json:"apiKey"` + Transformer string `json:"transformer"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -251,7 +295,42 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { return } - models, err := h.fetchModelsFromProvider(req.APIUrl, req.APIKey, req.Transformer) + apiURL := req.APIUrl + apiKey := req.APIKey + transformer := req.Transformer + if endpointName := strings.TrimSpace(req.EndpointName); endpointName != "" { + endpoints, err := h.storage.GetEndpoints() + if err != nil { + logger.Error("Failed to get endpoints: %v", err) + WriteError(w, http.StatusInternalServerError, "Failed to get endpoints") + return + } + var endpoint *storage.Endpoint + for i := range endpoints { + if endpoints[i].Name == endpointName { + endpoint = &endpoints[i] + break + } + } + if endpoint == nil { + WriteError(w, http.StatusNotFound, "Endpoint not found") + return + } + apiURL = endpoint.APIUrl + transformer = endpoint.Transformer + resolvedAPIKey, err := h.resolveEndpointAPIKey(endpoint) + if err != nil { + WriteError(w, http.StatusBadRequest, err.Error()) + return + } + apiKey = resolvedAPIKey + } + if !isSupportedEndpointTransformer(transformer) { + WriteError(w, http.StatusBadRequest, fmt.Sprintf("unsupported transformer: %s", transformer)) + return + } + + models, err := h.fetchModelsFromProvider(apiURL, apiKey, transformer) if err != nil { logger.Error("Failed to fetch models: %v", err) WriteError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to fetch models: %v", err)) @@ -265,53 +344,70 @@ func (h *Handler) handleFetchModels(w http.ResponseWriter, r *http.Request) { // fetchModelsFromProvider fetches available models from a provider func (h *Handler) fetchModelsFromProvider(apiUrl, apiKey, transformer string) ([]string, error) { - var url string - var authHeader string + normalizedAPIURL := normalizeAPIUrl(strings.TrimSpace(apiUrl)) + redact := func(message string) string { + return proxy.RedactProviderMessage(message, normalizedAPIURL, apiKey) + } + codexAPIURL := strings.TrimSuffix(normalizedAPIURL, "/v1") + if transformer == "openai2" && strings.EqualFold(codexAPIURL, config.CodexTokenPoolAPIURL) { + return nil, fmt.Errorf("models are unavailable for Codex token pool endpoints") + } + var modelsPath string switch transformer { - case "openai", "openai2": - url = fmt.Sprintf("%s/v1/models", apiUrl) - authHeader = "Bearer " + apiKey - case "claude": - // Claude doesn't have a models endpoint, return known models - return []string{ - "claude-3-5-sonnet-20241022", - "claude-3-5-haiku-20241022", - "claude-3-opus-20240229", - "claude-3-sonnet-20240229", - "claude-3-haiku-20240307", - }, nil + case "claude", "openai", "openai2": + modelsPath = "/v1/models" case "gemini": - // Gemini models are typically known - return []string{ - "gemini-pro", - "gemini-pro-vision", - "gemini-ultra", - }, nil + modelsPath = "/v1beta/models" default: return nil, fmt.Errorf("unsupported transformer: %s", transformer) } + url := providerURL(normalizedAPIURL, modelsPath) req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create request: %s", redact(err.Error())) } - req.Header.Set("Authorization", authHeader) - - client := &http.Client{ - Timeout: 10 * time.Second, + req.Header.Set("Accept", "application/json") + switch transformer { + case "claude": + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + case "openai", "openai2": + req.Header.Set("Authorization", "Bearer "+apiKey) + case "gemini": + req.Header.Set("x-goog-api-key", apiKey) } - resp, err := client.Do(req) + resp, err := h.providerClient(10 * time.Second).Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("request failed: %s", redact(err.Error())) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, redact(string(body))) + } + + if transformer == "gemini" { + var result struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + models := make([]string, 0, len(result.Models)) + for _, model := range result.Models { + name := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(model.Name), "models/")) + if name != "" { + models = append(models, name) + } + } + return models, nil } var result struct { @@ -319,15 +415,107 @@ func (h *Handler) fetchModelsFromProvider(apiUrl, apiKey, transformer string) ([ ID string `json:"id"` } `json:"data"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse response: %w", err) } - models := make([]string, 0, len(result.Data)) for _, model := range result.Data { - models = append(models, model.ID) + if id := strings.TrimSpace(model.ID); id != "" { + models = append(models, id) + } } - return models, nil } + +func providerURL(apiURL, requestPath string) string { + apiURL = strings.TrimRight(normalizeAPIUrl(apiURL), "/") + for _, versionPath := range []string{"/v1", "/v1beta"} { + if strings.HasPrefix(requestPath, versionPath+"/") && strings.HasSuffix(apiURL, versionPath) { + return apiURL + strings.TrimPrefix(requestPath, versionPath) + } + } + return apiURL + requestPath +} + +func redactAPIKey(message, apiKey string) string { + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return message + } + return strings.ReplaceAll(message, apiKey, "[REDACTED]") +} + +func (h *Handler) providerClient(timeout time.Duration) *http.Client { + if h.providerHTTPClient != nil { + return h.providerHTTPClient + } + return &http.Client{Timeout: timeout} +} + +func extractResponsesSSEText(body []byte) (string, error) { + var deltas strings.Builder + completedText := "" + completed := false + for _, rawLine := range bytes.Split(body, []byte("\n")) { + line := strings.TrimSpace(string(rawLine)) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + continue + } + + var event map[string]interface{} + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + switch event["type"] { + case "response.output_text.delta": + if delta, ok := event["delta"].(string); ok { + deltas.WriteString(delta) + } + case "response.completed": + completed = true + if response, ok := event["response"].(map[string]interface{}); ok { + completedText = extractResponsesText(response) + } + case "response.failed", "error": + return "", fmt.Errorf("test response received %s", event["type"]) + } + } + if !completed { + return "", fmt.Errorf("test response ended before response.completed") + } + if completedText != "" { + return completedText, nil + } + if deltas.Len() > 0 { + return deltas.String(), nil + } + return "", fmt.Errorf("missing expected response content") +} + +func extractResponsesText(result map[string]interface{}) string { + if text, ok := result["output_text"].(string); ok { + return text + } + output, _ := result["output"].([]interface{}) + for _, item := range output { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + content, _ := itemMap["content"].([]interface{}) + for _, block := range content { + blockMap, ok := block.(map[string]interface{}) + if !ok { + continue + } + if text, ok := blockMap["text"].(string); ok { + return text + } + } + } + return "" +} diff --git a/cmd/server/webui/api/testing_codex_contract_test.go b/cmd/server/webui/api/testing_codex_contract_test.go new file mode 100644 index 00000000..bc6e4f17 --- /dev/null +++ b/cmd/server/webui/api/testing_codex_contract_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/lich0821/ccNexus/internal/config" + "github.com/lich0821/ccNexus/internal/storage" +) + +type providerRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f providerRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestSendTestRequestSupportsCodexTokenPoolProtocol(t *testing.T) { + h := newEndpointTestHandler(t) + saveCredentialForTest(t, h, "codex-pool") + + var requestURL string + var requestHeaders http.Header + var requestBody map[string]interface{} + h.providerHTTPClient = &http.Client{Transport: providerRoundTripFunc(func(req *http.Request) (*http.Response, error) { + requestURL = req.URL.String() + requestHeaders = req.Header.Clone() + if err := json.NewDecoder(req.Body).Decode(&requestBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + body := strings.Join([]string{ + `data: {"type":"response.output_text.delta","delta":"codex-ok"}`, + `data: {"type":"response.completed","response":{"output":[{"content":[{"type":"output_text","text":"codex-ok"}]}]}}`, + `data: [DONE]`, + "", + }, "\n") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + + got, err := h.sendTestRequest(&storage.Endpoint{ + Name: "codex-pool", + APIUrl: config.CodexTokenPoolAPIURL, + AuthMode: config.AuthModeCodexTokenPool, + Transformer: config.CodexTokenPoolTransformer, + Model: "gpt-test", + }) + if err != nil { + t.Fatalf("send Codex token pool test request: %v", err) + } + if got != "codex-ok" { + t.Fatalf("response text = %q, want codex-ok", got) + } + if requestURL != config.CodexTokenPoolAPIURL+"/responses" { + t.Fatalf("request URL = %q", requestURL) + } + if requestHeaders.Get("Authorization") != "Bearer access-secret" || + requestHeaders.Get("Chatgpt-Account-Id") != "account-1" || + requestHeaders.Get("Originator") != "codex_cli_rs" || + requestHeaders.Get("Accept") != "text/event-stream" { + t.Fatalf("incomplete Codex headers: %v", requestHeaders) + } + if requestBody["stream"] != true || requestBody["store"] != false { + t.Fatalf("Codex request flags = %v", requestBody) + } + if _, ok := requestBody["instructions"]; !ok { + t.Fatalf("Codex request omitted instructions: %v", requestBody) + } +} + +func TestExtractResponsesSSETextRequiresCompletedEvent(t *testing.T) { + _, err := extractResponsesSSEText([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n")) + if err == nil || !strings.Contains(err.Error(), "before response.completed") { + t.Fatalf("incomplete stream error = %v", err) + } +} + +func TestExtractResponsesSSETextReportsFailureEvents(t *testing.T) { + for _, eventType := range []string{"response.failed", "error"} { + t.Run(eventType, func(t *testing.T) { + body := `data: {"type":"` + eventType + `","error":{"message":"provider detail"}}` + "\n\n" + _, err := extractResponsesSSEText([]byte(body)) + if err == nil || !strings.Contains(err.Error(), eventType) { + t.Fatalf("failure event error = %v", err) + } + }) + } +} diff --git a/cmd/server/webui/ui/css/components.css b/cmd/server/webui/ui/css/components.css index fbc3faa4..ef0de10d 100644 --- a/cmd/server/webui/ui/css/components.css +++ b/cmd/server/webui/ui/css/components.css @@ -1,427 +1,1111 @@ -/* Cards */ .card { - background-color: var(--bg-primary); - border-radius: 0.5rem; - padding: 1.5rem; - box-shadow: var(--shadow); - border: 1px solid var(--border-color); + max-width: 1440px; + margin: 0 auto 20px; + padding: 25px; + border-radius: 12px; + background: var(--bg-primary); + box-shadow: var(--shadow-card); } -.card-header { - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-color); +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 15px; } -.card-title { - font-size: 1.125rem; - font-weight: 600; +.section-header h2 { + display: flex; + align-items: center; + gap: 8px; margin: 0; + color: var(--text-primary); + font-size: 18px; + line-height: 1.3; } -.card-body { - padding: 0; +.stats-tabs, +.endpoint-view-tabs { + display: inline-flex; + align-items: center; + gap: 4px; } -/* Stats cards */ -.stat-card { - background-color: var(--bg-primary); - border-radius: 0.5rem; - padding: 1.5rem; - box-shadow: var(--shadow); - border: 1px solid var(--border-color); +.stats-tab-btn { + min-height: 36px; + padding: 8px 16px; + border: 2px solid transparent; + border-radius: 8px; + color: var(--text-secondary); + background: var(--bg-secondary); + font-size: 13px; + font-weight: 500; + transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.stats-tab-btn:hover { + background: var(--bg-tertiary); + transform: translateY(-1px); +} + +.stats-tab-btn.active { + color: #ffffff; + background: linear-gradient(135deg, var(--bg-gradient-start), var(--bg-gradient-end)); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 15px; +} + +.stat-box { + position: relative; + min-width: 0; + min-height: 136px; + padding: 20px; + border-radius: 8px; + color: #ffffff; + background: linear-gradient(135deg, var(--bg-gradient-start), var(--bg-gradient-end)); + text-align: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.stat-box:hover { + box-shadow: 0 6px 16px rgba(55, 48, 120, 0.25); + transform: translateY(-2px); } .stat-label { - font-size: 0.875rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; + margin-bottom: 8px; + font-size: 13px; + opacity: 0.95; } .stat-value { - font-size: 2rem; + min-width: 0; + margin: 8px 0; + font-size: 32px; font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.stat-secondary { + color: rgba(255, 255, 255, 0.68); + font-size: 19px; +} + +.stat-detail { + color: rgba(255, 255, 255, 0.78); + font-size: 13px; +} + +.stats-details { + margin-top: 16px; + border-top: 1px solid var(--border-light); +} + +.stats-details summary { + min-height: 44px; + padding: 12px 4px 8px; + color: var(--text-secondary); + font-weight: 600; +} + +.stats-details[open] summary { + color: var(--primary-color); +} + +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 136px; + color: var(--text-secondary); +} + +.endpoint-section-header { + align-items: flex-start; +} + +.endpoint-section-header > .btn { + flex: 0 0 auto; + white-space: nowrap; +} + +.endpoint-heading-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 0; +} + +.endpoint-collapse-btn { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 32px; + padding: 4px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + background: transparent; + font-size: 13px; +} + +.endpoint-collapse-btn:hover { color: var(--text-primary); + background: var(--bg-secondary); } -.stat-change { - font-size: 0.875rem; - margin-top: 0.5rem; +.endpoint-view-tabs { + padding: 2px; + border: 1px solid var(--border-color); + border-radius: 6px; } -.stat-change.positive { - color: var(--success-color); +.endpoint-view-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 30px; + border: 0; + border-radius: 4px; + color: var(--text-secondary); + background: transparent; + font-size: 16px; } -.stat-change.negative { - color: var(--danger-color); +.endpoint-view-btn:hover { + color: var(--primary-color); + background: var(--hover-bg); } -/* Tables */ -.table-container { - overflow-x: auto; +.endpoint-view-btn.active { + color: #ffffff; + background: var(--primary-color); + box-shadow: 0 2px 4px var(--focus-ring); } -.table { - width: 100%; - border-collapse: collapse; +.endpoint-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; } -.table th, -.table td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border-color); +.filter-select { + width: auto; + max-width: 150px; + min-height: 34px; + padding: 5px 28px 5px 9px; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + background-color: var(--input-bg); + font-size: 12px; } -.table th { - font-weight: 600; +.filter-active-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding: 8px 12px; + border: 1px solid var(--warning-color); + border-radius: 6px; + color: var(--warning-text); + background: var(--warning-soft); + font-size: 13px; +} + +[hidden] { + display: none !important; +} + +.endpoint-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px; +} + +.endpoint-card { + display: flex; + min-width: 0; + padding: 18px; + border: 1px solid transparent; + border-radius: 8px; + background: var(--bg-secondary); + transition: border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease, transform 0.2s ease; +} + +.endpoint-card:hover { + box-shadow: 0 4px 12px var(--shadow-color); + transform: translateY(-2px); +} + +.endpoint-card.is-current { + border-color: rgba(102, 126, 234, 0.45); + box-shadow: 0 0 0 2px var(--primary-soft); +} + +.endpoint-card[draggable="true"] .drag-handle { + cursor: grab; +} + +.endpoint-card.is-dragging { + z-index: 2; + opacity: 0.55; + transform: rotate(1deg) scale(1.01); +} + +.endpoint-card.is-drop-target { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-soft); +} + +.endpoint-card-main { + flex: 1; + min-width: 0; +} + +.endpoint-card-title-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 12px; +} + +.drag-handle { + flex: 0 0 auto; + padding: 3px 1px; + color: var(--text-tertiary); + letter-spacing: -3px; + user-select: none; +} + +.endpoint-card-title { + flex: 1; + min-width: 0; +} + +.endpoint-card-title h3 { + margin: 0 0 7px; + color: var(--text-primary); + font-size: 16px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.endpoint-badges { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.reorder-actions { + display: flex; + flex: 0 0 auto; + gap: 4px; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 5px; color: var(--text-secondary); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; + background: var(--bg-primary); } -.table tbody tr:hover { - background-color: var(--bg-secondary); +.icon-btn:hover:not(:disabled) { + color: var(--primary-color); + border-color: var(--primary-color); +} + +.endpoint-card-details { + display: grid; + gap: 7px; + min-width: 0; +} + +.endpoint-detail { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + align-items: start; + gap: 8px; + min-width: 0; + color: var(--text-secondary); + font-size: 13px; +} + +.detail-label { + color: var(--text-tertiary); + font-size: 12px; +} + +.endpoint-detail > :last-child { + min-width: 0; + overflow-wrap: anywhere; +} + +.endpoint-detail-url { + grid-template-columns: 92px minmax(0, 1fr) 32px; + align-items: center; +} + +.endpoint-url { + display: block; + min-width: 0; + color: var(--text-secondary); + background: transparent; + font-family: Monaco, Menlo, "Courier New", monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.endpoint-token-summary { + align-items: start; +} + +.endpoint-card-actions { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: stretch; + gap: 7px; + width: 92px; + margin-left: 16px; +} + +.endpoint-card-actions .btn { + width: 100%; +} + +.endpoint-card-actions .toggle-switch { + align-self: center; + margin: 2px 0; +} + +.endpoint-card-compact { + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} + +.endpoint-card-compact .endpoint-card { + align-items: center; + padding: 11px 14px; +} + +.endpoint-card-compact .endpoint-card-main { + display: grid; + grid-template-columns: minmax(220px, 0.65fr) minmax(280px, 1fr); + align-items: center; + gap: 14px; +} + +.endpoint-card-compact .endpoint-card-title-row { + align-items: center; + margin: 0; +} + +.endpoint-card-compact .endpoint-card-title h3 { + margin-bottom: 4px; + font-size: 14px; +} + +.endpoint-card-compact .endpoint-card-details { + display: flex; + align-items: center; + gap: 12px; + overflow: hidden; +} + +.endpoint-card-compact .endpoint-detail { + display: flex; + flex: 0 1 auto; + min-width: 0; +} + +.endpoint-card-compact .endpoint-detail-url { + flex: 1 1 280px; +} + +.endpoint-card-compact .endpoint-detail:nth-child(2), +.endpoint-card-compact .endpoint-detail:nth-child(5), +.endpoint-card-compact .detail-label, +.endpoint-card-compact .endpoint-detail-url .icon-btn { + display: none; +} + +.endpoint-card-compact .endpoint-card-actions { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + width: auto; + max-width: 390px; + margin-left: 14px; +} + +.endpoint-card-compact .endpoint-card-actions .btn { + width: auto; } -/* Badges */ .badge { display: inline-flex; align-items: center; - padding: 0.25rem 0.75rem; - font-size: 0.75rem; - font-weight: 500; - border-radius: 9999px; + min-height: 22px; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; } .badge-success { - background-color: #d1fae5; - color: #065f46; + color: var(--success-text); + background: var(--success-soft); } .badge-danger { - background-color: #fee2e2; - color: #991b1b; + color: var(--danger-text); + background: var(--danger-soft); } .badge-warning { - background-color: #fef3c7; - color: #92400e; + color: var(--warning-text); + background: var(--warning-soft); } -.badge-info { - background-color: #dbeafe; - color: #1e40af; +.badge-info, +.badge-primary { + color: var(--info-text); + background: var(--info-soft); } -.badge-primary { - background-color: #dbeafe; - color: #1e40af; +.secret-status { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 12px; +} + +.secret-status::before { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--border-color); + content: ""; +} + +.secret-status.is-configured::before { + background: var(--success-color); +} + +.token-pool-summary, +.token-pool-stats { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + font-size: 12px; } -/* Status indicators */ -.status-indicator { +.token-pool-summary div { + flex-basis: 100%; +} + +.toggle-switch { + position: relative; display: inline-block; - width: 8px; - height: 8px; + flex: 0 0 auto; + width: 44px; + height: 24px; +} + +.toggle-switch input { + width: 0; + height: 0; + opacity: 0; +} + +.toggle-slider { + position: absolute; + inset: 0; + border-radius: 24px; + background: var(--border-color); + transition: background-color 0.2s ease; +} + +.toggle-slider::before { + position: absolute; + bottom: 3px; + left: 3px; + width: 18px; + height: 18px; border-radius: 50%; - margin-right: 0.5rem; + background: #ffffff; + content: ""; + transition: transform 0.2s ease; } -.status-indicator.online { - background-color: var(--success-color); +.toggle-switch input:focus-visible + .toggle-slider { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; } -.status-indicator.offline { - background-color: var(--danger-color); +.toggle-switch input:checked + .toggle-slider { + background: var(--primary-color); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +.empty-state { + padding: 42px 16px; + color: var(--text-secondary); + text-align: center; +} + +.empty-state.compact { + padding: 24px 12px; +} + +.empty-state-icon { + margin-bottom: 8px; + color: var(--primary-color); + font-size: 42px; + line-height: 1; +} + +.empty-state-title { + margin-bottom: 6px; + color: var(--text-primary); + font-size: 16px; + font-weight: 600; +} + +.empty-state-message { + color: var(--text-secondary); +} + +.table-container { + width: 100%; + margin-top: 8px; + border: 1px solid var(--border-light); + border-radius: 8px; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + background: var(--bg-primary); +} + +.table th, +.table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + text-align: left; + vertical-align: middle; +} + +.table th { + color: var(--text-secondary); + background: var(--bg-secondary); + font-size: 12px; + font-weight: 600; +} + +.table tr:last-child td { + border-bottom: 0; +} + +.table tbody tr:hover { + background: var(--hover-bg); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; } -/* Modals */ .modal-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); + inset: 0; + z-index: 1000; display: flex; align-items: center; justify-content: center; - z-index: 1000; - animation: fadeIn 0.2s; + padding: 18px; + background: rgba(0, 0, 0, 0.55); + animation: fade-in 0.18s ease; +} + +.modal-overlay--nested { + z-index: 1001; } .modal { - background-color: var(--bg-primary); - border-radius: 0.5rem; - box-shadow: var(--shadow-lg); - max-width: 600px; - width: 90%; - max-height: 90vh; + width: min(100%, 600px); + max-height: calc(100dvh - 36px); + border-radius: 12px; + background: var(--bg-primary); + box-shadow: var(--shadow-modal); overflow-y: auto; - animation: slideUp 0.3s; + animation: modal-up 0.22s ease; +} + +.modal--wide { + width: min(100%, 760px); +} + +.modal--compact { + width: min(100%, 500px); +} + +.settings-modal { + width: min(100%, 560px); } .modal-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); + position: relative; display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-light); +} + +.modal-header::after { + position: absolute; + right: 24px; + bottom: -1px; + left: 24px; + height: 2px; + border-radius: 2px; + background: linear-gradient(90deg, var(--primary-color), transparent); + content: ""; } .modal-title { - font-size: 1.25rem; - font-weight: 600; margin: 0; + color: var(--text-primary); + font-size: 19px; + line-height: 1.3; } .modal-close { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: var(--text-secondary); - padding: 0; - width: 2rem; - height: 2rem; - display: flex; + display: inline-flex; align-items: center; justify-content: center; - border-radius: 0.25rem; + flex: 0 0 auto; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 8px; + color: var(--text-secondary); + background: var(--bg-tertiary); + font-size: 24px; } .modal-close:hover { - background-color: var(--bg-secondary); + color: var(--text-primary); + background: var(--hover-bg); } .modal-body { - padding: 1.5rem; + padding: 22px 24px; } .modal-footer { - padding: 1.5rem; - border-top: 1px solid var(--border-color); display: flex; + flex-wrap: wrap; justify-content: flex-end; - gap: 0.75rem; + gap: 10px; + padding: 16px 24px 20px; + border-top: 1px solid var(--border-light); } -/* Toast notifications */ -#toast-container { - position: fixed; - top: 1rem; - right: 1rem; - z-index: 2000; - display: flex; - flex-direction: column; - gap: 0.75rem; +body.modal-open { + overflow: hidden; } -.toast { - background-color: var(--bg-primary); - border-radius: 0.5rem; - box-shadow: var(--shadow-lg); - padding: 1rem 1.5rem; - min-width: 300px; +.dialog-message { + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.model-picker-row { display: flex; - align-items: center; - gap: 0.75rem; - animation: slideInRight 0.3s; - border-left: 4px solid var(--primary-color); + gap: 8px; +} + +.model-picker-input { + flex: 1; + min-width: 0; } -.toast.success { - border-left-color: var(--success-color); +.model-picker-button { + flex: 0 0 auto; + white-space: nowrap; } -.toast.error { - border-left-color: var(--danger-color); +.model-list { + display: grid; + gap: 7px; + max-height: 400px; + overflow-y: auto; } -.toast.warning { - border-left-color: var(--warning-color); +.model-item { + justify-content: flex-start; + width: 100%; + text-align: left; + overflow-wrap: anywhere; } -.toast-icon { - font-size: 1.5rem; +.token-import-input { + min-height: 140px; } -.toast-content { - flex: 1; +.token-import-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; } -.toast-title { - font-weight: 600; - margin-bottom: 0.25rem; +.credential-enabled-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; + font-size: 12px; } -.toast-message { - font-size: 0.875rem; - color: var(--text-secondary); +.credential-error-cell { + max-width: 230px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.toast-close { - background: none; - border: none; - font-size: 1.25rem; - cursor: pointer; +.test-result-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.inline-alert { + padding: 11px 13px; + border: 1px solid var(--border-color); + border-radius: 6px; color: var(--text-secondary); - padding: 0; + background: var(--bg-secondary); } -/* Loading spinner */ -.spinner { - border: 3px solid var(--bg-tertiary); - border-top-color: var(--primary-color); - border-radius: 50%; - width: 2rem; - height: 2rem; - animation: spin 0.8s linear infinite; +.inline-alert.error { + color: var(--danger-text); + border-color: var(--danger-color); + background: var(--danger-soft); } -.spinner-sm { - width: 1rem; - height: 1rem; - border-width: 2px; +.code-block { + padding: 12px; + border-radius: 6px; + color: var(--code-text); + background: var(--code-bg); + font-family: Monaco, Menlo, "Courier New", monospace; + font-size: 12px; + line-height: 1.55; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; } -/* Empty state */ -.empty-state { - text-align: center; - padding: 3rem 1rem; +.spinner { + width: 28px; + height: 28px; + border: 3px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; } -.empty-state-icon { - font-size: 4rem; - margin-bottom: 1rem; - opacity: 0.5; +#toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 10px; } -.empty-state-title { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.5rem; +.toast { + display: flex; + align-items: center; + gap: 10px; + width: min(360px, calc(100vw - 32px)); + padding: 13px 15px; + border-left: 4px solid var(--info-color); + border-radius: 8px; + background: var(--bg-primary); + box-shadow: var(--shadow-modal); + animation: toast-in 0.2s ease; } -.empty-state-message { +.toast.success { border-left-color: var(--success-color); } +.toast.error { border-left-color: var(--danger-color); } +.toast.warning { border-left-color: var(--warning-color); } +.toast.is-leaving { opacity: 0; transform: translateX(12px); } +.toast-icon { flex: 0 0 auto; font-size: 18px; } +.toast-content { flex: 1; min-width: 0; } +.toast-message { color: var(--text-secondary); overflow-wrap: anywhere; } +.toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; color: var(--text-secondary); - margin-bottom: 1.5rem; + background: transparent; + font-size: 20px; } -/* Animations */ -@keyframes fadeIn { +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(1rem); - } - to { - opacity: 1; - transform: translateY(0); - } +@keyframes modal-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } } -@keyframes slideInRight { - from { - opacity: 0; - transform: translateX(1rem); - } - to { - opacity: 1; - transform: translateX(0); - } +@keyframes toast-in { + from { opacity: 0; transform: translateX(12px); } + to { opacity: 1; transform: translateX(0); } } @keyframes spin { to { transform: rotate(360deg); } } -/* Code block */ -.code-block { - background-color: #1e293b; - color: #e2e8f0; - padding: 1rem; - border-radius: 0.375rem; - overflow-x: auto; - font-family: 'Courier New', monospace; - font-size: 0.875rem; - line-height: 1.5; -} +@media (max-width: 1100px) { + .endpoint-card-compact .endpoint-card-main { + grid-template-columns: minmax(180px, 0.7fr) minmax(220px, 1fr); + } -/* Toggle switch */ -.toggle-switch { - position: relative; - display: inline-block; - width: 44px; - height: 24px; + .endpoint-card-compact .endpoint-card-actions { + max-width: 260px; + } } -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} +@media (max-width: 900px) { + .endpoint-card-grid, + .stats-grid { + grid-template-columns: minmax(0, 1fr); + } -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--bg-tertiary); - transition: 0.3s; - border-radius: 24px; -} + .stat-box { + min-height: 124px; + } -.toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 3px; - background-color: white; - transition: 0.3s; - border-radius: 50%; -} + .endpoint-card-compact .endpoint-card { + align-items: stretch; + } -.toggle-switch input:checked + .toggle-slider { - background-color: var(--primary-color); -} + .endpoint-card-compact .endpoint-card-main { + display: block; + } -.toggle-switch input:checked + .toggle-slider:before { - transform: translateX(20px); + .endpoint-card-compact .endpoint-card-details { + margin-top: 8px; + } } -/* Copy button */ -.btn-icon { - background: none; - border: none; - cursor: pointer; - padding: 0.25rem; - font-size: 1rem; - color: var(--text-secondary); - transition: color 0.2s; - margin-left: 0.5rem; -} +@media (max-width: 700px) { + .card { + padding: 18px; + border-radius: 10px; + } -.btn-icon:hover { - color: var(--primary-color); -} + .section-header, + .endpoint-section-header { + align-items: stretch; + flex-direction: column; + } + + .stats-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .stats-tab-btn { + padding-inline: 8px; + } + + .endpoint-heading-tools { + align-items: flex-start; + } + + .endpoint-heading-tools h2 { + flex-basis: 100%; + } + + .endpoint-filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + } + + .filter-select { + width: 100%; + max-width: none; + min-width: 0; + } + + .endpoint-section-header > .btn { + width: 100%; + } + + .endpoint-card, + .endpoint-card-compact .endpoint-card { + align-items: stretch; + flex-direction: column; + padding: 14px; + } + + .endpoint-card-actions, + .endpoint-card-compact .endpoint-card-actions { + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + width: auto; + max-width: none; + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid var(--border-light); + } + + .endpoint-card-actions .btn, + .endpoint-card-compact .endpoint-card-actions .btn { + flex: 1 1 72px; + width: auto; + min-height: 40px; + } + + .endpoint-card-actions .toggle-switch { + margin: 8px 6px; + } + + .endpoint-card-compact .endpoint-card-details { + display: grid; + gap: 7px; + } -/* Drag and drop styles */ -.table tbody tr[draggable="true"] { - transition: opacity 0.2s, border-top 0.2s; + .endpoint-card-compact .endpoint-detail:nth-child(2), + .endpoint-card-compact .endpoint-detail:nth-child(5), + .endpoint-card-compact .detail-label { + display: initial; + } + + .endpoint-card-compact .endpoint-detail, + .endpoint-card-compact .endpoint-detail-url { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + } + + .endpoint-card-compact .endpoint-detail-url .icon-btn { + display: none; + } + + .modal-overlay { + align-items: flex-end; + padding: 0; + } + + .modal, + .modal--wide, + .modal--compact, + .settings-modal { + width: 100%; + max-width: none; + max-height: calc(100dvh - 12px); + border-radius: 12px 12px 0 0; + } + + .modal-header, + .modal-body, + .modal-footer { + padding-right: 18px; + padding-left: 18px; + } + + .modal-footer .btn { + flex: 1; + min-height: 44px; + } + + .model-picker-row { + align-items: stretch; + flex-direction: column; + } + + #toast-container { + right: 12px; + left: 12px; + } + + .toast { + width: 100%; + } } -.table tbody tr[draggable="true"]:active { - cursor: grabbing; +@media (max-width: 430px) { + .endpoint-filters { + grid-template-columns: minmax(0, 1fr); + } + + .endpoint-card-title-row { + gap: 7px; + } + + .endpoint-detail, + .endpoint-detail-url, + .endpoint-card-compact .endpoint-detail, + .endpoint-card-compact .endpoint-detail-url { + grid-template-columns: 78px minmax(0, 1fr); + } + + .endpoint-detail-url { + grid-template-columns: 78px minmax(0, 1fr) 32px; + } + + .reorder-actions { + flex-direction: column; + } } diff --git a/cmd/server/webui/ui/css/main.css b/cmd/server/webui/ui/css/main.css index 504b234d..76f745e2 100644 --- a/cmd/server/webui/ui/css/main.css +++ b/cmd/server/webui/ui/css/main.css @@ -1,311 +1,441 @@ -/* Reset and base styles */ * { + box-sizing: border-box; margin: 0; padding: 0; - box-sizing: border-box; } :root { - --primary-color: #3b82f6; - --primary-hover: #2563eb; - --success-color: #10b981; - --danger-color: #ef4444; - --warning-color: #f59e0b; + color-scheme: light; + --bg-gradient-start: #667eea; + --bg-gradient-end: #764ba2; --bg-primary: #ffffff; - --bg-secondary: #f3f4f6; - --bg-tertiary: #e5e7eb; - --text-primary: #111827; - --text-secondary: #6b7280; - --border-color: #d1d5db; - --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - --sidebar-width: 250px; - --header-height: 60px; + --bg-secondary: #f8f9fa; + --bg-tertiary: #f3f4f6; + --input-bg: #ffffff; + --hover-bg: #eef0fb; + --text-primary: #333333; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-color: #dddddd; + --border-light: #e8e8e8; + --primary-color: #667eea; + --primary-hover: #5568d3; + --primary-soft: rgba(102, 126, 234, 0.12); + --success-color: #28a745; + --success-soft: #e8f6ec; + --success-text: #176c2d; + --danger-color: #dc3545; + --danger-soft: #fdebec; + --danger-text: #9f1d2a; + --warning-color: #d99a00; + --warning-soft: #fff6db; + --warning-text: #7a5700; + --info-color: #2563eb; + --info-soft: #eaf1ff; + --info-text: #1747a6; + --shadow-color: rgba(0, 0, 0, 0.1); + --shadow-card: 0 4px 6px var(--shadow-color); + --shadow-modal: 0 18px 60px rgba(20, 24, 46, 0.24); + --focus-ring: rgba(102, 126, 234, 0.25); + --code-bg: #f4f5f7; + --code-text: #333333; +} + +html { + min-width: 320px; + background: var(--bg-gradient-end); } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + min-width: 320px; + color: var(--text-primary); + background: linear-gradient(135deg, var(--bg-gradient-start), var(--bg-gradient-end)); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 14px; line-height: 1.5; - color: var(--text-primary); - background-color: var(--bg-secondary); + overflow: hidden; } -#app { - display: flex; - min-height: 100vh; +button, +input, +select, +textarea { + color: inherit; + font: inherit; } -/* Sidebar */ -#sidebar { - width: var(--sidebar-width); - background-color: var(--bg-primary); - border-right: 1px solid var(--border-color); +button, +select, +label[for], +.toggle-switch { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; +} + +#app { display: flex; flex-direction: column; + height: 100dvh; + min-height: 100dvh; +} + +.skip-link { position: fixed; - height: 100vh; - overflow-y: auto; + top: 0.5rem; + left: 0.5rem; + z-index: 3000; + padding: 0.65rem 0.9rem; + border-radius: 6px; + color: #ffffff; + background: #222222; + text-decoration: none; + transform: translateY(-150%); } -.sidebar-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); +.skip-link:focus { + transform: translateY(0); } -.sidebar-header h1 { - font-size: 1.5rem; - font-weight: 700; +.app-header { + z-index: 10; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 82px; + padding: 15px 30px; + background: var(--bg-primary); + box-shadow: 0 2px 10px var(--shadow-color); +} + +.brand-block { + min-width: 0; +} + +.brand-block h1 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 3px; color: var(--primary-color); + font-size: 22px; + line-height: 1.25; } -.sidebar-header .subtitle { - font-size: 0.875rem; +.brand-block p { + margin: 0; color: var(--text-secondary); - margin-top: 0.25rem; + font-size: 13px; } -.nav-menu { - list-style: none; - padding: 1rem 0; - flex: 1; +.header-actions { + display: flex; + align-items: center; + gap: 10px; } -.nav-link { - display: flex; +.header-link { + display: inline-flex; align-items: center; - padding: 0.75rem 1.5rem; - color: var(--text-primary); - text-decoration: none; - transition: all 0.2s; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 8px; + color: var(--primary-color); + background: var(--primary-soft); + font-size: 18px; + transition: background-color 0.2s ease, transform 0.2s ease; } -.nav-link:hover { - background-color: var(--bg-secondary); +.header-link:hover { + background: rgba(102, 126, 234, 0.2); + transform: translateY(-2px); } -.nav-link.active { - background-color: var(--primary-color); - color: white; +.port-display { + display: inline-flex; + align-items: baseline; + gap: 8px; + min-height: 42px; + padding: 8px 16px; + border: 2px solid rgba(102, 126, 234, 0.3); + border-radius: 8px; + color: var(--text-secondary); + background: var(--primary-soft); + transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease; } -.nav-link .icon { - margin-right: 0.75rem; - font-size: 1.25rem; +.port-display:hover { + border-color: rgba(102, 126, 234, 0.55); + background: rgba(102, 126, 234, 0.16); + transform: translateY(-1px); } -.sidebar-footer { - padding: 1rem 1.5rem; - border-top: 1px solid var(--border-color); +.port-number { + color: var(--primary-color); + font-family: Monaco, Menlo, "Courier New", monospace; + font-size: 18px; + font-weight: 700; } -/* Main content */ -#content { - flex: 1; - margin-left: var(--sidebar-width); - padding: 2rem; +.app-container { + flex: 1 1 auto; + min-width: 0; + padding: 30px 30px 13px; + overflow-x: hidden; overflow-y: auto; } -#view-container { - max-width: 1400px; - margin: 0 auto; +.app-footer { + display: grid; + grid-template-columns: 1fr auto 1fr; + flex: 0 0 auto; + align-items: center; + gap: 16px; + min-height: 48px; + padding: 12px 30px; + border-top: 1px solid rgba(102, 126, 234, 0.15); + color: var(--text-secondary); + background: var(--bg-primary); + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); + font-size: 13px; } -/* Typography */ -h1, h2, h3, h4, h5, h6 { - font-weight: 600; - line-height: 1.2; - margin-bottom: 1rem; +.app-footer > :last-child { + justify-self: end; + color: var(--primary-color); + font-family: Monaco, Menlo, "Courier New", monospace; } -h1 { font-size: 2rem; } -h2 { font-size: 1.5rem; } -h3 { font-size: 1.25rem; } -h4 { font-size: 1.125rem; } +.footer-status { + color: var(--success-color); +} -p { - margin-bottom: 1rem; +.footer-status.is-offline { + color: var(--warning-color); } -/* Buttons */ .btn { display: inline-flex; align-items: center; justify-content: center; - padding: 0.5rem 1rem; - font-size: 0.875rem; + min-height: 40px; + padding: 8px 16px; + border: 0; + border-radius: 6px; + font-size: 14px; font-weight: 500; - border-radius: 0.375rem; - border: none; - cursor: pointer; - transition: all 0.2s; text-decoration: none; + transition: background-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; } -.btn-primary { - background-color: var(--primary-color); - color: white; +.btn:hover:not(:disabled) { + transform: translateY(-1px); } -.btn-primary:hover { - background-color: var(--primary-hover); +.btn:disabled, +.icon-btn:disabled { + cursor: not-allowed; + opacity: 0.45; } -.btn-success { - background-color: var(--success-color); - color: white; +.btn-primary { + color: #ffffff; + background: var(--primary-color); } -.btn-danger { - background-color: var(--danger-color); - color: white; +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); } .btn-secondary { - background-color: var(--bg-tertiary); - color: var(--text-primary); + color: #ffffff; + background: #6c757d; } -.btn-icon { - padding: 0.5rem; - background: none; - border: none; - cursor: pointer; - font-size: 1.25rem; - border-radius: 0.375rem; - transition: background-color 0.2s; +.btn-secondary:hover:not(:disabled) { + background: #5a6268; +} + +.btn-danger { + color: #ffffff; + background: var(--danger-color); +} + +.btn-danger:hover:not(:disabled) { + background: #c82333; } -.btn-icon:hover { - background-color: var(--bg-secondary); +.btn-sm, +.btn-card { + min-height: 34px; + padding: 6px 12px; + font-size: 13px; } -.btn-sm { - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; +.btn-link { + min-height: 32px; + padding: 4px 8px; + border: 0; + color: var(--primary-color); + background: transparent; + font-weight: 600; } -/* Forms */ .form-group { - margin-bottom: 1rem; + margin-bottom: 15px; } .form-label { display: block; - margin-bottom: 0.5rem; - font-weight: 500; + margin-bottom: 5px; color: var(--text-primary); + font-weight: 500; } .form-input, .form-select, .form-textarea { width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + min-height: 42px; + padding: 10px; border: 1px solid var(--border-color); - border-radius: 0.375rem; - background-color: var(--bg-primary); + border-radius: 6px; color: var(--text-primary); - transition: border-color 0.2s; + background: var(--input-bg); + transition: border-color 0.18s ease, box-shadow 0.18s ease; } .form-input:focus, .form-select:focus, .form-textarea:focus { - outline: none; border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--focus-ring); + outline: 0; +} + +.form-input[aria-invalid="true"], +.form-select[aria-invalid="true"] { + border-color: var(--danger-color); } .form-textarea { + min-height: 92px; resize: vertical; - min-height: 100px; } -.form-checkbox { - width: auto; - margin-right: 0.5rem; +.form-hint, +.text-muted { + color: var(--text-secondary); + font-size: 12px; } -/* Grid */ -.grid { - display: grid; - gap: 1.5rem; +.form-error { + min-height: 18px; + color: var(--danger-color); + font-size: 12px; } -.grid-cols-2 { - grid-template-columns: repeat(2, 1fr); +.form-check-row { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 44px; } -.grid-cols-3 { - grid-template-columns: repeat(3, 1fr); +.form-checkbox { + width: 16px; + height: 16px; } -.grid-cols-4 { - grid-template-columns: repeat(4, 1fr); +.settings-divider { + height: 1px; + margin: 18px 0 12px; + background: var(--border-light); } -/* Utilities */ -.mt-1 { margin-top: 0.5rem; } -.mt-2 { margin-top: 1rem; } -.mt-3 { margin-top: 1.5rem; } -.mt-4 { margin-top: 2rem; } - -.mb-1 { margin-bottom: 0.5rem; } -.mb-2 { margin-bottom: 1rem; } -.mb-3 { margin-bottom: 1.5rem; } -.mb-4 { margin-bottom: 2rem; } - +.mt-1 { margin-top: 4px; } +.mb-2 { margin-bottom: 8px; } .text-center { text-align: center; } -.text-right { text-align: right; } -.text-sm { font-size: 0.875rem; } -.text-xs { font-size: 0.75rem; } - -.text-muted { color: var(--text-secondary); } +@media (max-width: 700px) { + body { + overflow-y: auto; + } -.flex { - display: flex; -} + #app { + height: auto; + } -.flex-between { - display: flex; - justify-content: space-between; - align-items: center; -} + .app-header { + align-items: flex-start; + min-height: 0; + padding: 14px 16px; + } -.flex-center { - display: flex; - justify-content: center; - align-items: center; -} + .header-actions { + flex-wrap: wrap; + justify-content: flex-end; + max-width: 210px; + } -.gap-2 { gap: 1rem; } -.gap-3 { gap: 1.5rem; } + .port-display { + min-height: 40px; + padding: 6px 10px; + } -/* Responsive */ -@media (max-width: 768px) { - #sidebar { - width: 60px; + .app-container { + padding: 16px; + overflow: visible; } - #content { - margin-left: 60px; + .app-footer { + grid-template-columns: 1fr auto; + padding: 12px 16px; } - .nav-link span:not(.icon) { + .footer-status { display: none; } +} - .sidebar-header h1, - .sidebar-header .subtitle { - display: none; +@media (max-width: 430px) { + .app-header { + flex-direction: column; + gap: 10px; } - .grid-cols-2, - .grid-cols-3, - .grid-cols-4 { - grid-template-columns: 1fr; + .header-actions { + justify-content: flex-start; + width: 100%; + max-width: none; + } + + .port-display { + flex: 1; + justify-content: center; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/cmd/server/webui/ui/css/themes.css b/cmd/server/webui/ui/css/themes.css index be87e446..24bff29c 100644 --- a/cmd/server/webui/ui/css/themes.css +++ b/cmd/server/webui/ui/css/themes.css @@ -1,43 +1,40 @@ -/* Dark theme */ body.dark-theme { - --primary-color: #60a5fa; - --primary-hover: #3b82f6; - --success-color: #34d399; - --danger-color: #f87171; - --warning-color: #fbbf24; - --bg-primary: #1f2937; - --bg-secondary: #111827; - --bg-tertiary: #374151; - --text-primary: #f9fafb; - --text-secondary: #9ca3af; - --border-color: #374151; - --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); + color-scheme: dark; + --bg-gradient-start: #121212; + --bg-gradient-end: #1a1a1a; + --bg-primary: #1e1e1e; + --bg-secondary: #2a2a2a; + --bg-tertiary: #333333; + --input-bg: #333333; + --hover-bg: #2f2f2f; + --text-primary: #e0e0e0; + --text-secondary: #b0b0b0; + --text-tertiary: #808080; + --border-color: #444444; + --border-light: #3a3a3a; + --primary-color: #7c8adb; + --primary-hover: #6a7acf; + --primary-soft: rgba(124, 138, 219, 0.16); + --success-soft: rgba(40, 167, 69, 0.18); + --success-text: #7bd88f; + --danger-soft: rgba(220, 53, 69, 0.2); + --danger-text: #ff9aa5; + --warning-soft: rgba(217, 154, 0, 0.18); + --warning-text: #ffd36d; + --info-soft: rgba(102, 126, 234, 0.18); + --info-text: #aeb9ff; + --shadow-color: rgba(0, 0, 0, 0.3); + --shadow-card: 0 4px 8px rgba(0, 0, 0, 0.28); + --shadow-modal: 0 18px 60px rgba(0, 0, 0, 0.52); + --code-bg: #202020; + --code-text: #d9d9d9; } -/* Dark theme badge adjustments */ -body.dark-theme .badge-success { - background-color: #064e3b; - color: #6ee7b7; +body.dark-theme .btn-secondary { + color: var(--text-primary); + background: #454545; } -body.dark-theme .badge-danger { - background-color: #7f1d1d; - color: #fca5a5; -} - -body.dark-theme .badge-warning { - background-color: #78350f; - color: #fcd34d; -} - -body.dark-theme .badge-info { - background-color: #1e3a8a; - color: #93c5fd; -} - -/* Dark theme code block */ -body.dark-theme .code-block { - background-color: #0f172a; - color: #cbd5e1; +body.dark-theme .btn-secondary:hover:not(:disabled) { + background: #525252; } diff --git a/cmd/server/webui/ui/index.html b/cmd/server/webui/ui/index.html index d772c854..7dc6fa7a 100644 --- a/cmd/server/webui/ui/index.html +++ b/cmd/server/webui/ui/index.html @@ -1,64 +1,52 @@ - + - ccNexus - Admin Dashboard + ccNexus +
- -
-
- -
-
-
- - - + - -
+
+
+
+
- - +
+ © 2025 ccNexus + + Server WebUI +
+ - + +
diff --git a/cmd/server/webui/ui/js/api.js b/cmd/server/webui/ui/js/api.js index 94d89407..804db871 100644 --- a/cmd/server/webui/ui/js/api.js +++ b/cmd/server/webui/ui/js/api.js @@ -1,3 +1,5 @@ +import { t } from './utils/i18n.js'; + // API Client for ccNexus class APIClient { constructor(baseURL = '/api') { @@ -18,15 +20,35 @@ class APIClient { try { const response = await fetch(`${this.baseURL}${path}`, options); - const result = await response.json(); + const responseText = await response.text(); + let result = null; + + if (responseText.trim()) { + try { + result = JSON.parse(responseText); + } catch { + if (response.ok) { + throw new Error(t('errors.invalidJSONResponse')); + } + } + } if (!response.ok) { - throw new Error(result.error || 'Request failed'); + const apiError = result?.error; + const message = (typeof apiError === 'string' ? apiError : apiError?.message) || + result?.message || responseText.trim() || t('errors.requestFailed').replace('{status}', response.status); + throw new Error(message); } - return result.data || result; + if (result && Object.prototype.hasOwnProperty.call(result, 'data')) { + return result.data; + } + return result; } catch (error) { console.error(`API Error [${method} ${path}]:`, error); + if (error instanceof TypeError) { + throw new Error(t('errors.networkError')); + } throw error; } } @@ -52,8 +74,8 @@ class APIClient { return this.request('PATCH', `/endpoints/${encodeURIComponent(name)}/toggle`, { enabled }); } - async testEndpoint(name) { - return this.request('POST', `/endpoints/${encodeURIComponent(name)}/test`); + async testEndpoint(name, model = '') { + return this.request('POST', `/endpoints/${encodeURIComponent(name)}/test`, { model }); } async reorderEndpoints(names) { @@ -68,8 +90,8 @@ class APIClient { return this.request('POST', '/endpoints/switch', { name }); } - async fetchModels(apiUrl, apiKey, transformer) { - return this.request('POST', '/endpoints/fetch-models', { apiUrl, apiKey, transformer }); + async fetchModels({ apiUrl, apiKey, transformer, endpointName }) { + return this.request('POST', '/endpoints/fetch-models', { apiUrl, apiKey, transformer, endpointName }); } async getEndpointCredentials(name) { @@ -133,6 +155,14 @@ class APIClient { async updateLogLevel(logLevel) { return this.request('PUT', '/config/log-level', { logLevel }); } + + async getBasicAuth() { + return this.request('GET', '/config/basic-auth'); + } + + async updateBasicAuth(data) { + return this.request('PUT', '/config/basic-auth', data); + } } export const api = new APIClient(); diff --git a/cmd/server/webui/ui/js/components/dashboard.js b/cmd/server/webui/ui/js/components/dashboard.js deleted file mode 100644 index 13cbba8a..00000000 --- a/cmd/server/webui/ui/js/components/dashboard.js +++ /dev/null @@ -1,184 +0,0 @@ -import { api } from '../api.js'; -import { state } from '../state.js'; -import { notifications } from '../utils/notifications.js'; -import { formatNumber, formatTokens } from '../utils/formatters.js'; -import { t } from '../utils/i18n.js'; - -class Dashboard { - constructor() { - this.container = document.getElementById('view-container'); - // 监听语言切换 - window.addEventListener('languageChanged', () => { - if (state.get('currentView') === 'dashboard') { - this.render(); - } - }); - } - - async render() { - this.container.innerHTML = ` -
-

${t('dashboard.title')}

-
-
-
${t('dashboard.totalRequests')}
-
-
-
-
-
${t('dashboard.successRate')}
-
-
-
-
-
${t('dashboard.inputTokens')}
-
-
-
-
-
${t('dashboard.outputTokens')}
-
-
-
-
- -
-
-
-

${t('dashboard.activeEndpoints')}

-
-
-
-
-
- -
-
-

${t('dashboard.recentActivity')}

-
-
- -
-
-
-
- `; - - await this.loadData(); - } - - async loadData() { - try { - // Load stats - const stats = await api.getStatsSummary(); - this.updateStats(stats); - - // Load endpoints - const endpointsData = await api.getEndpoints(); - this.updateEndpoints(endpointsData.endpoints); - - // Load daily stats for chart - const dailyStats = await api.getStatsDaily(); - this.renderChart(dailyStats); - } catch (error) { - notifications.error('Failed to load dashboard data: ' + error.message); - } - } - - updateStats(stats) { - const totalRequests = stats.TotalRequests || 0; - const totalErrors = stats.TotalErrors || 0; - const successRate = totalRequests > 0 - ? ((totalRequests - totalErrors) / totalRequests * 100).toFixed(1) - : 0; - - document.getElementById('stat-requests').textContent = formatNumber(totalRequests); - document.getElementById('stat-success').textContent = successRate + '%'; - document.getElementById('stat-input-tokens').textContent = formatTokens(stats.TotalInputTokens || 0); - document.getElementById('stat-output-tokens').textContent = formatTokens(stats.TotalOutputTokens || 0); - } - - updateEndpoints(endpoints) { - const container = document.getElementById('endpoints-list'); - - if (!endpoints || endpoints.length === 0) { - container.innerHTML = `

${t('dashboard.noEndpoints')}

`; - return; - } - - const enabledEndpoints = endpoints.filter(ep => ep.enabled); - - if (enabledEndpoints.length === 0) { - container.innerHTML = `

${t('dashboard.noEnabledEndpoints')}

`; - return; - } - - container.innerHTML = ` -
- - - - - - - - - - ${enabledEndpoints.map(ep => ` - - - - - - `).join('')} - -
${t('common.name')}${t('endpoints.transformer')}${t('common.status')}
${this.escapeHtml(ep.name)}${this.escapeHtml(ep.transformer)} - - ${t('common.active')} -
-
- `; - } - - renderChart(dailyStats) { - const canvas = document.getElementById('activity-chart'); - const ctx = canvas.getContext('2d'); - - // Simple bar chart showing requests - const stats = dailyStats.stats || {}; - const endpoints = Object.keys(stats.endpoints || {}); - const requests = endpoints.map(ep => stats.endpoints[ep].requests || 0); - - new Chart(ctx, { - type: 'bar', - data: { - labels: endpoints, - datasets: [{ - label: 'Requests', - data: requests, - backgroundColor: '#3b82f6', - borderColor: '#2563eb', - borderWidth: 1 - }] - }, - options: { - responsive: true, - maintainAspectRatio: true, - plugins: { - legend: { - display: false - } - }, - scales: { - y: { - beginAtZero: true - } - } - } - }); - } - - escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -} - -export const dashboard = new Dashboard(); diff --git a/cmd/server/webui/ui/js/components/endpoints.js b/cmd/server/webui/ui/js/components/endpoints.js index 9ede0722..1f1c1a3a 100644 --- a/cmd/server/webui/ui/js/components/endpoints.js +++ b/cmd/server/webui/ui/js/components/endpoints.js @@ -1,76 +1,220 @@ import { api } from '../api.js'; import { state } from '../state.js'; import { notifications } from '../utils/notifications.js'; -import { getTransformerLabel, getStatusBadge } from '../utils/formatters.js'; +import { escapeHtml, formatDateTime, getTransformerLabel, getStatusBadge } from '../utils/formatters.js'; import { t } from '../utils/i18n.js'; +import { activateModal, closeAllModals, confirmDialog } from '../utils/modal.js'; + +const tokenPoolAuthModes = new Set(['token_pool', 'codex_token_pool']); class Endpoints { constructor() { - this.container = document.getElementById('view-container'); + this.container = document.getElementById('endpoints-panel'); this.endpoints = []; this.tokenPools = {}; this.currentEndpoint = null; this.draggedIndex = null; this.currentTokenPoolEndpoint = null; + this.renderVersion = 0; + this.actionVersion = 0; + this.mutationVersion = 0; + this.mutationQueue = Promise.resolve(); + this.reorderQueue = Promise.resolve(); + this.viewMode = localStorage.getItem('ccNexus_endpointViewMode') === 'compact' ? 'compact' : 'detail'; + this.collapsed = false; + this.filters = { transformer: '', availability: '', enabled: '' }; + state.subscribe('currentEndpoint', currentEndpoint => { + if (state.get('currentView') === 'endpoints' && currentEndpoint !== this.currentEndpoint) { + this.currentEndpoint = currentEndpoint; + this.renderTable(); + } + }); // 监听语言切换 window.addEventListener('languageChanged', () => { if (state.get('currentView') === 'endpoints') { + closeAllModals(); this.render(); } }); } async render() { + const renderVersion = ++this.renderVersion; + this.invalidateActions(); this.container.innerHTML = ` -
-
-

${t('endpoints.title')}

- -
- -
-
-
+
+ + +
+
+ + +
+
- `; + +
+
+
`; + this.bindWorkspaceControls(); + + await this.loadEndpoints(renderVersion); + } + + bindWorkspaceControls() { document.getElementById('add-endpoint-btn').addEventListener('click', () => this.showAddModal()); + document.getElementById('endpoint-collapse-btn').addEventListener('click', () => { + this.collapsed = !this.collapsed; + this.render(); + }); + document.querySelectorAll('.endpoint-view-btn').forEach(button => { + button.addEventListener('click', () => { + this.viewMode = button.dataset.mode; + localStorage.setItem('ccNexus_endpointViewMode', this.viewMode); + document.querySelectorAll('.endpoint-view-btn').forEach(viewButton => { + const active = viewButton.dataset.mode === this.viewMode; + viewButton.classList.toggle('active', active); + viewButton.setAttribute('aria-pressed', String(active)); + }); + this.renderTable(); + }); + }); + for (const key of Object.keys(this.filters)) { + const select = document.getElementById(`endpoint-filter-${key}`); + select.value = this.filters[key]; + select.addEventListener('change', () => { + this.filters[key] = select.value; + this.renderTable(); + }); + } + document.getElementById('clear-endpoint-filters').addEventListener('click', () => { + this.filters = { transformer: '', availability: '', enabled: '' }; + for (const key of Object.keys(this.filters)) { + document.getElementById(`endpoint-filter-${key}`).value = ''; + } + this.renderTable(); + }); + } + + beginAction() { + return ++this.actionVersion; + } - await this.loadEndpoints(); + invalidateActions() { + this.actionVersion++; + } + + isActionCurrent(actionVersion) { + return actionVersion === this.actionVersion && state.get('currentView') === 'endpoints'; + } + + isLoadCurrent(renderVersion, actionVersion) { + return renderVersion === this.renderVersion && + state.get('currentView') === 'endpoints' && + (actionVersion == null || actionVersion === this.actionVersion); + } + + queueMutation(operation) { + const mutationVersion = ++this.mutationVersion; + const result = this.mutationQueue.then(operation); + this.mutationQueue = result.catch(() => {}).then(() => { + if (mutationVersion === this.mutationVersion) { + return this.loadEndpoints(this.renderVersion); + } + }); + return result; } - async loadEndpoints() { + activateEndpointModal(overlay, options = {}) { + const { onClose, ...modalOptions } = options; + return activateModal(overlay, { + ...modalOptions, + onClose: () => { + this.invalidateActions(); + onClose?.(); + } + }); + } + + async loadEndpoints(renderVersion = this.renderVersion, actionVersion = null) { + const mutationVersion = this.mutationVersion; try { const data = await api.getEndpoints(); - this.endpoints = data.endpoints || []; - this.tokenPools = data.tokenPools || {}; + if (!this.isLoadCurrent(renderVersion, actionVersion) || mutationVersion !== this.mutationVersion) { + return; + } + const endpoints = data.endpoints || []; + const tokenPools = data.tokenPools || {}; + let currentEndpoint = null; // Get current endpoint try { const currentData = await api.getCurrentEndpoint(); - this.currentEndpoint = currentData.name || null; + if (!this.isLoadCurrent(renderVersion, actionVersion) || mutationVersion !== this.mutationVersion) { + return; + } + currentEndpoint = currentData.name || null; } catch (error) { + if (!this.isLoadCurrent(renderVersion, actionVersion) || mutationVersion !== this.mutationVersion) { + return; + } console.error('Failed to get current endpoint:', error); - this.currentEndpoint = null; } - this.renderTable(); + if (this.isLoadCurrent(renderVersion, actionVersion) && mutationVersion === this.mutationVersion) { + this.endpoints = endpoints; + this.tokenPools = tokenPools; + this.currentEndpoint = currentEndpoint; + this.renderTable(); + } } catch (error) { - notifications.error(`${t('endpoints.failedToLoad')}: ${error.message}`); + if (this.isLoadCurrent(renderVersion, actionVersion) && mutationVersion === this.mutationVersion) { + notifications.error(`${t('endpoints.failedToLoad')}: ${error.message}`); + } } } renderTable() { const container = document.getElementById('endpoints-table'); + if (!container) { + return; + } + + const visibleEndpoints = this.getVisibleEndpoints(); + document.getElementById('endpoint-filter-banner').hidden = !this.isFilterActive(); if (this.endpoints.length === 0) { container.innerHTML = `
-
🔗
+
${t('endpoints.noEndpoints')}
${t('endpoints.noEndpointsMessage')}
@@ -78,25 +222,15 @@ class Endpoints { return; } + if (visibleEndpoints.length === 0) { + container.innerHTML = `
${t('endpoints.noFilterResults')}
`; + document.getElementById('empty-clear-filters').addEventListener('click', () => document.getElementById('clear-endpoint-filters').click()); + return; + } + container.innerHTML = ` -
- - - - - - - - - - - - - - - ${this.endpoints.map((ep, index) => this.renderEndpointRow(ep, index)).join('')} - -
${t('common.name')}${t('endpoints.apiUrl')}${t('endpoints.transformer')}${t('endpoints.model')}${t('endpoints.tokenPool')}${t('common.status')}${t('common.actions')}
+
+ ${visibleEndpoints.map(item => this.renderEndpointRow(item.endpoint, item.index)).join('')}
`; @@ -105,68 +239,81 @@ class Endpoints { this.attachDragListeners(); } + isFilterActive() { + return Object.values(this.filters).some(Boolean); + } + + getVisibleEndpoints() { + return this.endpoints.map((endpoint, index) => ({ endpoint, index })).filter(({ endpoint }) => { + const testStatus = this.getTestStatus(endpoint.name); + const availability = testStatus === true ? 'available' : testStatus === false ? 'unavailable' : 'unknown'; + const enabled = endpoint.enabled ? 'enabled' : 'disabled'; + return (!this.filters.transformer || endpoint.transformer === this.filters.transformer) && + (!this.filters.availability || availability === this.filters.availability) && + (!this.filters.enabled || enabled === this.filters.enabled); + }); + } + renderEndpointRow(ep, index) { const isCurrentEndpoint = ep.name === this.currentEndpoint; + const isTokenPool = tokenPoolAuthModes.has(ep.authMode); const testStatus = this.getTestStatus(ep.name); - let testStatusIcon = '⚠️'; - let testStatusTitle = t('endpoints.notTested'); + const reorderLabel = escapeHtml(t('endpoints.reorder')); + let testStatusClass = 'badge-warning'; + let testStatusLabel = t('endpoints.notTested'); if (testStatus === true) { - testStatusIcon = '✅'; - testStatusTitle = t('endpoints.testPassed'); + testStatusClass = 'badge-success'; + testStatusLabel = t('endpoints.testPassed'); } else if (testStatus === false) { - testStatusIcon = '❌'; - testStatusTitle = t('endpoints.testFailed'); + testStatusClass = 'badge-danger'; + testStatusLabel = t('endpoints.testFailed'); } + const reorderDisabled = this.isFilterActive(); return ` - - ⋮⋮ - - ${this.escapeHtml(ep.name)} - ${testStatusIcon} - ${isCurrentEndpoint ? `${t('endpoints.current')}` : ''} - - - ${this.escapeHtml(ep.apiUrl)} - - - ${getTransformerLabel(ep.transformer)} - ${this.escapeHtml(ep.model || '-')} - ${this.renderTokenPoolSummary(this.tokenPools[ep.name])} - ${getStatusBadge(ep.enabled)} - -
- ${ep.enabled && !isCurrentEndpoint ? ` - - ` : ''} - - - - - - +
+
+
+
⋮⋮
+
+

${escapeHtml(ep.name)}

+
+ ${escapeHtml(testStatusLabel)} + ${isCurrentEndpoint ? `${t('endpoints.current')}` : ''} + ${getStatusBadge(ep.enabled)} +
+
+
+ + +
- - - `; +
+
+ ${t('endpoints.apiUrl')} + ${escapeHtml(ep.apiUrl)} + +
+
${t('endpoints.authMode')}${t(`authModes.${ep.authMode || 'api_key'}`)}
+
${t('endpoints.transformer')}${escapeHtml(getTransformerLabel(ep.transformer))}
+
${t('endpoints.model')}${escapeHtml(ep.model || '-')}
+ ${isTokenPool ? `
${t('endpoints.tokenPool')}${this.renderTokenPoolSummary(this.tokenPools[ep.name])}
` : `
${t('endpoints.apiKey')}${ep.hasApiKey ? t('endpoints.apiKeyConfigured') : t('endpoints.apiKeyMissing')}
`} +
+
+
+ ${ep.enabled && !isCurrentEndpoint ? `` : ''} + + ${isTokenPool ? `` : ''} + + + + +
+
`; } renderTokenPoolSummary(pool) { @@ -175,10 +322,15 @@ class Endpoints { } return ` -
+
${t('endpoints.total')}: ${pool.total}
-
A:${pool.active || 0} E:${pool.expiring || 0} X:${pool.expired || 0} I:${pool.invalid || 0}
-
C:${pool.cooldown || 0} R:${pool.needRefresh || 0} D:${pool.disabled || 0}
+ ${t('endpoints.active')}: ${pool.active || 0} + ${t('endpoints.expiring')}: ${pool.expiring || 0} + ${t('endpoints.expired')}: ${pool.expired || 0} + ${t('endpoints.invalid')}: ${pool.invalid || 0} + ${t('endpoints.cooldown')}: ${pool.cooldown || 0} + ${t('endpoints.needRefresh')}: ${pool.needRefresh || 0} + ${t('common.disabled')}: ${pool.disabled || 0}
`; } @@ -223,72 +375,109 @@ class Endpoints { document.querySelectorAll('.copy-btn').forEach(btn => { btn.addEventListener('click', () => this.copyToClipboard(btn.dataset.copy, btn)); }); + + document.querySelectorAll('.move-up-btn').forEach(btn => { + btn.addEventListener('click', () => this.moveEndpoint(Number(btn.dataset.index), -1)); + }); + document.querySelectorAll('.move-down-btn').forEach(btn => { + btn.addEventListener('click', () => this.moveEndpoint(Number(btn.dataset.index), 1)); + }); } attachDragListeners() { - const rows = document.querySelectorAll('#endpoints-tbody tr[draggable="true"]'); + const rows = document.querySelectorAll('.endpoint-card[draggable="true"]'); rows.forEach(row => { row.addEventListener('dragstart', (e) => { this.draggedIndex = parseInt(row.dataset.index); - row.style.opacity = '0.5'; + row.classList.add('is-dragging'); }); row.addEventListener('dragend', (e) => { - row.style.opacity = '1'; + row.classList.remove('is-dragging'); + document.querySelectorAll('.endpoint-card.is-drop-target').forEach(target => { + target.classList.remove('is-drop-target'); + }); + this.draggedIndex = null; }); row.addEventListener('dragover', (e) => { e.preventDefault(); - row.style.borderTop = '2px solid #3b82f6'; + row.classList.add('is-drop-target'); }); row.addEventListener('dragleave', (e) => { - row.style.borderTop = ''; + row.classList.remove('is-drop-target'); }); - row.addEventListener('drop', async (e) => { + row.addEventListener('drop', (e) => { e.preventDefault(); - row.style.borderTop = ''; + row.classList.remove('is-drop-target'); + const fromIndex = this.draggedIndex; const dropIndex = parseInt(row.dataset.index); - if (this.draggedIndex !== null && this.draggedIndex !== dropIndex) { - await this.reorderEndpoints(this.draggedIndex, dropIndex); - } this.draggedIndex = null; + if (fromIndex !== null && fromIndex !== dropIndex) { + this.reorderEndpoints(fromIndex, dropIndex); + } }); }); } - async reorderEndpoints(fromIndex, toIndex) { - try { - // Reorder the array - const [movedItem] = this.endpoints.splice(fromIndex, 1); - this.endpoints.splice(toIndex, 0, movedItem); - - // Send new order to backend - const names = this.endpoints.map(ep => ep.name); - await api.reorderEndpoints(names); - - notifications.success(t('notifications.endpointsReordered')); - await this.loadEndpoints(); - } catch (error) { - notifications.error(`${t('endpoints.failedToReorder')}: ${error.message}`); - await this.loadEndpoints(); // Reload to reset order + moveEndpoint(fromIndex, offset) { + const toIndex = fromIndex + offset; + if (toIndex < 0 || toIndex >= this.endpoints.length) { + return; } + this.reorderEndpoints(fromIndex, toIndex); + } + + reorderEndpoints(fromIndex, toIndex) { + const actionVersion = this.beginAction(); + const [movedItem] = this.endpoints.splice(fromIndex, 1); + this.endpoints.splice(toIndex, 0, movedItem); + const names = this.endpoints.map(endpoint => endpoint.name); + const orderKey = JSON.stringify(names); + this.renderTable(); + + const mutation = this.queueMutation(() => api.reorderEndpoints(names)); + this.reorderQueue = mutation.then( + () => { + if (!this.isActionCurrent(actionVersion)) { + return; + } + notifications.success(t('notifications.endpointsReordered')); + }, + error => { + const visibleOrder = JSON.stringify(this.endpoints.map(endpoint => endpoint.name)); + if (this.isActionCurrent(actionVersion) && visibleOrder === orderKey) { + notifications.error(`${t('endpoints.failedToReorder')}: ${error.message}`); + } + } + ); + return this.reorderQueue; } async switchEndpoint(name) { + const actionVersion = this.beginAction(); try { - await api.switchEndpoint(name); + await this.queueMutation(() => api.switchEndpoint(name)); + if (!this.isActionCurrent(actionVersion)) { + return; + } notifications.success(`${t('notifications.endpointSwitched')} ${name}`); - await this.loadEndpoints(); } catch (error) { - notifications.error(`${t('endpoints.failedToSwitch')}: ${error.message}`); + if (this.isActionCurrent(actionVersion)) { + notifications.error(`${t('endpoints.failedToSwitch')}: ${error.message}`); + } } } copyToClipboard(text, button) { + if (!navigator.clipboard?.writeText) { + notifications.error(t('endpoints.failedToCopy')); + return; + } navigator.clipboard.writeText(text).then(() => { const originalText = button.textContent; button.textContent = '✓'; @@ -331,64 +520,80 @@ class Endpoints { } showEndpointModal(endpoint, isClone = false) { + this.invalidateActions(); const isEdit = !!endpoint && !isClone; const modalContainer = document.getElementById('modal-container'); - - // For clone mode: show masked value like edit mode - const apiKeyValue = endpoint ? '****' : ''; - const apiKeyPlaceholder = 'sk-...'; - const apiKeyHint = isEdit || isClone ? `${t('endpoints.keepExistingKey')}` : ''; + const authMode = endpoint?.authMode || 'api_key'; + const hasApiKey = endpoint?.hasApiKey === true; + const apiKeyHint = isEdit || isClone ? `${t('endpoints.keepExistingKey')}` : ''; const cloneHiddenInput = isClone ? '' : ''; const cloneFromValue = endpoint?.cloneFrom || ''; - const cloneFromInput = isClone && cloneFromValue ? `` : ''; + const cloneFromInput = isClone && cloneFromValue ? `` : ''; + closeAllModals(); modalContainer.innerHTML = `