From 43a6e6712f943bdd4c8ee0205fdc7767ac4ea911 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sat, 21 Mar 2026 17:08:43 +0800 Subject: [PATCH 1/2] Show UI drift marker for env draft vs Vercel config --- internal/admin/handler.go | 1 + internal/admin/handler_config_read.go | 5 +- internal/admin/handler_vercel.go | 72 +++++++++++++++++-- webui/src/app/useAdminConfig.js | 28 ++++++++ .../account/AccountManagerContainer.jsx | 1 + webui/src/features/account/AccountsTable.jsx | 9 ++- .../features/vercel/VercelSyncContainer.jsx | 3 +- webui/src/features/vercel/VercelSyncForm.jsx | 5 ++ .../src/features/vercel/useVercelSyncState.js | 16 +++-- webui/src/layout/DashboardShell.jsx | 2 +- webui/src/locales/en.json | 2 + webui/src/locales/zh.json | 2 + 12 files changed, 131 insertions(+), 15 deletions(-) diff --git a/internal/admin/handler.go b/internal/admin/handler.go index fc20533..125abd0 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -36,6 +36,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Post("/test", h.testAPI) pr.Post("/vercel/sync", h.syncVercel) pr.Get("/vercel/status", h.vercelStatus) + pr.Post("/vercel/status", h.vercelStatus) pr.Get("/export", h.exportConfig) pr.Get("/dev/captures", h.getDevCaptures) pr.Delete("/dev/captures", h.clearDevCaptures) diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index e32aabd..9839887 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -8,8 +8,9 @@ import ( func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { snap := h.Store.Snapshot() safe := map[string]any{ - "keys": snap.Keys, - "accounts": []map[string]any{}, + "keys": snap.Keys, + "accounts": []map[string]any{}, + "env_backed": h.Store.IsEnvBacked(), "claude_mapping": func() map[string]string { if len(snap.ClaudeMapping) > 0 { return snap.ClaudeMapping diff --git a/internal/admin/handler_vercel.go b/internal/admin/handler_vercel.go index 2c6356c..5f66a68 100644 --- a/internal/admin/handler_vercel.go +++ b/internal/admin/handler_vercel.go @@ -3,6 +3,8 @@ package admin import ( "bytes" "context" + "crypto/md5" + "encoding/base64" "encoding/json" "fmt" "io" @@ -11,6 +13,8 @@ import ( "os" "strings" "time" + + "ds2api/internal/config" ) func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { @@ -25,7 +29,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { return } validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate) - _, cfgB64, err := h.Store.ExportJSONAndBase64() + cfgJSON, cfgB64, err := h.exportSyncConfig(req) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()}) return @@ -47,7 +51,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) { } savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs) manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers) - _ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix()) + _ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix()) result := map[string]any{"success": true, "validated_accounts": validated} if manual { result["message"] = "配置已同步到 Vercel,请手动触发重新部署" @@ -209,11 +213,71 @@ func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID return false, deployURL } -func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) { +func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) { snap := h.Store.Snapshot() current := h.computeSyncHash() synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current - writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""}) + draftHash := "" + draftDiffers := false + if r != nil && r.Method == http.MethodPost && r.Body != nil { + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + if cfgJSON, _, err := h.exportSyncConfig(req); err == nil { + draftHash = syncHashForJSON(cfgJSON) + draftDiffers = draftHash != "" && draftHash != current + } + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "synced": synced, + "last_sync_time": nilIfZero(snap.VercelSyncTime), + "has_synced_before": snap.VercelSyncHash != "", + "env_backed": h.Store.IsEnvBacked(), + "config_hash": current, + "last_synced_hash": snap.VercelSyncHash, + "draft_hash": draftHash, + "draft_differs": draftDiffers, + }) +} + +func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) { + override, ok := req["config_override"] + if !ok || override == nil { + return h.Store.ExportJSONAndBase64() + } + raw, err := json.Marshal(override) + if err != nil { + return "", "", err + } + var cfg config.Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", "", err + } + cfg.DropInvalidAccounts() + cfg.ClearAccountTokens() + cfg.VercelSyncHash = "" + cfg.VercelSyncTime = 0 + b, err := json.Marshal(cfg) + if err != nil { + return "", "", err + } + return string(b), base64.StdEncoding.EncodeToString(b), nil +} + +func syncHashForJSON(s string) string { + var cfg config.Config + if err := json.Unmarshal([]byte(s), &cfg); err != nil { + return "" + } + cfg.VercelSyncHash = "" + cfg.VercelSyncTime = 0 + cfg.ClearAccountTokens() + b, err := json.Marshal(cfg) + if err != nil { + return "" + } + sum := md5.Sum(b) + return fmt.Sprintf("%x", sum) } func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) { diff --git a/webui/src/app/useAdminConfig.js b/webui/src/app/useAdminConfig.js index 3fa410d..86ca7b9 100644 --- a/webui/src/app/useAdminConfig.js +++ b/webui/src/app/useAdminConfig.js @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react' +const ENV_DRAFT_KEY = 'ds2api_env_config_draft_v1' + export function useAdminConfig({ token, showMessage, t }) { const [config, setConfig] = useState({ keys: [], accounts: [] }) @@ -11,6 +13,21 @@ export function useAdminConfig({ token, showMessage, t }) { }) if (res.ok) { const data = await res.json() + if (data?.env_backed) { + const rawDraft = localStorage.getItem(ENV_DRAFT_KEY) + if (rawDraft) { + try { + const draft = JSON.parse(rawDraft) + setConfig({ ...draft, env_backed: true }) + return + } catch (_e) { + localStorage.removeItem(ENV_DRAFT_KEY) + } + } + localStorage.setItem(ENV_DRAFT_KEY, JSON.stringify(data)) + } else { + localStorage.removeItem(ENV_DRAFT_KEY) + } setConfig(data) } } catch (e) { @@ -21,6 +38,17 @@ export function useAdminConfig({ token, showMessage, t }) { useEffect(() => { if (token) { + const rawDraft = localStorage.getItem(ENV_DRAFT_KEY) + if (rawDraft) { + try { + const draft = JSON.parse(rawDraft) + if (draft?.env_backed) { + setConfig(draft) + } + } catch (_e) { + localStorage.removeItem(ENV_DRAFT_KEY) + } + } fetchConfig() } }, [fetchConfig, token]) diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 8300df7..6f70b66 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -101,6 +101,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, onPageSizeChange={changePageSize} searchQuery={searchQuery} onSearchChange={handleSearchChange} + envBacked={Boolean(config?.env_backed)} /> 0 ? ( accounts.map((acc, i) => { const id = resolveAccountIdentifier(acc) + const runtimeUnknown = envBacked && !acc.test_status + const isActive = acc.test_status === 'ok' || acc.has_token return (
- {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} + {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')} {acc.token_preview && ( {acc.token_preview} diff --git a/webui/src/features/vercel/VercelSyncContainer.jsx b/webui/src/features/vercel/VercelSyncContainer.jsx index 87f833d..5acfaa8 100644 --- a/webui/src/features/vercel/VercelSyncContainer.jsx +++ b/webui/src/features/vercel/VercelSyncContainer.jsx @@ -4,7 +4,7 @@ import VercelSyncForm from './VercelSyncForm' import VercelSyncStatus from './VercelSyncStatus' import VercelGuide from './VercelGuide' -export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false }) { +export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false, config = null }) { const { t } = useI18n() const apiFetch = authFetch || fetch @@ -28,6 +28,7 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f onMessage, t, isVercel, + config, }) return ( diff --git a/webui/src/features/vercel/VercelSyncForm.jsx b/webui/src/features/vercel/VercelSyncForm.jsx index fb1724e..a394435 100644 --- a/webui/src/features/vercel/VercelSyncForm.jsx +++ b/webui/src/features/vercel/VercelSyncForm.jsx @@ -69,6 +69,11 @@ export default function VercelSyncForm({ {t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}

)} + {syncStatus?.draft_differs && ( +

+ {t('vercel.draftDiffers')} +

+ )}
diff --git a/webui/src/features/vercel/useVercelSyncState.js b/webui/src/features/vercel/useVercelSyncState.js index 2db27b1..0a9d7c3 100644 --- a/webui/src/features/vercel/useVercelSyncState.js +++ b/webui/src/features/vercel/useVercelSyncState.js @@ -8,7 +8,7 @@ function pollDelayMs(attempt) { return 60000 } -export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) { +export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false, config = null }) { const [vercelToken, setVercelToken] = useState('') const [projectId, setProjectId] = useState('') const [teamId, setTeamId] = useState('') @@ -22,7 +22,14 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => { try { - const res = await apiFetch('/admin/vercel/status') + const hasConfig = Boolean(config && (Array.isArray(config?.keys) || Array.isArray(config?.accounts))) + const res = await apiFetch('/admin/vercel/status', hasConfig ? { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config_override: { keys: config?.keys || [], accounts: config?.accounts || [] }, + }), + } : undefined) if (!res.ok) { throw new Error(`status ${res.status}`) } @@ -50,7 +57,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) // eslint-disable-next-line no-console console.error('Failed to fetch sync status:', e) } - }, [apiFetch, isVercel, onMessage, t]) + }, [apiFetch, config, isVercel, onMessage, t]) useEffect(() => { const loadPreconfig = async () => { @@ -117,6 +124,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) vercel_token: tokenToUse, project_id: projectId, team_id: teamId || undefined, + config_override: config ? { keys: config.keys || [], accounts: config.accounts || [] } : undefined, }), }) const data = await res.json() @@ -133,7 +141,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false }) } finally { setLoading(false) } - }, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken]) + }, [apiFetch, config, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken]) return { vercelToken, diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 5e91f8a..b0dc5ea 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -79,7 +79,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s case 'import': return case 'vercel': - return + return case 'settings': return default: diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 2f0adf0..cfa68ca 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -115,6 +115,7 @@ "testingAllAccounts": "Refreshing tokens for all accounts...", "sessionActive": "Session active", "reauthRequired": "Re-auth required", + "runtimeStatusUnknown": "Will be determined after sync", "testStatusFailed": "Last test failed", "noAccounts": "No accounts found.", "modalAddKeyTitle": "Add API key", @@ -294,6 +295,7 @@ "statusNotSynced": "Not synced", "statusNeverSynced": "Never synced", "lastSyncTime": "Last sync: {time}", + "draftDiffers": "Frontend draft differs from env config. Click Sync & redeploy.", "pollPaused": "Status polling paused after {count} failures.", "manualRefresh": "Refresh manually", "howItWorks": "How it works", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 3a3a6e8..b5cf456 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -115,6 +115,7 @@ "testingAllAccounts": "正在刷新所有账号 Token...", "sessionActive": "已建立会话", "reauthRequired": "需重新登录", + "runtimeStatusUnknown": "状态以同步后为准", "testStatusFailed": "上次测试失败", "noAccounts": "未找到任何账号", "modalAddKeyTitle": "添加 API 密钥", @@ -294,6 +295,7 @@ "statusNotSynced": "未同步", "statusNeverSynced": "从未同步", "lastSyncTime": "上次同步: {time}", + "draftDiffers": "检测到前端草稿与环境变量配置不一致,请点击“同步并重新部署”。", "pollPaused": "状态轮询已暂停:连续失败 {count} 次。", "manualRefresh": "手动刷新", "howItWorks": "工作原理", From 7e473dffc988dc2cc5b18cd617818929433bfea3 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sat, 21 Mar 2026 17:19:32 +0800 Subject: [PATCH 2/2] Fix Vercel sync override to avoid redacted config payloads --- webui/src/features/vercel/useVercelSyncState.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/webui/src/features/vercel/useVercelSyncState.js b/webui/src/features/vercel/useVercelSyncState.js index 0a9d7c3..fcdacdf 100644 --- a/webui/src/features/vercel/useVercelSyncState.js +++ b/webui/src/features/vercel/useVercelSyncState.js @@ -20,14 +20,16 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false, c const [pollFailures, setPollFailures] = useState(0) const [nextRetryAt, setNextRetryAt] = useState(null) + + const configOverride = config?.env_backed ? config : undefined + const fetchSyncStatus = useCallback(async ({ manual = false } = {}) => { try { - const hasConfig = Boolean(config && (Array.isArray(config?.keys) || Array.isArray(config?.accounts))) - const res = await apiFetch('/admin/vercel/status', hasConfig ? { + const res = await apiFetch('/admin/vercel/status', configOverride ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - config_override: { keys: config?.keys || [], accounts: config?.accounts || [] }, + config_override: configOverride, }), } : undefined) if (!res.ok) { @@ -57,7 +59,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false, c // eslint-disable-next-line no-console console.error('Failed to fetch sync status:', e) } - }, [apiFetch, config, isVercel, onMessage, t]) + }, [apiFetch, configOverride, isVercel, onMessage, t]) useEffect(() => { const loadPreconfig = async () => { @@ -124,7 +126,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false, c vercel_token: tokenToUse, project_id: projectId, team_id: teamId || undefined, - config_override: config ? { keys: config.keys || [], accounts: config.accounts || [] } : undefined, + config_override: configOverride, }), }) const data = await res.json() @@ -141,7 +143,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false, c } finally { setLoading(false) } - }, [apiFetch, config, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken]) + }, [apiFetch, configOverride, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken]) return { vercelToken,