A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

feat: prioritize backfill queue, defer checks, and add web update controls

jack 000f891e 703f9d24

+440 -23
+64 -15
src/index.ts
··· 2041 2041 2042 2042 if (backfillReq) { 2043 2043 const limit = backfillReq.limit || 15; 2044 + const accountCount = mapping.twitterUsernames.length; 2045 + const estimatedTotalTweets = accountCount * limit; 2044 2046 console.log( 2045 2047 `[${mapping.bskyIdentifier}] Running backfill for ${mapping.twitterUsernames.length} accounts (limit ${limit})...`, 2046 2048 ); 2047 2049 updateAppStatus({ 2048 2050 state: 'backfilling', 2049 2051 currentAccount: mapping.twitterUsernames[0], 2050 - message: `Starting backfill (limit ${limit})...`, 2052 + processedCount: 0, 2053 + totalCount: accountCount, 2054 + message: `Backfill queued for ${accountCount} account(s), up to ${estimatedTotalTweets} tweets`, 2051 2055 backfillMappingId: mapping.id, 2052 2056 backfillRequestId: backfillReq.requestId, 2053 2057 }); 2054 2058 2055 - for (const twitterUsername of mapping.twitterUsernames) { 2059 + for (let i = 0; i < mapping.twitterUsernames.length; i += 1) { 2060 + const twitterUsername = mapping.twitterUsernames[i]; 2061 + if (!twitterUsername) { 2062 + continue; 2063 + } 2056 2064 const stillPending = explicitBackfill 2057 2065 ? true 2058 2066 : getPendingBackfills().some((b) => b.id === mapping.id && b.requestId === backfillReq.requestId); ··· 2065 2073 updateAppStatus({ 2066 2074 state: 'backfilling', 2067 2075 currentAccount: twitterUsername, 2068 - message: `Starting backfill (limit ${limit})...`, 2076 + processedCount: i, 2077 + totalCount: accountCount, 2078 + message: `Backfill ${i + 1}/${accountCount}: @${twitterUsername} (limit ${limit})`, 2069 2079 backfillMappingId: mapping.id, 2070 2080 backfillRequestId: backfillReq.requestId, 2071 2081 }); 2072 2082 await importHistory(twitterUsername, mapping.bskyIdentifier, limit, dryRun, false, backfillReq.requestId); 2083 + updateAppStatus({ 2084 + state: 'backfilling', 2085 + currentAccount: twitterUsername, 2086 + processedCount: i + 1, 2087 + totalCount: accountCount, 2088 + message: `Completed ${i + 1}/${accountCount} for ${mapping.bskyIdentifier}`, 2089 + backfillMappingId: mapping.id, 2090 + backfillRequestId: backfillReq.requestId, 2091 + }); 2073 2092 } catch (err) { 2074 2093 console.error(`❌ Error backfilling ${twitterUsername}:`, err); 2075 2094 } ··· 2077 2096 clearBackfill(mapping.id, backfillReq.requestId); 2078 2097 updateAppStatus({ 2079 2098 state: 'idle', 2099 + processedCount: accountCount, 2100 + totalCount: accountCount, 2080 2101 message: `Backfill complete for ${mapping.bskyIdentifier}`, 2081 2102 backfillMappingId: undefined, 2082 2103 backfillRequestId: undefined, ··· 2263 2284 2264 2285 // Concurrency limit for processing accounts 2265 2286 const runLimit = pLimit(3); 2287 + let deferredScheduledRun = false; 2266 2288 2267 2289 // Main loop 2268 2290 while (true) { ··· 2270 2292 const config = getConfig(); // Reload config to get new mappings/settings 2271 2293 const nextTime = getNextCheckTime(); 2272 2294 2273 - // Check if it's time for a scheduled run OR if we have pending backfills 2274 - const isScheduledRun = now >= nextTime; 2295 + const isScheduledRunDue = now >= nextTime; 2275 2296 const pendingBackfills = getPendingBackfills(); 2297 + const shouldRunScheduledCycle = isScheduledRunDue || (deferredScheduledRun && pendingBackfills.length === 0); 2276 2298 2277 - if (isScheduledRun) { 2278 - console.log(`[${new Date().toISOString()}] ⏰ Scheduled check triggered.`); 2279 - updateLastCheckTime(); 2299 + if (isScheduledRunDue && pendingBackfills.length > 0) { 2300 + deferredScheduledRun = true; 2280 2301 } 2281 2302 2282 - const tasks: Promise<void>[] = []; 2283 - 2284 2303 if (pendingBackfills.length > 0) { 2285 - const [nextBackfill, ...rest] = pendingBackfills; 2304 + const estimatedPendingTweets = pendingBackfills.reduce((total, backfill) => { 2305 + const mapping = findMappingById(config.mappings, backfill.id); 2306 + const accountCount = mapping ? Math.max(1, mapping.twitterUsernames.length) : 1; 2307 + const limit = backfill.limit || 15; 2308 + return total + accountCount * limit; 2309 + }, 0); 2310 + 2311 + updateAppStatus({ 2312 + state: 'backfilling', 2313 + message: `Backfill queue priority: ${pendingBackfills.length} job(s), ~${estimatedPendingTweets} tweets pending`, 2314 + }); 2315 + 2316 + const [nextBackfill] = pendingBackfills; 2286 2317 if (nextBackfill) { 2287 2318 const mapping = findMappingById(config.mappings, nextBackfill.id); 2288 2319 if (mapping && mapping.enabled) { 2289 - console.log(`[Scheduler] 🚧 Backfill priority: ${mapping.bskyIdentifier}`); 2320 + const limit = nextBackfill.limit || 15; 2321 + console.log( 2322 + `[Scheduler] 🚧 Backfill priority 1/${pendingBackfills.length}: ${mapping.bskyIdentifier} (limit ${limit})`, 2323 + ); 2290 2324 await runAccountTask(mapping, nextBackfill, options.dryRun); 2291 2325 } else { 2292 2326 clearBackfill(nextBackfill.id, nextBackfill.requestId); 2293 2327 } 2294 2328 } 2295 - if (pendingBackfills.length === 0 && getPendingBackfills().length === 0) { 2329 + 2330 + const remainingBackfills = getPendingBackfills(); 2331 + if (remainingBackfills.length === 0) { 2296 2332 updateAppStatus({ 2297 2333 state: 'idle', 2298 - message: 'Backfill queue empty', 2334 + message: deferredScheduledRun || isScheduledRunDue ? 'Backfill queue complete. Scheduled checks next.' : 'Backfill queue empty', 2299 2335 backfillMappingId: undefined, 2300 2336 backfillRequestId: undefined, 2301 2337 }); 2302 2338 } 2339 + 2340 + await new Promise((resolve) => setTimeout(resolve, 2000)); 2341 + } else if (shouldRunScheduledCycle) { 2342 + console.log( 2343 + deferredScheduledRun && !isScheduledRunDue 2344 + ? `[${new Date().toISOString()}] ⏰ Running deferred scheduled checks after backfill queue.` 2345 + : `[${new Date().toISOString()}] ⏰ Scheduled check triggered.`, 2346 + ); 2347 + 2348 + deferredScheduledRun = false; 2303 2349 updateLastCheckTime(); 2304 - } else if (isScheduledRun) { 2350 + 2351 + const tasks: Promise<void>[] = []; 2305 2352 for (const mapping of config.mappings) { 2306 2353 if (!mapping.enabled) continue; 2307 2354 ··· 2315 2362 if (tasks.length > 0) { 2316 2363 await Promise.all(tasks); 2317 2364 console.log(`[Scheduler] ✅ All tasks for this cycle complete.`); 2365 + } else { 2366 + console.log('[Scheduler] ℹ️ No enabled mappings found for scheduled cycle.'); 2318 2367 } 2319 2368 2320 2369 updateAppStatus({ state: 'idle', message: 'Scheduled checks complete' });
+218 -2
src/server.ts
··· 1 1 import fs from 'node:fs'; 2 2 import path from 'node:path'; 3 3 import { fileURLToPath } from 'node:url'; 4 + import { execSync, spawn } from 'node:child_process'; 4 5 import axios from 'axios'; 5 6 import bcrypt from 'bcryptjs'; 6 7 import cors from 'cors'; ··· 17 18 const app = express(); 18 19 const PORT = Number(process.env.PORT) || 3000; 19 20 const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret'; 20 - const WEB_DIST_DIR = path.join(__dirname, '..', 'web', 'dist'); 21 - const LEGACY_PUBLIC_DIR = path.join(__dirname, '..', 'public'); 21 + const APP_ROOT_DIR = path.join(__dirname, '..'); 22 + const WEB_DIST_DIR = path.join(APP_ROOT_DIR, 'web', 'dist'); 23 + const LEGACY_PUBLIC_DIR = path.join(APP_ROOT_DIR, 'public'); 24 + const PACKAGE_JSON_PATH = path.join(APP_ROOT_DIR, 'package.json'); 25 + const UPDATE_SCRIPT_PATH = path.join(APP_ROOT_DIR, 'update.sh'); 26 + const UPDATE_LOG_DIR = path.join(APP_ROOT_DIR, 'data'); 22 27 const staticAssetsDir = fs.existsSync(path.join(WEB_DIST_DIR, 'index.html')) ? WEB_DIST_DIR : LEGACY_PUBLIC_DIR; 23 28 const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app'; 24 29 const POST_VIEW_CACHE_TTL_MS = 60_000; 25 30 const PROFILE_CACHE_TTL_MS = 5 * 60_000; 26 31 const RESERVED_UNGROUPED_KEY = 'ungrouped'; 32 + const SERVER_STARTED_AT = Date.now(); 27 33 28 34 interface CacheEntry<T> { 29 35 value: T; ··· 88 94 score: number; 89 95 } 90 96 97 + interface RuntimeVersionInfo { 98 + version: string; 99 + commit?: string; 100 + branch?: string; 101 + startedAt: number; 102 + } 103 + 104 + interface UpdateJobState { 105 + running: boolean; 106 + pid?: number; 107 + startedAt?: number; 108 + startedBy?: string; 109 + finishedAt?: number; 110 + exitCode?: number | null; 111 + signal?: NodeJS.Signals | null; 112 + logFile?: string; 113 + } 114 + 115 + interface UpdateStatusPayload { 116 + running: boolean; 117 + pid?: number; 118 + startedAt?: number; 119 + startedBy?: string; 120 + finishedAt?: number; 121 + exitCode?: number | null; 122 + signal?: NodeJS.Signals | null; 123 + logFile?: string; 124 + logTail: string[]; 125 + } 126 + 91 127 const postViewCache = new Map<string, CacheEntry<any>>(); 92 128 const profileCache = new Map<string, CacheEntry<BskyProfileView>>(); 93 129 ··· 160 196 } 161 197 } 162 198 199 + function safeExec(command: string, cwd = APP_ROOT_DIR): string | undefined { 200 + try { 201 + return execSync(command, { 202 + cwd, 203 + stdio: ['ignore', 'pipe', 'ignore'], 204 + encoding: 'utf8', 205 + }).trim(); 206 + } catch { 207 + return undefined; 208 + } 209 + } 210 + 211 + function getRuntimeVersionInfo(): RuntimeVersionInfo { 212 + let version = 'unknown'; 213 + try { 214 + const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')); 215 + if (typeof pkg?.version === 'string' && pkg.version.trim().length > 0) { 216 + version = pkg.version.trim(); 217 + } 218 + } catch { 219 + // Ignore parse/read failures and keep fallback. 220 + } 221 + 222 + return { 223 + version, 224 + commit: safeExec('git rev-parse --short HEAD'), 225 + branch: safeExec('git rev-parse --abbrev-ref HEAD'), 226 + startedAt: SERVER_STARTED_AT, 227 + }; 228 + } 229 + 230 + function isProcessAlive(pid?: number): boolean { 231 + if (!pid || pid <= 0) return false; 232 + try { 233 + process.kill(pid, 0); 234 + return true; 235 + } catch { 236 + return false; 237 + } 238 + } 239 + 240 + function readLogTail(logFile?: string, maxLines = 30): string[] { 241 + if (!logFile || !fs.existsSync(logFile)) { 242 + return []; 243 + } 244 + 245 + try { 246 + const raw = fs.readFileSync(logFile, 'utf8'); 247 + const lines = raw.split(/\r?\n/).filter((line) => line.length > 0); 248 + return lines.slice(-maxLines); 249 + } catch { 250 + return []; 251 + } 252 + } 253 + 163 254 function extractMediaFromEmbed(embed: any): EnrichedPostMedia[] { 164 255 if (!embed || typeof embed !== 'object') { 165 256 return []; ··· 388 479 lastUpdate: Date.now(), 389 480 }; 390 481 482 + let updateJobState: UpdateJobState = { 483 + running: false, 484 + }; 485 + 391 486 app.use(cors()); 392 487 app.use(express.json()); 393 488 ··· 415 510 next(); 416 511 }; 417 512 513 + function reconcileUpdateJobState() { 514 + if (!updateJobState.running) { 515 + return; 516 + } 517 + 518 + if (isProcessAlive(updateJobState.pid)) { 519 + return; 520 + } 521 + 522 + updateJobState = { 523 + ...updateJobState, 524 + running: false, 525 + finishedAt: updateJobState.finishedAt || Date.now(), 526 + exitCode: updateJobState.exitCode ?? null, 527 + signal: updateJobState.signal ?? null, 528 + }; 529 + } 530 + 531 + function getUpdateStatusPayload(): UpdateStatusPayload { 532 + reconcileUpdateJobState(); 533 + return { 534 + ...updateJobState, 535 + logTail: readLogTail(updateJobState.logFile), 536 + }; 537 + } 538 + 539 + function startUpdateJob(startedBy: string): { ok: true; state: UpdateStatusPayload } | { ok: false; message: string } { 540 + reconcileUpdateJobState(); 541 + 542 + if (updateJobState.running) { 543 + return { ok: false, message: 'Update already running.' }; 544 + } 545 + 546 + if (!fs.existsSync(UPDATE_SCRIPT_PATH)) { 547 + return { ok: false, message: 'update.sh not found in app root.' }; 548 + } 549 + 550 + fs.mkdirSync(UPDATE_LOG_DIR, { recursive: true }); 551 + const logFile = path.join(UPDATE_LOG_DIR, `update-${Date.now()}.log`); 552 + const logFd = fs.openSync(logFile, 'a'); 553 + fs.writeSync(logFd, `[${new Date().toISOString()}] Update requested by ${startedBy}\n`); 554 + 555 + try { 556 + const child = spawn('bash', [UPDATE_SCRIPT_PATH], { 557 + cwd: APP_ROOT_DIR, 558 + detached: true, 559 + stdio: ['ignore', logFd, logFd], 560 + env: process.env, 561 + }); 562 + 563 + updateJobState = { 564 + running: true, 565 + pid: child.pid, 566 + startedAt: Date.now(), 567 + startedBy, 568 + logFile, 569 + finishedAt: undefined, 570 + exitCode: undefined, 571 + signal: undefined, 572 + }; 573 + 574 + child.on('error', (error) => { 575 + fs.appendFileSync(logFile, `[${new Date().toISOString()}] Failed to launch updater: ${error.message}\n`); 576 + updateJobState = { 577 + ...updateJobState, 578 + running: false, 579 + finishedAt: Date.now(), 580 + exitCode: 1, 581 + }; 582 + }); 583 + 584 + child.on('exit', (code, signal) => { 585 + const success = code === 0; 586 + fs.appendFileSync( 587 + logFile, 588 + `[${new Date().toISOString()}] Updater exited (${success ? 'success' : 'failure'}) code=${code ?? 'null'} signal=${signal ?? 'null'}\n`, 589 + ); 590 + updateJobState = { 591 + ...updateJobState, 592 + running: false, 593 + finishedAt: Date.now(), 594 + exitCode: code ?? null, 595 + signal: signal ?? null, 596 + }; 597 + }); 598 + 599 + child.unref(); 600 + return { ok: true, state: getUpdateStatusPayload() }; 601 + } catch (error) { 602 + return { ok: false, message: `Failed to start update process: ${(error as Error).message}` }; 603 + } finally { 604 + fs.closeSync(logFd); 605 + } 606 + } 607 + 418 608 // --- Auth Routes --- 419 609 420 610 app.post('/api/register', async (req, res) => { ··· 803 993 position: index + 1, 804 994 })), 805 995 currentStatus: currentAppStatus, 996 + }); 997 + }); 998 + 999 + app.get('/api/version', authenticateToken, (_req, res) => { 1000 + res.json(getRuntimeVersionInfo()); 1001 + }); 1002 + 1003 + app.get('/api/update-status', authenticateToken, requireAdmin, (_req, res) => { 1004 + res.json(getUpdateStatusPayload()); 1005 + }); 1006 + 1007 + app.post('/api/update', authenticateToken, requireAdmin, (req: any, res) => { 1008 + const startedBy = typeof req.user?.email === 'string' && req.user.email.length > 0 ? req.user.email : 'admin'; 1009 + const result = startUpdateJob(startedBy); 1010 + if (!result.ok) { 1011 + const message = result.message; 1012 + const statusCode = message === 'Update already running.' ? 409 : 500; 1013 + res.status(statusCode).json({ error: message }); 1014 + return; 1015 + } 1016 + 1017 + res.json({ 1018 + success: true, 1019 + message: 'Update started. Service may restart automatically.', 1020 + status: result.state, 1021 + version: getRuntimeVersionInfo(), 806 1022 }); 807 1023 }); 808 1024
+158 -6
web/src/App.tsx
··· 20 20 Play, 21 21 Plus, 22 22 Quote, 23 + RefreshCw, 23 24 Repeat2, 24 25 Save, 25 26 Settings2, ··· 202 203 interface AuthUser { 203 204 email: string; 204 205 isAdmin: boolean; 206 + } 207 + 208 + interface RuntimeVersionInfo { 209 + version: string; 210 + commit?: string; 211 + branch?: string; 212 + startedAt: number; 213 + } 214 + 215 + interface UpdateStatusInfo { 216 + running: boolean; 217 + pid?: number; 218 + startedAt?: number; 219 + startedBy?: string; 220 + finishedAt?: number; 221 + exitCode?: number | null; 222 + signal?: string | null; 223 + logFile?: string; 224 + logTail?: string[]; 205 225 } 206 226 207 227 interface Notice { ··· 545 565 const [aiConfig, setAiConfig] = useState<AIConfig>({ provider: 'gemini', apiKey: '', model: '', baseUrl: '' }); 546 566 const [recentActivity, setRecentActivity] = useState<ActivityLog[]>([]); 547 567 const [status, setStatus] = useState<StatusResponse | null>(null); 568 + const [runtimeVersion, setRuntimeVersion] = useState<RuntimeVersionInfo | null>(null); 569 + const [updateStatus, setUpdateStatus] = useState<UpdateStatusInfo | null>(null); 548 570 const [countdown, setCountdown] = useState('--'); 549 571 const [activeTab, setActiveTab] = useState<DashboardTab>(() => { 550 572 const fromPath = getTabFromPath(window.location.pathname); ··· 600 622 const [notice, setNotice] = useState<Notice | null>(null); 601 623 602 624 const [isBusy, setIsBusy] = useState(false); 625 + const [isUpdateBusy, setIsUpdateBusy] = useState(false); 603 626 const [authError, setAuthError] = useState(''); 604 627 605 628 const noticeTimerRef = useRef<number | null>(null); ··· 628 651 setEnrichedPosts([]); 629 652 setProfilesByActor({}); 630 653 setStatus(null); 654 + setRuntimeVersion(null); 655 + setUpdateStatus(null); 631 656 setRecentActivity([]); 632 657 setEditingMapping(null); 633 658 setNewTwitterUsers([]); ··· 644 669 setIsSearchingLocalPosts(false); 645 670 setGroupDraftsByKey({}); 646 671 setIsGroupActionBusy(false); 672 + setIsUpdateBusy(false); 647 673 postsSearchRequestRef.current = 0; 648 674 setAuthView('login'); 649 675 }, []); ··· 711 737 } 712 738 }, [authHeaders, handleAuthFailure]); 713 739 740 + const fetchRuntimeVersion = useCallback(async () => { 741 + if (!authHeaders) { 742 + return; 743 + } 744 + 745 + try { 746 + const response = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 747 + setRuntimeVersion(response.data); 748 + } catch (error) { 749 + handleAuthFailure(error, 'Failed to fetch app version.'); 750 + } 751 + }, [authHeaders, handleAuthFailure]); 752 + 753 + const fetchUpdateStatus = useCallback(async () => { 754 + if (!authHeaders || !isAdmin) { 755 + return; 756 + } 757 + 758 + try { 759 + const response = await axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }); 760 + setUpdateStatus(response.data); 761 + } catch (error) { 762 + handleAuthFailure(error, 'Failed to fetch update status.'); 763 + } 764 + }, [authHeaders, handleAuthFailure, isAdmin]); 765 + 714 766 const fetchProfiles = useCallback( 715 767 async (actors: string[]) => { 716 768 if (!authHeaders) { ··· 755 807 setMe(profile); 756 808 setMappings(mappingData); 757 809 setGroups(groupData); 810 + const versionResponse = await axios.get<RuntimeVersionInfo>('/api/version', { headers: authHeaders }); 811 + setRuntimeVersion(versionResponse.data); 758 812 759 813 if (profile.isAdmin) { 760 - const [twitterResponse, aiResponse] = await Promise.all([ 814 + const [twitterResponse, aiResponse, updateStatusResponse] = await Promise.all([ 761 815 axios.get<TwitterConfig>('/api/twitter-config', { headers: authHeaders }), 762 816 axios.get<AIConfig>('/api/ai-config', { headers: authHeaders }), 817 + axios.get<UpdateStatusInfo>('/api/update-status', { headers: authHeaders }), 763 818 ]); 764 819 765 820 setTwitterConfig({ ··· 775 830 model: aiResponse.data.model || '', 776 831 baseUrl: aiResponse.data.baseUrl || '', 777 832 }); 833 + setUpdateStatus(updateStatusResponse.data); 834 + } else { 835 + setUpdateStatus(null); 778 836 } 779 837 780 838 await Promise.all([fetchStatus(), fetchRecentActivity(), fetchEnrichedPosts()]); ··· 875 933 window.clearInterval(postsInterval); 876 934 }; 877 935 }, [token, fetchEnrichedPosts, fetchRecentActivity, fetchStatus]); 936 + 937 + useEffect(() => { 938 + if (!token) { 939 + return; 940 + } 941 + 942 + const versionInterval = window.setInterval(() => { 943 + void fetchRuntimeVersion(); 944 + if (isAdmin) { 945 + void fetchUpdateStatus(); 946 + } 947 + }, 15000); 948 + 949 + return () => { 950 + window.clearInterval(versionInterval); 951 + }; 952 + }, [token, isAdmin, fetchRuntimeVersion, fetchUpdateStatus]); 878 953 879 954 useEffect(() => { 880 955 if (!status?.nextCheckTime) { ··· 1290 1365 1291 1366 const themeLabel = 1292 1367 themeMode === 'system' ? `Theme: system (${resolvedTheme})` : `Theme: ${themeMode}`; 1368 + const runtimeVersionLabel = runtimeVersion 1369 + ? `v${runtimeVersion.version}${runtimeVersion.commit ? ` (${runtimeVersion.commit})` : ''}` 1370 + : 'v--'; 1371 + const runtimeBranchLabel = runtimeVersion?.branch ? `branch ${runtimeVersion.branch}` : null; 1372 + const updateStateLabel = updateStatus?.running 1373 + ? 'Update in progress' 1374 + : updateStatus?.finishedAt 1375 + ? updateStatus.exitCode === 0 1376 + ? 'Last update succeeded' 1377 + : 'Last update failed' 1378 + : 'No update run recorded'; 1293 1379 1294 1380 const handleLogin = async (event: React.FormEvent<HTMLFormElement>) => { 1295 1381 event.preventDefault(); ··· 1906 1992 } 1907 1993 }; 1908 1994 1995 + const handleRunUpdate = async () => { 1996 + if (!authHeaders || !isAdmin) { 1997 + return; 1998 + } 1999 + 2000 + const confirmed = window.confirm( 2001 + 'Run ./update.sh now? The service may restart automatically after update completes.', 2002 + ); 2003 + if (!confirmed) { 2004 + return; 2005 + } 2006 + 2007 + setIsUpdateBusy(true); 2008 + try { 2009 + const response = await axios.post<{ message?: string }>('/api/update', {}, { headers: authHeaders }); 2010 + showNotice('info', response.data?.message || 'Update started. Service may restart soon.'); 2011 + await Promise.all([fetchRuntimeVersion(), fetchUpdateStatus()]); 2012 + } catch (error) { 2013 + handleAuthFailure(error, 'Failed to start update.'); 2014 + } finally { 2015 + setIsUpdateBusy(false); 2016 + } 2017 + }; 2018 + 1909 2019 if (!token) { 1910 2020 return ( 1911 2021 <main className="flex min-h-screen items-center justify-center p-4"> ··· 1970 2080 <p className="flex items-center gap-2 text-sm text-muted-foreground"> 1971 2081 <Clock3 className="h-4 w-4" /> 1972 2082 Next run in <span className="font-mono text-foreground">{countdown}</span> 2083 + </p> 2084 + <p className="text-xs text-muted-foreground"> 2085 + Version <span className="font-mono text-foreground">{runtimeVersionLabel}</span> 2086 + {runtimeBranchLabel ? <span className="ml-2">{runtimeBranchLabel}</span> : null} 1973 2087 </p> 1974 2088 </div> 1975 2089 ··· 3044 3158 </CardTitle> 3045 3159 <CardDescription>Configured sections stay collapsed so adding accounts is one click.</CardDescription> 3046 3160 </CardHeader> 3047 - <CardContent className="pt-0"> 3048 - <Button className="w-full sm:w-auto" onClick={openAddAccountSheet}> 3049 - <Plus className="mr-2 h-4 w-4" /> 3050 - Add Account 3051 - </Button> 3161 + <CardContent className="space-y-4 pt-0"> 3162 + <div className="rounded-lg border border-border/70 bg-muted/20 p-3"> 3163 + <div className="flex flex-wrap items-start justify-between gap-3"> 3164 + <div className="space-y-1"> 3165 + <p className="text-sm font-semibold">Running Version</p> 3166 + <p className="font-mono text-sm text-foreground">{runtimeVersionLabel}</p> 3167 + {runtimeBranchLabel ? ( 3168 + <p className="text-xs text-muted-foreground">{runtimeBranchLabel}</p> 3169 + ) : null} 3170 + <p className="text-xs text-muted-foreground">{updateStateLabel}</p> 3171 + </div> 3172 + <div className="flex flex-wrap gap-2"> 3173 + <Button 3174 + variant="outline" 3175 + onClick={() => { 3176 + void handleRunUpdate(); 3177 + }} 3178 + disabled={isUpdateBusy || updateStatus?.running} 3179 + > 3180 + {isUpdateBusy || updateStatus?.running ? ( 3181 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3182 + ) : ( 3183 + <RefreshCw className="mr-2 h-4 w-4" /> 3184 + )} 3185 + {updateStatus?.running ? 'Updating...' : 'Update'} 3186 + </Button> 3187 + <Button className="w-full sm:w-auto" onClick={openAddAccountSheet}> 3188 + <Plus className="mr-2 h-4 w-4" /> 3189 + Add Account 3190 + </Button> 3191 + </div> 3192 + </div> 3193 + {updateStatus?.logTail && updateStatus.logTail.length > 0 ? ( 3194 + <details className="mt-3"> 3195 + <summary className="cursor-pointer text-xs font-medium text-muted-foreground"> 3196 + Update log 3197 + </summary> 3198 + <pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"> 3199 + {updateStatus.logTail.join('\n')} 3200 + </pre> 3201 + </details> 3202 + ) : null} 3203 + </div> 3052 3204 </CardContent> 3053 3205 </Card> 3054 3206