feat(version): warn when the server was updated but not restarted (#898)

When the package is updated on disk but the long-lived server process is
not restarted, the new frontend bundle (served from disk) talks to the
old running backend. New DB-backed features then fail silently — e.g.
deleting/archiving a session appears to do nothing — because the new
schema/routes only take effect on restart.

Nothing currently detects this skew: useVersionCheck only compares the
frontend's build-time version against the latest GitHub release.

This exposes the running server's version (captured once at startup) via
/health, compares it to the frontend's build-time version in
useVersionCheck, and shows a "restart required" banner in the sidebar
(and a small indicator in the collapsed sidebar) when they differ.

- server: add `version` (RUNNING_VERSION, read once at startup) to /health
- useVersionCheck: return `restartRequired` / `runningVersion`
- SidebarFooter / SidebarCollapsed: surface a restart-required banner
- i18n: add `version.restartRequired` to all 10 sidebar locales

Verified with `tsc --noEmit` (client + server) and eslint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
This commit is contained in:
Koya Kikuchi
2026-06-23 05:49:57 +09:00
committed by GitHub
parent c5fe127958
commit f6326c8082
16 changed files with 90 additions and 18 deletions

View File

@@ -76,6 +76,19 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname); const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
// Version of the code that is actually running, captured once at process
// startup. This intentionally does NOT re-read package.json per request: after
// an update replaces the files on disk, package.json reflects the NEW version
// while this long-lived process still runs the OLD code. The frontend bundle is
// rebuilt on update, so a mismatch between this value and the frontend's
// build-time version means the server was updated but not restarted.
const RUNNING_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version || null;
} catch {
return null;
}
})();
const MAX_FILE_UPLOAD_SIZE_MB = 200; const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024; const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20; const MAX_FILE_UPLOAD_COUNT = 20;
@@ -156,7 +169,8 @@ app.get('/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
installMode installMode,
version: RUNNING_VERSION
}); });
}); });

View File

@@ -43,7 +43,7 @@ function Sidebar({
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']); const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false }); const { isPWA } = useDeviceSettings({ trackMobile: false });
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck( const { updateAvailable, restartRequired, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
'siteboon', 'siteboon',
'claudecodeui', 'claudecodeui',
); );
@@ -224,6 +224,7 @@ function Sidebar({
onExpand={handleExpandSidebar} onExpand={handleExpandSidebar}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
restartRequired={restartRequired}
onShowVersionModal={() => setShowVersionModal(true)} onShowVersionModal={() => setShowVersionModal(true)}
t={t} t={t}
/> />
@@ -296,6 +297,7 @@ function Sidebar({
onCreateProject={() => setShowNewProject(true)} onCreateProject={() => setShowNewProject(true)}
onCollapseSidebar={handleCollapseSidebar} onCollapseSidebar={handleCollapseSidebar}
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
latestVersion={latestVersion} latestVersion={latestVersion}
currentVersion={currentVersion} currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, Sparkles, PanelLeftOpen, Bug } from 'lucide-react'; import { Settings, Sparkles, PanelLeftOpen, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE'; const DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';
@@ -16,6 +16,7 @@ type SidebarCollapsedProps = {
onExpand: () => void; onExpand: () => void;
onShowSettings: () => void; onShowSettings: () => void;
updateAvailable: boolean; updateAvailable: boolean;
restartRequired: boolean;
onShowVersionModal: () => void; onShowVersionModal: () => void;
t: TFunction; t: TFunction;
}; };
@@ -24,6 +25,7 @@ export default function SidebarCollapsed({
onExpand, onExpand,
onShowSettings, onShowSettings,
updateAvailable, updateAvailable,
restartRequired,
onShowVersionModal, onShowVersionModal,
t, t,
}: SidebarCollapsedProps) { }: SidebarCollapsedProps) {
@@ -75,6 +77,18 @@ export default function SidebarCollapsed({
<DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" /> <DiscordIcon className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a> </a>
{/* Restart-required indicator */}
{restartRequired && (
<div
className="relative flex h-8 w-8 items-center justify-center rounded-lg"
aria-label={t('version.restartRequired')}
title={t('version.restartRequired')}
>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" />
</div>
)}
{/* Update indicator */} {/* Update indicator */}
{updateAvailable && ( {updateAvailable && (
<button <button

View File

@@ -141,6 +141,7 @@ type SidebarContentProps = {
onCreateProject: () => void; onCreateProject: () => void;
onCollapseSidebar: () => void; onCollapseSidebar: () => void;
updateAvailable: boolean; updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
latestVersion: string | null; latestVersion: string | null;
currentVersion: string; currentVersion: string;
@@ -178,6 +179,7 @@ export default function SidebarContent({
onCreateProject, onCreateProject,
onCollapseSidebar, onCollapseSidebar,
updateAvailable, updateAvailable,
restartRequired,
releaseInfo, releaseInfo,
latestVersion, latestVersion,
currentVersion, currentVersion,
@@ -553,6 +555,7 @@ export default function SidebarContent({
<SidebarFooter <SidebarFooter
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
restartRequired={restartRequired}
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
latestVersion={latestVersion} latestVersion={latestVersion}
currentVersion={currentVersion} currentVersion={currentVersion}

View File

@@ -1,4 +1,4 @@
import { Settings, ArrowUpCircle, Bug } from 'lucide-react'; import { Settings, ArrowUpCircle, Bug, AlertTriangle } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { IS_PLATFORM } from '../../../../constants/config'; import { IS_PLATFORM } from '../../../../constants/config';
import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ReleaseInfo } from '../../../../types/sharedTypes';
@@ -18,6 +18,7 @@ function DiscordIcon({ className }: { className?: string }) {
type SidebarFooterProps = { type SidebarFooterProps = {
updateAvailable: boolean; updateAvailable: boolean;
restartRequired: boolean;
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
latestVersion: string | null; latestVersion: string | null;
currentVersion: string; currentVersion: string;
@@ -28,6 +29,7 @@ type SidebarFooterProps = {
export default function SidebarFooter({ export default function SidebarFooter({
updateAvailable, updateAvailable,
restartRequired,
releaseInfo, releaseInfo,
latestVersion, latestVersion,
currentVersion, currentVersion,
@@ -37,6 +39,22 @@ export default function SidebarFooter({
}: SidebarFooterProps) { }: SidebarFooterProps) {
return ( return (
<div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}> <div className="flex-shrink-0" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>
{/* Restart-required banner: the running server version differs from the
installed/frontend version (updated but not restarted). */}
{restartRequired && (
<>
<div className="nav-divider" />
<div className="px-2 py-1.5 md:px-2 md:py-1.5">
<div className="flex items-center gap-2.5 rounded-lg border border-amber-300/60 bg-amber-50/80 px-2.5 py-2 dark:border-amber-700/40 dark:bg-amber-900/15">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-500 dark:text-amber-400" />
<span className="min-w-0 flex-1 text-xs font-medium text-amber-700 dark:text-amber-300">
{t('version.restartRequired')}
</span>
</div>
</div>
</>
)}
{/* Update banner */} {/* Update banner */}
{updateAvailable && ( {updateAvailable && (
<> <>

View File

@@ -28,20 +28,31 @@ export const useVersionCheck = (owner: string, repo: string) => {
const [latestVersion, setLatestVersion] = useState<string | null>(null); const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null); const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
const [installMode, setInstallMode] = useState<InstallMode>('git'); const [installMode, setInstallMode] = useState<InstallMode>('git');
const [runningVersion, setRunningVersion] = useState<string | null>(null);
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => { useEffect(() => {
const fetchInstallMode = async () => { const fetchHealth = async () => {
try { try {
const response = await fetch('/health'); const response = await fetch('/health');
const data = await response.json(); const data = await response.json();
if (data.installMode === 'npm' || data.installMode === 'git') { if (data.installMode === 'npm' || data.installMode === 'git') {
setInstallMode(data.installMode); setInstallMode(data.installMode);
} }
// `data.version` is the version the server process is actually running.
// This module's `version` is baked into the frontend bundle at build
// time, so it reflects the installed (on-disk) package. If they differ,
// the package was updated but the server process was not restarted, and
// DB-backed actions may silently fail until it is.
if (typeof data.version === 'string' && data.version.length > 0) {
setRunningVersion(data.version);
setRestartRequired(data.version !== version);
}
} catch { } catch {
// Default to git on error // Default to git / no restart hint on error
} }
}; };
fetchInstallMode(); fetchHealth();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -84,5 +95,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [owner, repo]); }, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode }; return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode, runningVersion, restartRequired };
}; };

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen." "restoreSessionError": "Fehler beim Wiederherstellen der Sitzung. Bitte erneut versuchen."
}, },
"version": { "version": {
"updateAvailable": "Update verfügbar" "updateAvailable": "Update verfügbar",
"restartRequired": "Update installiert zum Anwenden Server neu starten"
}, },
"search": { "search": {
"modeProjects": "Projekte", "modeProjects": "Projekte",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Error restoring session. Please try again." "restoreSessionError": "Error restoring session. Please try again."
}, },
"version": { "version": {
"updateAvailable": "Update available" "updateAvailable": "Update available",
"restartRequired": "Update installed — restart the server to apply"
}, },
"search": { "search": {
"modeProjects": "Projects", "modeProjects": "Projects",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer." "restoreSessionError": "Erreur lors de la restauration de la session. Veuillez réessayer."
}, },
"version": { "version": {
"updateAvailable": "Mise à jour disponible" "updateAvailable": "Mise à jour disponible",
"restartRequired": "Mise à jour installée — redémarrez le serveur pour l'appliquer"
}, },
"search": { "search": {
"modeProjects": "Projets", "modeProjects": "Projets",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Errore durante il ripristino della sessione. Riprova." "restoreSessionError": "Errore durante il ripristino della sessione. Riprova."
}, },
"version": { "version": {
"updateAvailable": "Aggiornamento disponibile" "updateAvailable": "Aggiornamento disponibile",
"restartRequired": "Aggiornamento installato — riavvia il server per applicarlo"
}, },
"search": { "search": {
"modeProjects": "Progetti", "modeProjects": "Progetti",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。" "restoreSessionError": "セッションの復元でエラーが発生しました。もう一度お試しください。"
}, },
"version": { "version": {
"updateAvailable": "アップデートあり" "updateAvailable": "アップデートあり",
"restartRequired": "更新が適用されていません。サーバーを再起動してください"
}, },
"deleteConfirmation": { "deleteConfirmation": {
"deleteProject": "プロジェクトを除去", "deleteProject": "プロジェクトを除去",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "세션 복원 오류. 다시 시도해주세요." "restoreSessionError": "세션 복원 오류. 다시 시도해주세요."
}, },
"version": { "version": {
"updateAvailable": "업데이트 가능" "updateAvailable": "업데이트 가능",
"restartRequired": "업데이트가 설치됨 — 적용하려면 서버를 재시작하세요"
}, },
"deleteConfirmation": { "deleteConfirmation": {
"deleteProject": "프로젝트 제거", "deleteProject": "프로젝트 제거",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова." "restoreSessionError": "Ошибка при восстановлении сеанса. Попробуйте снова."
}, },
"version": { "version": {
"updateAvailable": "Доступно обновление" "updateAvailable": "Доступно обновление",
"restartRequired": "Обновление установлено — перезапустите сервер для применения"
}, },
"search": { "search": {
"modeProjects": "Проекты", "modeProjects": "Проекты",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene." "restoreSessionError": "Oturum geri yüklenirken hata oluştu. Lütfen tekrar dene."
}, },
"version": { "version": {
"updateAvailable": "Güncelleme mevcut" "updateAvailable": "Güncelleme mevcut",
"restartRequired": "Güncelleme yüklendi — uygulamak için sunucuyu yeniden başlatın"
}, },
"search": { "search": {
"modeProjects": "Projeler", "modeProjects": "Projeler",

View File

@@ -115,7 +115,8 @@
"restoreSessionError": "恢复会话时出错,请重试。" "restoreSessionError": "恢复会话时出错,请重试。"
}, },
"version": { "version": {
"updateAvailable": "有可用更新" "updateAvailable": "有可用更新",
"restartRequired": "已安装更新 — 请重启服务器以生效"
}, },
"search": { "search": {
"modeProjects": "项目", "modeProjects": "项目",

View File

@@ -114,7 +114,8 @@
"restoreSessionError": "還原工作階段時出錯,請重試。" "restoreSessionError": "還原工作階段時出錯,請重試。"
}, },
"version": { "version": {
"updateAvailable": "有可用更新" "updateAvailable": "有可用更新",
"restartRequired": "已安裝更新 — 請重新啟動伺服器以套用"
}, },
"search": { "search": {
"modeProjects": "專案", "modeProjects": "專案",