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
+ 跳到主要内容
-