Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

- Polish the API access console with connection status, compact endpoint cards, and copy/open actions for supported routes
- Expose the Anthropic Messages and OpenAI Responses routes in the console access catalog and overview metadata
- Split managed updates into image preparation and operator-confirmed restart, with durable progress states and rollback kept on the restart step

### 中文

- 优化 API 接入控制台,增加连接状态、紧凑端点卡片,以及支持端点的复制和打开操作
- 在控制台接入目录和概览元数据中展示 Anthropic Messages 与 OpenAI Responses 端点
- 托管更新拆为镜像准备和人工确认重启两步,过程状态可追踪,重启步骤仍保留回滚

## 0.2.42 - 2026-09-04

Expand Down
7 changes: 6 additions & 1 deletion frontend/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { api } from './client'
export type UpdateAgentStatus = {
protocol_version?: number
available: boolean
staged_update?: boolean
state: string
job_id?: string
current_version?: string
Expand Down Expand Up @@ -74,5 +75,9 @@ export function updateSystemSettings(crossProviderModelPool: boolean) {
}

export function startSystemUpdate() {
return api<StartUpdateResult>('/api/system/update', { method: 'POST', body: '{}' })
return api<StartUpdateResult>('/api/system/update/prepare', { method: 'POST', body: '{}' })
}

export function applyPreparedSystemUpdate() {
return api<StartUpdateResult>('/api/system/update/apply', { method: 'POST', body: '{}' })
}
10 changes: 8 additions & 2 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export const messages: Record<Lang, Dict> = {
updaterNeedInstall: 'Install the host updater from deploy/install-updater.sh or deploy/install-updater.ps1 so it can talk to Docker.',
updaterNeedCompose: 'Linux needs the updater socket mounted into the container. Docker Desktop uses a loopback updater URL instead of a Unix socket.',
updaterNeedRelease: 'The running build must be a published GitHub release, not a local or development image.',
updateNow: 'Update now',
updateNow: 'Prepare update',
applyUpdateNow: 'Restart with downloaded image',
updateReadyHint: 'The image is downloaded and ready. Confirm when you want to restart the service; new requests will pause only then.',
updateStagedUnavailableHint: 'Update the host updater first to use the staged update flow.',
updateReloadingIn: 'Updating… refresh in {seconds}s',
updateInProgress: 'Updating…',
updateInProgressHint: 'The service is updating and will refresh automatically when it is ready.',
Expand Down Expand Up @@ -600,7 +603,10 @@ export const messages: Record<Lang, Dict> = {
updaterNeedInstall: '用 deploy/install-updater.sh 或 deploy/install-updater.ps1 安装宿主机更新器,让它能调用 Docker。',
updaterNeedCompose: 'Linux 需要把更新器 socket 挂进容器。Docker Desktop 用本机回环地址,而不是 Unix socket。',
updaterNeedRelease: '当前运行的必须是已发布的 GitHub Release,不能是本地或开发镜像。',
updateNow: '立即更新',
updateNow: '准备更新',
applyUpdateNow: '使用已下载镜像更新',
updateReadyHint: '镜像已经拉取完成。确认后才会重启服务;在此之前不会暂停新请求。',
updateStagedUnavailableHint: '请先更新宿主机更新器,才能使用分阶段更新。',
updateReloadingIn: '更新中… {seconds}s 后刷新',
updateInProgress: '更新中…',
updateInProgressHint: '系统正在更新,准备就绪后会自动刷新页面。',
Expand Down
37 changes: 29 additions & 8 deletions frontend/src/pages/SystemPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ import {
X,
} from '@phosphor-icons/react'
import { fetchConsoleKey, rotateConsoleKey, type ConsoleKeyView } from '@/api/keys'
import { fetchSystemSettings, fetchSystemUpdate, startSystemUpdate, updateSystemSettings, type StartUpdateResult, type SystemSettings, type SystemUpdateInfo } from '@/api/system'
import { applyPreparedSystemUpdate, fetchSystemSettings, fetchSystemUpdate, startSystemUpdate, updateSystemSettings, type StartUpdateResult, type SystemSettings, type SystemUpdateInfo } from '@/api/system'
import { useApiKey } from '@/hooks/useApiKey'
import { PageAlert } from '@/components/ui/PageAlert'
import { SystemBodySkeleton, SystemPageSkeleton } from '@/components/ui/PageSkeletons'
import { useI18n } from '@/hooks/useI18n'
import { CompactSwitch } from '@/components/ui/CompactSwitch'

const activeStates = new Set(['preparing', 'checking', 'backing_up', 'submitting', 'running', 'queued', 'pulling', 'recreating', 'rolling_back'])
const activeStates = new Set(['preparing', 'preparing_image', 'checking', 'backing_up', 'submitting', 'running', 'queued', 'pulling', 'recreating', 'rolling_back'])

export function SystemPage() {
const { t } = useI18n()
Expand Down Expand Up @@ -74,7 +74,9 @@ export function SystemPage() {
useEffect(() => {
if (!submitting || !started || !info || info.update?.job_id !== started.job_id) return
const state = info.update.state
if (state === 'succeeded') {
if (state === 'ready_to_apply') {
setSubmitting(false)
} else if (state === 'succeeded') {
setSubmitting(false)
setReloadIn((current) => current ?? 3)
} else if (state === 'failed' || state === 'rolled_back') {
Expand All @@ -92,9 +94,10 @@ export function SystemPage() {
return () => window.clearTimeout(timer)
}, [reloadIn])

const canUpdate = Boolean(info?.managed && info?.has_update && info?.next_version && info?.agent?.available && !active)
const canPrepare = Boolean(info?.managed && info?.has_update && info?.next_version && info?.agent?.available && info?.agent?.staged_update && preparationState !== 'ready_to_apply' && !active)
const canApply = Boolean(preparationState === 'ready_to_apply' && info?.agent?.available && !active)

async function applyUpdate() {
async function prepareUpdate() {
setSubmitting(true)
setStarted(null)
setError('')
Expand All @@ -108,6 +111,19 @@ export function SystemPage() {
}
}

async function confirmUpdate() {
setSubmitting(true)
setError('')
try {
const result = await applyPreparedSystemUpdate()
setStarted(result)
await load(false, true)
} catch {
setSubmitting(false)
setError(t('updateFailedHint'))
}
}

async function updateCrossProviderModelPool(enabled: boolean) {
const previous = settings?.cross_provider_model_pool ?? true
setSettings((current) => current ? { ...current, cross_provider_model_pool: enabled } : current)
Expand Down Expand Up @@ -168,13 +184,18 @@ export function SystemPage() {
</div>

<div className="mt-5 flex flex-wrap items-center justify-end gap-3">
<Button isDisabled={!canUpdate || reloadIn != null} isPending={submitting || active} onPress={() => void applyUpdate()}>
<Button isDisabled={(!canPrepare && !canApply) || reloadIn != null} isPending={submitting || active} onPress={() => void (canApply ? confirmUpdate() : prepareUpdate())}>
<ArrowCircleUp size={16} />
{reloadIn != null ? t('updateReloadingIn', { seconds: reloadIn }) : active || submitting ? t('updateInProgress') : t('updateNow')}
{reloadIn != null ? t('updateReloadingIn', { seconds: reloadIn }) : canApply ? t('applyUpdateNow') : active || submitting ? t('updateInProgress') : t('updateNow')}
</Button>
</div>
{!info?.agent?.available && !active ? <p className="mt-3 text-right text-xs text-muted">{t('updateUnavailableHint')}</p> : null}
{active ? (
{info?.agent?.available && !info.agent.staged_update && !active ? <p className="mt-3 text-right text-xs text-muted">{t('updateStagedUnavailableHint')}</p> : null}
{preparationState === 'ready_to_apply' ? (
<div className="mt-4 rounded-lg border border-success/25 bg-success/5 px-3 py-3" role="status" aria-live="polite">
<p className="text-xs leading-5 text-muted">{t('updateReadyHint')}</p>
</div>
) : active ? (
<div className="mt-4 rounded-lg border border-warning/25 bg-warning/5 px-3 py-3" role="status" aria-live="polite">
<p className="text-xs leading-5 text-muted">{reloadIn != null ? t('updateReloadingHint') : t('updateInProgressHint')}</p>
</div>
Expand Down
2 changes: 2 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ func (s *Server) routes() {
s.mux.HandleFunc(endpoint.HealthPath, s.handleHealth)
s.mux.HandleFunc("/api/overview", s.withConsoleKey(s.handleOverview))
s.mux.HandleFunc("/api/system/update", s.withConsoleKey(s.handleSystemUpdate))
s.mux.HandleFunc("/api/system/update/prepare", s.withConsoleKey(s.handleSystemUpdatePrepare))
s.mux.HandleFunc("/api/system/update/apply", s.withConsoleKey(s.handleSystemUpdateConfirm))
s.mux.HandleFunc("/api/system/settings", s.withConsoleKey(s.handleSystemSettings))
s.mux.HandleFunc("/api/system/console-key", s.withConsoleKey(s.handleConsoleKey))
s.mux.HandleFunc("/api/keys", s.withConsoleKey(s.handleAPIKeys))
Expand Down
202 changes: 199 additions & 3 deletions internal/api/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ type updateAgent interface {
Apply(context.Context, control.ApplyRequest) (control.ApplyResponse, error)
}

type preparedUpdateAgent interface {
Prepare(context.Context, control.PrepareRequest) (control.ApplyResponse, error)
ApplyPrepared(context.Context, control.ApplyRequest) (control.ApplyResponse, error)
}

type systemUpdateJob struct {
JobID string `json:"job_id"`
AgentJobID string `json:"agent_job_id,omitempty"`
Expand Down Expand Up @@ -51,6 +56,44 @@ func (s *Server) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
}
}

func (s *Server) handleSystemUpdatePrepare(w http.ResponseWriter, _ *http.Request) {
if !s.updateRunning.CompareAndSwap(false, true) {
writeErr(w, http.StatusConflict, "update_in_progress", "An update is already in progress")
return
}
jobID, err := newSystemUpdateJobID()
if err != nil {
s.updateRunning.Store(false)
writeErr(w, http.StatusInternalServerError, "update_job_failed", err.Error())
return
}
s.updateMu.Lock()
s.updateJob = &systemUpdateJob{JobID: jobID, State: "preparing", StartedAt: time.Now().UTC().Format(time.RFC3339)}
s.updateMu.Unlock()
go s.prepareImageSystemUpdate(jobID)
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}

func (s *Server) handleSystemUpdateConfirm(w http.ResponseWriter, _ *http.Request) {
s.updateMu.Lock()
job := s.updateJob
if job == nil || job.State != "ready_to_apply" {
s.updateMu.Unlock()
writeErr(w, http.StatusConflict, "update_not_ready", "The target image is not ready to apply")
return
}
jobID := job.JobID
s.updateMu.Unlock()
if !s.updateRunning.Load() {
writeErr(w, http.StatusConflict, "update_not_ready", "The update preparation has expired")
return
}
s.maintenance.Store(true)
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) { job.State = "backing_up" })
go s.applyPreparedSystemUpdate(jobID)
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}

func (s *Server) handleSystemUpdateInfo(w http.ResponseWriter, r *http.Request) {
info, err := s.updateChecker.Check(r.Context(), r.URL.Query().Get("force") == "1")
if err != nil {
Expand All @@ -63,7 +106,19 @@ func (s *Server) handleSystemUpdateInfo(w http.ResponseWriter, r *http.Request)
if statusErr != nil {
status = control.AgentStatus{Available: false, State: "unavailable", Error: statusErr.Error()}
}
writeJSON(w, http.StatusOK, systemUpdateInfo{Info: info, Agent: status, Update: s.snapshotUpdateJob()})
job := s.snapshotUpdateJob()
if job == nil && status.State == "ready_to_apply" && status.JobID != "" {
job = &systemUpdateJob{JobID: "agent-" + status.JobID, AgentJobID: status.JobID, State: "ready_to_apply", CurrentVersion: status.CurrentVersion, TargetVersion: status.TargetVersion, StartedAt: status.StartedAt}
s.updateMu.Lock()
if s.updateJob == nil {
s.updateJob = job
s.updateRunning.Store(true)
} else {
job = s.updateJob
}
s.updateMu.Unlock()
}
writeJSON(w, http.StatusOK, systemUpdateInfo{Info: info, Agent: status, Update: job})
}

func (s *Server) handleSystemUpdateApply(w http.ResponseWriter, _ *http.Request) {
Expand All @@ -87,6 +142,80 @@ func (s *Server) handleSystemUpdateApply(w http.ResponseWriter, _ *http.Request)
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}

func (s *Server) prepareImageSystemUpdate(jobID string) {
ctx := context.Background()
setUpdateState := func(state string) {
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) { job.State = state })
}
fail := func(message string) {
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) {
job.State = "failed"
job.Error = message
job.FinishedAt = time.Now().UTC().Format(time.RFC3339)
})
s.updateRunning.Store(false)
}

setUpdateState("checking")
info, err := s.updateChecker.Check(ctx, true)
if err != nil {
fail(err.Error())
return
}
if !info.Managed {
fail("Development builds cannot update from the console")
return
}
if !info.HasUpdate || strings.TrimSpace(info.NextVersion) == "" {
fail("No next release is available")
return
}
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) {
job.CurrentVersion = info.CurrentVersion
job.TargetVersion = info.NextVersion
})

statusCtx, statusCancel := context.WithTimeout(ctx, 3*time.Second)
status, err := s.updateAgent.Status(statusCtx)
statusCancel()
if err != nil || !status.Available {
message := "Updater daemon is unavailable"
if err != nil {
message = err.Error()
}
fail(message)
return
}
if updaterStateActive(status.State) {
fail("Updater daemon is busy")
return
}
if !status.StagedUpdate {
fail("Host updater must be updated before staged updates can be used")
return
}
agent, ok := s.updateAgent.(preparedUpdateAgent)
if !ok {
fail("Host updater does not support staged updates")
return
}

setUpdateState("submitting")
request := control.PrepareRequest{CurrentVersion: info.CurrentVersion, TargetVersion: info.NextVersion}
prepareCtx, prepareCancel := context.WithTimeout(ctx, 8*time.Second)
response, err := agent.Prepare(prepareCtx, request)
prepareCancel()
if err != nil {
fail(err.Error())
return
}
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) {
job.State = "preparing_image"
job.AgentJobID = response.JobID
})
go s.monitorPreparedUpdate(jobID, response.JobID)
}

func (s *Server) prepareSystemUpdate(jobID string) {
ctx := context.Background()
setUpdateState := func(state string) {
Expand Down Expand Up @@ -168,6 +297,73 @@ func (s *Server) prepareSystemUpdate(jobID string) {
go s.monitorUpdate(jobID, response.JobID)
}

func (s *Server) applyPreparedSystemUpdate(jobID string) {
job := s.snapshotUpdateJob()
if job == nil || job.JobID != jobID {
s.finishUpdateJob(jobID, "failed", "Update preparation was lost", true)
s.maintenance.Store(false)
s.updateRunning.Store(false)
return
}
agent, ok := s.updateAgent.(preparedUpdateAgent)
if !ok {
s.finishUpdateJob(jobID, "failed", "Host updater does not support staged updates", true)
s.maintenance.Store(false)
s.updateRunning.Store(false)
return
}
ctx := context.Background()
backup, err := s.manager.Store().Backup(ctx, filepath.Join(s.cfg.DataDir, "backups"), 5)
if err != nil {
s.finishUpdateJob(jobID, "failed", err.Error(), true)
s.maintenance.Store(false)
s.updateRunning.Store(false)
return
}
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) {
job.BackupPath = filepath.Join("/data/backups", backup.Name)
job.State = "submitting"
})
request := control.ApplyRequest{CurrentVersion: job.CurrentVersion, TargetVersion: job.TargetVersion, BackupPath: filepath.Join("/data/backups", backup.Name)}
applyCtx, applyCancel := context.WithTimeout(ctx, 8*time.Second)
response, err := agent.ApplyPrepared(applyCtx, request)
applyCancel()
if err != nil {
s.finishUpdateJob(jobID, "failed", err.Error(), true)
s.maintenance.Store(false)
s.updateRunning.Store(false)
return
}
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) {
job.State = "running"
job.AgentJobID = response.JobID
})
go s.monitorUpdate(jobID, response.JobID)
}

func (s *Server) monitorPreparedUpdate(jobID, agentJobID string) {
deadline := time.Now().Add(15 * time.Minute)
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
status, err := s.updateAgent.Status(ctx)
cancel()
if err == nil && (status.JobID == "" || status.JobID == agentJobID) {
switch status.State {
case "ready_to_apply":
s.mutateUpdateJob(jobID, func(job *systemUpdateJob) { job.State = "ready_to_apply" })
return
case "failed":
s.finishUpdateJob(jobID, "failed", status.Error, true)
s.updateRunning.Store(false)
return
}
}
time.Sleep(2 * time.Second)
}
s.finishUpdateJob(jobID, "failed", "Image preparation timed out", true)
s.updateRunning.Store(false)
}

func (s *Server) snapshotUpdateJob() *systemUpdateJob {
s.updateMu.Lock()
defer s.updateMu.Unlock()
Expand Down Expand Up @@ -239,15 +435,15 @@ func newSystemUpdateJobID() (string, error) {

func updaterStateActive(state string) bool {
switch state {
case "queued", "preparing", "pulling", "recreating", "checking", "rolling_back":
case "queued", "preparing", "pulling", "recreating", "checking", "rolling_back", "ready_to_apply":
return true
default:
return false
}
}

func blocksDuringUpdate(path string) bool {
if path == "/api/system/update" || path == "/health" {
if path == "/api/system/update" || path == "/api/system/update/prepare" || path == "/api/system/update/apply" || path == "/health" {
return false
}
return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v1/")
Expand Down
Loading
Loading