mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
Show UI drift marker for env draft vs Vercel config
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
onPageSizeChange={changePageSize}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
envBacked={Boolean(config?.env_backed)}
|
||||
/>
|
||||
|
||||
<AddKeyModal
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function AccountsTable({
|
||||
onPageSizeChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
envBacked = false,
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
|
||||
@@ -101,14 +102,16 @@ export default function AccountsTable({
|
||||
) : accounts.length > 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 (
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
||||
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||
"bg-amber-500"
|
||||
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
@@ -122,7 +125,7 @@ export default function AccountsTable({
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{acc.token_preview}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -69,6 +69,11 @@ export default function VercelSyncForm({
|
||||
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
|
||||
</p>
|
||||
)}
|
||||
{syncStatus?.draft_differs && (
|
||||
<p className="text-xs text-amber-500 mt-2">
|
||||
{t('vercel.draftDiffers')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
|
||||
case 'import':
|
||||
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'vercel':
|
||||
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
|
||||
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} config={config} />
|
||||
case 'settings':
|
||||
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
|
||||
default:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "工作原理",
|
||||
|
||||
Reference in New Issue
Block a user