icebear0828 Claude Opus 4.6 (1M context) commited on
Commit
236dd56
·
1 Parent(s): f19ccae

feat: SSE update progress + reliable restart on Windows

Browse files

- apply-update returns SSE stream with step-by-step progress
(pull → install → build → restart)
- Frontend parses SSE, shows progress steps with checkmarks in modal
- Restart helper writes temp .restart-helper.cjs file instead of -e
inline script (fixes Windows command escaping issues)
- RESTART_TIMEOUT increased from 30s to 120s
- Frontend falls back to restart polling if connection drops mid-stream

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

shared/hooks/use-update-status.ts CHANGED
@@ -46,7 +46,13 @@ export interface CheckResult {
46
  }
47
 
48
  const RESTART_POLL_INTERVAL = 2000;
49
- const RESTART_TIMEOUT = 30000;
 
 
 
 
 
 
50
 
51
  export function useUpdateStatus() {
52
  const [status, setStatus] = useState<UpdateStatus | null>(null);
@@ -56,6 +62,7 @@ export function useUpdateStatus() {
56
  const [applying, setApplying] = useState(false);
57
  const [restarting, setRestarting] = useState(false);
58
  const [restartFailed, setRestartFailed] = useState(false);
 
59
  const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
60
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61
 
@@ -138,26 +145,83 @@ export function useUpdateStatus() {
138
  const applyUpdate = useCallback(async () => {
139
  setApplying(true);
140
  setError(null);
 
141
  try {
142
  const resp = await fetch("/admin/apply-update", { method: "POST" });
143
- const data = await resp.json() as { started: boolean; restarting?: boolean; error?: string };
144
- if (!resp.ok || !data.started) {
145
- setError(data.error ?? "Apply failed");
 
 
 
 
 
146
  setApplying(false);
147
- } else if (data.restarting) {
148
- // Server will restart — start polling for it to come back
 
 
 
 
 
149
  setApplying(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  startRestartPolling();
151
- } else {
152
- // Update succeeded but no auto-restart
153
- setApplying(false);
154
  await load();
155
  }
156
  } catch (err) {
157
- setError(err instanceof Error ? err.message : "Network error");
158
  setApplying(false);
 
159
  }
160
- }, [load, startRestartPolling]);
161
 
162
- return { status, checking, result, error, checkForUpdate, applyUpdate, applying, restarting, restartFailed };
163
  }
 
46
  }
47
 
48
  const RESTART_POLL_INTERVAL = 2000;
49
+ const RESTART_TIMEOUT = 120000;
50
+
51
+ export interface UpdateStep {
52
+ step: string;
53
+ status: "running" | "done" | "error";
54
+ detail?: string;
55
+ }
56
 
57
  export function useUpdateStatus() {
58
  const [status, setStatus] = useState<UpdateStatus | null>(null);
 
62
  const [applying, setApplying] = useState(false);
63
  const [restarting, setRestarting] = useState(false);
64
  const [restartFailed, setRestartFailed] = useState(false);
65
+ const [updateSteps, setUpdateSteps] = useState<UpdateStep[]>([]);
66
  const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
67
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
 
 
145
  const applyUpdate = useCallback(async () => {
146
  setApplying(true);
147
  setError(null);
148
+ setUpdateSteps([]);
149
  try {
150
  const resp = await fetch("/admin/apply-update", { method: "POST" });
151
+ if (!resp.ok) {
152
+ const text = await resp.text();
153
+ try {
154
+ const data = JSON.parse(text) as { error?: string };
155
+ setError(data.error ?? "Apply failed");
156
+ } catch {
157
+ setError(text || "Apply failed");
158
+ }
159
  setApplying(false);
160
+ return;
161
+ }
162
+
163
+ // Parse SSE stream
164
+ const reader = resp.body?.getReader();
165
+ if (!reader) {
166
+ setError("No response body");
167
  setApplying(false);
168
+ return;
169
+ }
170
+
171
+ const decoder = new TextDecoder();
172
+ let buffer = "";
173
+ let shouldRestart = false;
174
+
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done) break;
178
+ buffer += decoder.decode(value, { stream: true });
179
+
180
+ // Parse SSE lines
181
+ const lines = buffer.split("\n");
182
+ buffer = lines.pop() ?? "";
183
+ for (const line of lines) {
184
+ if (!line.startsWith("data: ")) continue;
185
+ try {
186
+ const data = JSON.parse(line.slice(6)) as Record<string, unknown>;
187
+ if (data.step && data.status) {
188
+ setUpdateSteps((prev) => {
189
+ const existing = prev.findIndex((s) => s.step === data.step);
190
+ const entry: UpdateStep = {
191
+ step: data.step as string,
192
+ status: data.status as UpdateStep["status"],
193
+ detail: data.detail as string | undefined,
194
+ };
195
+ if (existing >= 0) {
196
+ const next = [...prev];
197
+ next[existing] = entry;
198
+ return next;
199
+ }
200
+ return [...prev, entry];
201
+ });
202
+ }
203
+ if (data.done) {
204
+ shouldRestart = !!(data.restarting);
205
+ if (!data.started) {
206
+ setError((data.error as string) ?? "Apply failed");
207
+ }
208
+ }
209
+ } catch { /* skip malformed SSE */ }
210
+ }
211
+ }
212
+
213
+ setApplying(false);
214
+ if (shouldRestart) {
215
  startRestartPolling();
216
+ } else if (!error) {
 
 
217
  await load();
218
  }
219
  } catch (err) {
220
+ // Connection lost during update server may be restarting
221
  setApplying(false);
222
+ startRestartPolling();
223
  }
224
+ }, [load, startRestartPolling, error]);
225
 
226
+ return { status, checking, result, error, checkForUpdate, applyUpdate, applying, restarting, restartFailed, updateSteps };
227
  }
shared/i18n/translations.ts CHANGED
@@ -152,6 +152,9 @@ export const translations = {
152
  downloadUpdate: "Download",
153
  updateTitle: "Update Available",
154
  updateRestarting: "Restarting server...",
 
 
 
155
  restartFailed: "Server not responding. Please restart manually.",
156
  close: "Close",
157
  settings: "Settings",
@@ -326,6 +329,9 @@ export const translations = {
326
  downloadUpdate: "\u4e0b\u8f7d",
327
  updateTitle: "\u6709\u53ef\u7528\u66f4\u65b0",
328
  updateRestarting: "\u6b63\u5728\u91cd\u542f\u670d\u52a1...",
 
 
 
329
  restartFailed: "\u670d\u52a1\u5668\u672a\u54cd\u5e94\uff0c\u8bf7\u624b\u52a8\u91cd\u542f\u3002",
330
  close: "\u5173\u95ed",
331
  settings: "\u8bbe\u7f6e",
 
152
  downloadUpdate: "Download",
153
  updateTitle: "Update Available",
154
  updateRestarting: "Restarting server...",
155
+ updatePulling: "Pulling code...",
156
+ updateInstalling: "Installing dependencies...",
157
+ updateBuilding: "Building...",
158
  restartFailed: "Server not responding. Please restart manually.",
159
  close: "Close",
160
  settings: "Settings",
 
329
  downloadUpdate: "\u4e0b\u8f7d",
330
  updateTitle: "\u6709\u53ef\u7528\u66f4\u65b0",
331
  updateRestarting: "\u6b63\u5728\u91cd\u542f\u670d\u52a1...",
332
+ updatePulling: "\u62c9\u53d6\u4ee3\u7801...",
333
+ updateInstalling: "\u5b89\u88c5\u4f9d\u8d56...",
334
+ updateBuilding: "\u7f16\u8bd1\u4e2d...",
335
  restartFailed: "\u670d\u52a1\u5668\u672a\u54cd\u5e94\uff0c\u8bf7\u624b\u52a8\u91cd\u542f\u3002",
336
  close: "\u5173\u95ed",
337
  settings: "\u8bbe\u7f6e",
src/self-update.ts CHANGED
@@ -6,7 +6,7 @@
6
  */
7
 
8
  import { execFile, execFileSync, spawn } from "child_process";
9
- import { existsSync, readFileSync } from "fs";
10
  import { resolve } from "path";
11
  import { promisify } from "util";
12
  import { getRootDir, isEmbedded } from "./paths.js";
@@ -27,35 +27,43 @@ export function setCloseHandler(handler: () => Promise<void>): void {
27
  function hardRestart(cwd: string): void {
28
  const nodeExe = process.argv[0];
29
  const serverArgs = process.argv.slice(1);
30
-
31
- // Inline script: wait for the port to be free, then start the real server
32
- const helperScript = `
33
- const net = require("net");
34
- const { spawn } = require("child_process");
35
- const args = ${JSON.stringify(serverArgs)};
36
- const cwd = ${JSON.stringify(cwd)};
37
- let attempts = 0;
38
- function tryStart() {
39
- attempts++;
40
- const s = net.createServer();
41
- s.once("error", () => {
42
- if (attempts >= 20) process.exit(1);
43
- setTimeout(tryStart, 500);
44
- });
45
- s.once("listening", () => {
46
- s.close(() => {
47
- spawn(${JSON.stringify(nodeExe)}, args, { detached: true, stdio: "ignore", cwd }).unref();
48
- process.exit(0);
49
- });
50
- });
51
- s.listen(${getPort()});
52
- }
53
- tryStart();
54
- `;
 
 
 
 
 
 
 
55
 
56
  const doRestart = () => {
57
- console.log("[SelfUpdate] Spawning restart helper and exiting...");
58
- spawn(nodeExe, ["-e", helperScript], {
 
59
  detached: true,
60
  stdio: "ignore",
61
  cwd,
 
6
  */
7
 
8
  import { execFile, execFileSync, spawn } from "child_process";
9
+ import { existsSync, readFileSync, writeFileSync } from "fs";
10
  import { resolve } from "path";
11
  import { promisify } from "util";
12
  import { getRootDir, isEmbedded } from "./paths.js";
 
27
  function hardRestart(cwd: string): void {
28
  const nodeExe = process.argv[0];
29
  const serverArgs = process.argv.slice(1);
30
+ const port = getPort();
31
+
32
+ // Write a temporary CJS helper script that waits for the port to be free, then starts the server
33
+ const helperPath = resolve(cwd, ".restart-helper.cjs");
34
+ const helperContent = [
35
+ `const net = require("net");`,
36
+ `const { spawn, execSync } = require("child_process");`,
37
+ `const fs = require("fs");`,
38
+ `const nodeExe = ${JSON.stringify(nodeExe)};`,
39
+ `const args = ${JSON.stringify(serverArgs)};`,
40
+ `const cwd = ${JSON.stringify(cwd)};`,
41
+ `const port = ${port};`,
42
+ `let attempts = 0;`,
43
+ `function tryStart() {`,
44
+ ` attempts++;`,
45
+ ` const s = net.createServer();`,
46
+ ` s.once("error", () => {`,
47
+ ` if (attempts >= 20) { cleanup(); process.exit(1); }`,
48
+ ` setTimeout(tryStart, 500);`,
49
+ ` });`,
50
+ ` s.once("listening", () => {`,
51
+ ` s.close(() => {`,
52
+ ` spawn(nodeExe, args, { detached: true, stdio: "ignore", cwd }).unref();`,
53
+ ` cleanup();`,
54
+ ` process.exit(0);`,
55
+ ` });`,
56
+ ` });`,
57
+ ` s.listen(port);`,
58
+ `}`,
59
+ `function cleanup() { try { fs.unlinkSync(${JSON.stringify(helperPath)}); } catch {} }`,
60
+ `tryStart();`,
61
+ ].join("\n");
62
 
63
  const doRestart = () => {
64
+ console.log("[SelfUpdate] Writing restart helper and exiting...");
65
+ writeFileSync(helperPath, helperContent, "utf-8");
66
+ spawn(nodeExe, [helperPath], {
67
  detached: true,
68
  stdio: "ignore",
69
  cwd,
web/src/App.tsx CHANGED
@@ -160,6 +160,7 @@ function Dashboard() {
160
  applying={update.applying}
161
  restarting={update.restarting}
162
  restartFailed={update.restartFailed}
 
163
  />
164
  )}
165
  </>
 
160
  applying={update.applying}
161
  restarting={update.restarting}
162
  restartFailed={update.restartFailed}
163
+ updateSteps={update.updateSteps}
164
  />
165
  )}
166
  </>
web/src/components/UpdateModal.tsx CHANGED
@@ -1,5 +1,14 @@
1
  import { useEffect, useRef } from "preact/hooks";
2
  import { useI18n } from "../../../shared/i18n/context";
 
 
 
 
 
 
 
 
 
3
 
4
  interface UpdateModalProps {
5
  open: boolean;
@@ -11,6 +20,7 @@ interface UpdateModalProps {
11
  applying: boolean;
12
  restarting: boolean;
13
  restartFailed: boolean;
 
14
  }
15
 
16
  export function UpdateModal({
@@ -23,6 +33,7 @@ export function UpdateModal({
23
  applying,
24
  restarting,
25
  restartFailed,
 
26
  }: UpdateModalProps) {
27
  const { t } = useI18n();
28
  const dialogRef = useRef<HTMLDialogElement>(null);
@@ -80,7 +91,31 @@ export function UpdateModal({
80
 
81
  {/* Body */}
82
  <div class="px-5 py-4">
83
- {restarting ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  <div class="flex flex-col items-center gap-3 py-6">
85
  <svg class="size-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
86
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
 
1
  import { useEffect, useRef } from "preact/hooks";
2
  import { useI18n } from "../../../shared/i18n/context";
3
+ import type { UpdateStep } from "../../../shared/hooks/use-update-status";
4
+ import type { TranslationKey } from "../../../shared/i18n/translations";
5
+
6
+ const STEP_LABELS: Record<string, TranslationKey> = {
7
+ pull: "updatePulling",
8
+ install: "updateInstalling",
9
+ build: "updateBuilding",
10
+ restart: "updateRestarting",
11
+ };
12
 
13
  interface UpdateModalProps {
14
  open: boolean;
 
20
  applying: boolean;
21
  restarting: boolean;
22
  restartFailed: boolean;
23
+ updateSteps?: UpdateStep[];
24
  }
25
 
26
  export function UpdateModal({
 
33
  applying,
34
  restarting,
35
  restartFailed,
36
+ updateSteps = [],
37
  }: UpdateModalProps) {
38
  const { t } = useI18n();
39
  const dialogRef = useRef<HTMLDialogElement>(null);
 
91
 
92
  {/* Body */}
93
  <div class="px-5 py-4">
94
+ {(applying || restarting) && updateSteps.length > 0 ? (
95
+ <div class="space-y-2 py-2">
96
+ {updateSteps.map((s) => (
97
+ <div key={s.step} class="flex items-center gap-3 text-sm">
98
+ {s.status === "done" ? (
99
+ <svg class="size-5 text-primary shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
100
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
101
+ </svg>
102
+ ) : s.status === "error" ? (
103
+ <svg class="size-5 text-red-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
104
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
105
+ </svg>
106
+ ) : (
107
+ <svg class="size-5 animate-spin text-primary shrink-0" viewBox="0 0 24 24" fill="none">
108
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
109
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
110
+ </svg>
111
+ )}
112
+ <span class={s.status === "done" ? "text-slate-500 dark:text-text-dim" : "text-slate-700 dark:text-text-main font-medium"}>
113
+ {t(STEP_LABELS[s.step] ?? ("updateBuilding" as TranslationKey))}
114
+ </span>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ ) : restarting ? (
119
  <div class="flex flex-col items-center gap-3 py-6">
120
  <svg class="size-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
121
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />