feat(settings): add panel update management UI
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 10s

Expand the About tab into a full update manager over the existing backend
endpoints: edit the update repository URL, check for updates (showing
changelog/errors), trigger an update with a strong confirmation, and show a
live progress banner with a reset action while an update runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 22:18:08 +05:00
parent 179cb615f3
commit fb1267c533
4 changed files with 174 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ import {
useSecuritySettings, useUpdateSecuritySettings,
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
useAppVersion, useCheckUpdates,
useUpdateRepository, useSetUpdateRepository, useUpdateStatus, usePerformUpdate, useResetUpdate,
} from '@/hooks/use-settings';
import { SSHKeysManager } from './SSHKeysManager';
@@ -218,20 +219,102 @@ function AboutTab() {
const { t } = useTranslation();
const { data: version } = useAppVersion();
const checkUpdates = useCheckUpdates();
const { data: repo } = useUpdateRepository();
const setRepo = useSetUpdateRepository();
const performUpdate = usePerformUpdate();
const resetUpdate = useResetUpdate();
const check = checkUpdates.data;
const updating = check?.update_available ?? false;
// Poll the update status only while an update is running.
const { data: status } = useUpdateStatus(performUpdate.isSuccess);
const [repoUrl, setRepoUrl] = useState('');
const [repoEdit, setRepoEdit] = useState(false);
useEffect(() => {
if (repo?.repository_url) setRepoUrl(repo.repository_url);
}, [repo?.repository_url]);
const handleSaveRepo = () => {
setRepo.mutate(repoUrl, {
onSuccess: () => { toast.success(t('settings.repo_saved')); setRepoEdit(false); },
onError: (e: Error) => toast.error(e.message),
});
};
const handlePerform = () => {
if (!confirm(t('settings.update_confirm'))) return;
performUpdate.mutate(undefined, {
onSuccess: (r) => r.success ? toast.success(r.message || t('settings.update_started')) : toast.error(r.error || 'Error'),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
<Badge variant="outline">{version?.version || '—'}</Badge>
</div>
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()}>
{t('settings.check_updates')}
</Button>
{checkUpdates.data && (
<p className="text-sm">{checkUpdates.data.has_update ? `${t('settings.update_available')}: ${checkUpdates.data.latest_version}` : t('settings.up_to_date')}</p>
{/* Repository */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t('settings.update_repository')}</Label>
{repoEdit ? (
<div className="flex items-center gap-2">
<Input value={repoUrl} onChange={e => setRepoUrl(e.target.value)} placeholder="https://git.example.com/owner/repo" />
<Button size="sm" onClick={handleSaveRepo} disabled={setRepo.isPending || !repoUrl}>{t('common.save')}</Button>
<Button size="sm" variant="ghost" onClick={() => { setRepoEdit(false); setRepoUrl(repo?.repository_url || ''); }}>{t('common.cancel')}</Button>
</div>
) : (
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-2 py-1 text-xs">{repo?.repository_url || '—'}</code>
<Button size="sm" variant="ghost" onClick={() => setRepoEdit(true)}>{t('common.edit', 'Edit')}</Button>
</div>
)}
</div>
{/* Check */}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()} disabled={checkUpdates.isPending}>
{t('settings.check_updates')}
</Button>
{updating && (
<Button size="sm" onClick={handlePerform} disabled={performUpdate.isPending || status?.is_updating}>
{t('settings.perform_update')}
</Button>
)}
</div>
{check && (
<div className="space-y-1 text-sm">
{check.error ? (
<p className="text-destructive">{check.error}</p>
) : check.update_available ? (
<p className="font-medium text-amber-600 dark:text-amber-500">
{t('settings.update_available')}: {check.latest_version}
</p>
) : (
<p className="text-muted-foreground">{t('settings.up_to_date')}</p>
)}
{check.changelog && (
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{check.changelog}</pre>
)}
</div>
)}
{/* Update in progress banner */}
{status?.is_updating && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<p className="font-medium">{t('settings.update_in_progress')}</p>
<p className="text-xs text-muted-foreground">{status.stage} — {status.progress}%</p>
<Button size="sm" variant="ghost" className="mt-2" onClick={() => resetUpdate.mutate()} disabled={resetUpdate.isPending}>
{t('settings.update_reset')}
</Button>
</div>
)}
</CardContent>
</Card>

View File

@@ -7,8 +7,32 @@ export const settingsKeys = {
security: ['settings-security'] as const,
channels: ['notification-channels'] as const,
version: ['app-version'] as const,
updateRepository: ['update-repository'] as const,
updateStatus: ['update-status'] as const,
};
export interface CheckUpdateResult {
current_version: string;
latest_version: string | null;
update_available: boolean;
changelog: string | null;
error: string | null;
git_available: boolean;
project_mounted: boolean;
disabled: boolean;
repository_url?: string;
commits_behind?: number;
}
export interface UpdateStatus {
is_updating: boolean;
started_at: string | null;
stage: string | null;
progress: number;
error: string | null;
completed: boolean;
}
interface ProfileResponse {
id: number;
username: string;
@@ -117,6 +141,50 @@ export function useAppVersion() {
export function useCheckUpdates() {
return useMutation({
mutationFn: () => apiClient.get<Record<string, unknown>>('/settings/api/updates/check'),
mutationFn: () => apiClient.get<CheckUpdateResult>('/settings/api/updates/check'),
});
}
export function useUpdateRepository() {
return useQuery({
queryKey: settingsKeys.updateRepository,
queryFn: () => apiClient.get<{ repository_url: string }>('/settings/api/updates/repository'),
});
}
export function useSetUpdateRepository() {
const qc = useQueryClient();
return useMutation({
// The endpoint takes repository_url as a query parameter, not a JSON body.
mutationFn: (repository_url: string) =>
apiClient.put<{ message: string; repository_url: string }>(
`/settings/api/updates/repository?repository_url=${encodeURIComponent(repository_url)}`,
{},
),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.updateRepository }),
});
}
export function useUpdateStatus(poll = false) {
return useQuery({
queryKey: settingsKeys.updateStatus,
queryFn: () => apiClient.get<UpdateStatus>('/settings/api/updates/status'),
refetchInterval: poll ? 3000 : false,
});
}
export function usePerformUpdate() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ success: boolean; message?: string; error?: string }>('/settings/api/updates/perform', {}),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.updateStatus }),
});
}
export function useResetUpdate() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ success: boolean; message: string }>('/settings/api/updates/reset', {}),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.updateStatus }),
});
}

View File

@@ -554,7 +554,14 @@
"version": "Version",
"check_updates": "Check for Updates",
"update_available": "Update available",
"up_to_date": "You are up to date"
"up_to_date": "You are up to date",
"update_repository": "Update repository",
"repo_saved": "Repository URL saved",
"perform_update": "Update now",
"update_confirm": "Start the update? The panel will pull the latest version, rebuild and restart — it will be briefly unavailable.",
"update_started": "Update started",
"update_in_progress": "Update in progress",
"update_reset": "Reset status"
},
"ssh_keys": {
"title": "SSH Keys",

View File

@@ -554,7 +554,14 @@
"version": "Версия",
"check_updates": "Проверить обновления",
"update_available": "Доступно обновление",
"up_to_date": "Установлена последняя версия"
"up_to_date": "Установлена последняя версия",
"update_repository": "Репозиторий обновлений",
"repo_saved": "URL репозитория сохранён",
"perform_update": "Обновить сейчас",
"update_confirm": "Запустить обновление? Панель скачает последнюю версию, пересоберётся и перезапустится — на короткое время будет недоступна.",
"update_started": "Обновление запущено",
"update_in_progress": "Идёт обновление",
"update_reset": "Сбросить статус"
},
"ssh_keys": {
"title": "SSH ключи",