@hienlh/ppm 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/web/assets/{button-KIZetva8.js → button-CvHWF07y.js} +1 -1
  3. package/dist/web/assets/{chat-tab-D7dR7kbZ.js → chat-tab-C4ovA2w4.js} +3 -3
  4. package/dist/web/assets/{code-editor-r8P6Gk4M.js → code-editor-BgiyQO-M.js} +1 -1
  5. package/dist/web/assets/{dialog-D8ulRTfX.js → dialog-f3IZM-6v.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-vSvrem_i.js → diff-viewer-8_asmBRZ.js} +1 -1
  7. package/dist/web/assets/{dist-C4W3AGh3.js → dist-CCBctnax.js} +1 -1
  8. package/dist/web/assets/{git-graph-Cn-s1k0-.js → git-graph-BiyTIbCz.js} +1 -1
  9. package/dist/web/assets/{git-status-panel-QjAQzNAi.js → git-status-panel-BifyO31N.js} +1 -1
  10. package/dist/web/assets/index-DILaVO6p.css +2 -0
  11. package/dist/web/assets/index-DasstYgw.js +11 -0
  12. package/dist/web/assets/project-list-C7L3hZct.js +1 -0
  13. package/dist/web/assets/settings-tab-Cn5Ja0_J.js +1 -0
  14. package/dist/web/assets/{terminal-tab-DDf6S-Tu.js → terminal-tab-CyjhG4Ao.js} +1 -1
  15. package/dist/web/index.html +8 -8
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +3 -2
  18. package/src/cli/commands/init.ts +11 -1
  19. package/src/cli/commands/logs.ts +58 -0
  20. package/src/cli/commands/report.ts +60 -0
  21. package/src/cli/commands/status.ts +31 -22
  22. package/src/cli/commands/stop.ts +37 -29
  23. package/src/index.ts +21 -1
  24. package/src/server/index.ts +147 -65
  25. package/src/types/config.ts +2 -0
  26. package/src/version.ts +7 -0
  27. package/src/web/app.tsx +8 -0
  28. package/src/web/components/auth/login-screen.tsx +8 -1
  29. package/src/web/components/layout/sidebar.tsx +15 -0
  30. package/src/web/components/ui/sonner.tsx +9 -6
  31. package/src/web/hooks/use-health-check.ts +95 -0
  32. package/src/web/stores/settings-store.ts +19 -0
  33. package/src/web/stores/tab-store.ts +28 -8
  34. package/dist/web/assets/index-DUBI96T5.css +0 -2
  35. package/dist/web/assets/index-nk1dAWff.js +0 -10
  36. package/dist/web/assets/project-list-DqiatpaH.js +0 -1
  37. package/dist/web/assets/settings-tab-iCGeFFdt.js +0 -1
  38. /package/dist/web/assets/{dist-PA84y4Ga.js → dist-B6sG2GPc.js} +0 -0
  39. /package/dist/web/assets/{react-BSLFEYu8.js → react-gOPBns57.js} +0 -0
  40. /package/dist/web/assets/{utils-DpJF9mAi.js → utils-61GRB9Cb.js} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import { configService } from "../services/config.service.ts";
4
+ import { VERSION } from "../version.ts";
4
5
  import { authMiddleware } from "./middleware/auth.ts";
5
6
  import { projectRoutes } from "./routes/projects.ts";
6
7
  import { settingsRoutes } from "./routes/settings.ts";
@@ -10,13 +11,85 @@ import { terminalWebSocket } from "./ws/terminal.ts";
10
11
  import { chatWebSocket } from "./ws/chat.ts";
11
12
  import { ok } from "../types/api.ts";
12
13
 
14
+ /** Tee console.log/error to ~/.ppm/ppm.log while preserving terminal output */
15
+ async function setupLogFile() {
16
+ const { resolve } = await import("node:path");
17
+ const { homedir } = await import("node:os");
18
+ const { appendFileSync, mkdirSync, existsSync } = await import("node:fs");
19
+
20
+ const ppmDir = resolve(homedir(), ".ppm");
21
+ if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
22
+ const logPath = resolve(ppmDir, "ppm.log");
23
+
24
+ const origLog = console.log.bind(console);
25
+ const origError = console.error.bind(console);
26
+ const origWarn = console.warn.bind(console);
27
+
28
+ /** Redact tokens, passwords, API keys, and other sensitive values from log output */
29
+ const redact = (text: string): string =>
30
+ text
31
+ .replace(/Token:\s*\S+/gi, "Token: [REDACTED]")
32
+ .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
33
+ .replace(/password['":\s]+\S+/gi, "password: [REDACTED]")
34
+ .replace(/api[_-]?key['":\s]+\S+/gi, "api_key: [REDACTED]")
35
+ .replace(/ANTHROPIC_API_KEY=\S+/gi, "ANTHROPIC_API_KEY=[REDACTED]")
36
+ .replace(/secret['":\s]+\S+/gi, "secret: [REDACTED]");
37
+
38
+ const writeLog = (level: string, args: unknown[]) => {
39
+ const ts = new Date().toISOString();
40
+ const msg = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
41
+ try { appendFileSync(logPath, `[${ts}] [${level}] ${redact(msg)}\n`); } catch {}
42
+ };
43
+
44
+ console.log = (...args: unknown[]) => { origLog(...args); writeLog("INFO", args); };
45
+ console.error = (...args: unknown[]) => { origError(...args); writeLog("ERROR", args); };
46
+ console.warn = (...args: unknown[]) => { origWarn(...args); writeLog("WARN", args); };
47
+
48
+ // Capture uncaught errors
49
+ process.on("uncaughtException", (err) => {
50
+ writeLog("FATAL", [`Uncaught exception: ${err.stack ?? err.message}`]);
51
+ });
52
+ process.on("unhandledRejection", (reason) => {
53
+ writeLog("FATAL", [`Unhandled rejection: ${reason}`]);
54
+ });
55
+ }
56
+
13
57
  export const app = new Hono();
14
58
 
15
59
  // CORS for dev
16
60
  app.use("*", cors());
17
61
 
18
- // Health check (before auth)
62
+ // Public endpoints (before auth)
19
63
  app.get("/api/health", (c) => c.json(ok({ status: "running" })));
64
+ app.get("/api/info", (c) => c.json(ok({
65
+ version: VERSION,
66
+ device_name: configService.get("device_name") || null,
67
+ })));
68
+
69
+ // Public: recent logs for bug reports (last 30 lines)
70
+ app.get("/api/logs/recent", async (c) => {
71
+ const { resolve } = await import("node:path");
72
+ const { homedir } = await import("node:os");
73
+ const { existsSync, readFileSync } = await import("node:fs");
74
+ const logFile = resolve(homedir(), ".ppm", "ppm.log");
75
+ if (!existsSync(logFile)) return c.json(ok({ logs: "" }));
76
+ const content = readFileSync(logFile, "utf-8");
77
+ const lines = content.split("\n").slice(-30).join("\n").trim();
78
+ // Double-redact in case old logs have unredacted content
79
+ const redacted = lines
80
+ .replace(/Token:\s*\S+/gi, "Token: [REDACTED]")
81
+ .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
82
+ .replace(/password['":\s]+\S+/gi, "password: [REDACTED]")
83
+ .replace(/api[_-]?key['":\s]+\S+/gi, "api_key: [REDACTED]")
84
+ .replace(/ANTHROPIC_API_KEY=\S+/gi, "ANTHROPIC_API_KEY=[REDACTED]")
85
+ .replace(/secret['":\s]+\S+/gi, "secret: [REDACTED]");
86
+ return c.json(ok({ logs: redacted }));
87
+ });
88
+
89
+ // Dev-only: crash endpoint for testing health check UI
90
+ if (process.env.NODE_ENV !== "production") {
91
+ app.get("/api/debug/crash", () => { process.exit(1); });
92
+ }
20
93
 
21
94
  // Auth check endpoint (behind auth middleware)
22
95
  app.use("/api/*", authMiddleware);
@@ -42,6 +115,9 @@ export async function startServer(options: {
42
115
  const port = parseInt(options.port ?? String(configService.get("port")), 10);
43
116
  const host = configService.get("host");
44
117
 
118
+ // Setup log file (both foreground and daemon modes)
119
+ await setupLogFile();
120
+
45
121
  const isDaemon = !options.foreground;
46
122
 
47
123
  if (isDaemon) {
@@ -54,52 +130,87 @@ export async function startServer(options: {
54
130
  const pidFile = resolve(ppmDir, "ppm.pid");
55
131
  const statusFile = resolve(ppmDir, "status.json");
56
132
 
57
- // If --share, download cloudflared in parent (shows progress to user)
133
+ // If --share, download cloudflared and start tunnel as independent process
134
+ let shareUrl: string | undefined;
135
+ let tunnelPid: number | undefined;
58
136
  if (options.share) {
59
137
  const { ensureCloudflared } = await import("../services/cloudflared.service.ts");
60
- await ensureCloudflared();
138
+ const bin = await ensureCloudflared();
139
+
140
+ // Check if tunnel already running (reuse from previous server crash)
141
+ if (existsSync(statusFile)) {
142
+ try {
143
+ const prev = JSON.parse(readFileSync(statusFile, "utf-8"));
144
+ if (prev.tunnelPid && prev.shareUrl) {
145
+ try {
146
+ process.kill(prev.tunnelPid, 0); // Check alive
147
+ console.log(` Reusing existing tunnel (PID: ${prev.tunnelPid})`);
148
+ shareUrl = prev.shareUrl;
149
+ tunnelPid = prev.tunnelPid;
150
+ } catch { /* tunnel dead, spawn new one */ }
151
+ }
152
+ } catch {}
153
+ }
154
+
155
+ // Spawn new tunnel if no existing one
156
+ if (!shareUrl) {
157
+ console.log(" Starting share tunnel...");
158
+ const { openSync: openFd } = await import("node:fs");
159
+ const tunnelLog = resolve(ppmDir, "tunnel.log");
160
+ const tfd = openFd(tunnelLog, "a");
161
+ const tunnelProc = Bun.spawn({
162
+ cmd: [bin, "tunnel", "--url", `http://localhost:${port}`],
163
+ stdio: ["ignore", "ignore", tfd],
164
+ env: process.env,
165
+ });
166
+ tunnelProc.unref();
167
+ tunnelPid = tunnelProc.pid;
168
+
169
+ // Parse URL from tunnel.log (poll stderr output)
170
+ const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
171
+ const pollStart = Date.now();
172
+ while (Date.now() - pollStart < 30_000) {
173
+ await Bun.sleep(500);
174
+ try {
175
+ const logContent = readFileSync(tunnelLog, "utf-8");
176
+ const match = logContent.match(urlRegex);
177
+ if (match) { shareUrl = match[0]; break; }
178
+ } catch {}
179
+ }
180
+ if (!shareUrl) console.warn(" ⚠ Tunnel started but URL not detected.");
181
+ }
61
182
  }
62
183
 
63
- // Spawn child process
184
+ // Spawn server child process with log file
185
+ const { openSync } = await import("node:fs");
186
+ const logFile = resolve(ppmDir, "ppm.log");
187
+ const logFd = openSync(logFile, "a");
64
188
  const child = Bun.spawn({
65
189
  cmd: [
66
190
  process.execPath, "run", import.meta.dir + "/index.ts", "__serve__",
67
- String(port), host, options.config ?? "", options.share ? "share" : "",
191
+ String(port), host, options.config ?? "",
68
192
  ],
69
- stdio: ["ignore", "ignore", "ignore"],
193
+ stdio: ["ignore", logFd, logFd],
70
194
  env: process.env,
71
195
  });
72
196
  child.unref();
73
- writeFileSync(pidFile, String(child.pid));
74
197
 
75
- // Poll for status.json (child writes it when ready)
76
- const startTime = Date.now();
77
- let status: { pid: number; port: number; host: string; shareUrl?: string } | null = null;
78
- while (Date.now() - startTime < 30_000) {
79
- if (existsSync(statusFile)) {
80
- try {
81
- status = JSON.parse(readFileSync(statusFile, "utf-8"));
82
- break;
83
- } catch { /* file not fully written yet */ }
84
- }
85
- await Bun.sleep(200);
86
- }
198
+ // Write status file with both PIDs
199
+ const status = { pid: child.pid, port, host, shareUrl, tunnelPid };
200
+ writeFileSync(statusFile, JSON.stringify(status));
201
+ writeFileSync(pidFile, String(child.pid));
87
202
 
88
- if (status) {
89
- console.log(`\n PPM daemon started (PID: ${status.pid})\n`);
90
- console.log(` ➜ Local: http://localhost:${status.port}/`);
91
- if (status.shareUrl) {
92
- console.log(` ➜ Share: ${status.shareUrl}`);
93
- if (!configService.get("auth").enabled) {
94
- console.log(`\n ⚠ Warning: auth is disabled your IDE is publicly accessible!`);
95
- console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
96
- }
97
- const qr = await import("qrcode-terminal");
98
- console.log();
99
- qr.generate(status.shareUrl, { small: true });
203
+ console.log(`\n PPM v${VERSION} daemon started (PID: ${child.pid})\n`);
204
+ console.log(` ➜ Local: http://localhost:${port}/`);
205
+ if (shareUrl) {
206
+ console.log(` ➜ Share: ${shareUrl}`);
207
+ if (!configService.get("auth").enabled) {
208
+ console.log(`\n ⚠ Warning: auth is disabled — your IDE is publicly accessible!`);
209
+ console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
100
210
  }
101
- } else {
102
- console.log(`\n PPM daemon started (PID: ${child.pid}) but status not confirmed.`);
211
+ const qr = await import("qrcode-terminal");
212
+ console.log();
213
+ qr.generate(shareUrl, { small: true });
103
214
  }
104
215
 
105
216
  process.exit(0);
@@ -157,7 +268,7 @@ export async function startServer(options: {
157
268
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
158
269
  });
159
270
 
160
- console.log(`\n PPM v0.2.0 ready\n`);
271
+ console.log(`\n PPM v${VERSION} ready\n`);
161
272
  console.log(` ➜ Local: http://localhost:${server.port}/`);
162
273
 
163
274
  const { networkInterfaces } = await import("node:os");
@@ -203,16 +314,9 @@ if (process.argv.includes("__serve__")) {
203
314
  const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
204
315
  const host = process.argv[idx + 2] ?? "0.0.0.0";
205
316
  const configPath = process.argv[idx + 3] || undefined;
206
- const shareFlag = process.argv[idx + 4] === "share";
207
317
 
208
318
  configService.load(configPath);
209
-
210
- const { resolve } = await import("node:path");
211
- const { homedir } = await import("node:os");
212
- const { writeFileSync, unlinkSync } = await import("node:fs");
213
-
214
- const statusFile = resolve(homedir(), ".ppm", "status.json");
215
- const pidFile = resolve(homedir(), ".ppm", "ppm.pid");
319
+ await setupLogFile();
216
320
 
217
321
  Bun.serve({
218
322
  port,
@@ -264,27 +368,5 @@ if (process.argv.includes("__serve__")) {
264
368
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
265
369
  });
266
370
 
267
- // Start tunnel if --share was passed (eagerly import so cleanup doesn't race)
268
- let shareUrl: string | undefined;
269
- const tunnel = shareFlag
270
- ? (await import("../services/tunnel.service.ts")).tunnelService
271
- : null;
272
- if (tunnel) {
273
- try {
274
- shareUrl = await tunnel.startTunnel(port);
275
- } catch { /* non-fatal: server runs without share URL */ }
276
- }
277
-
278
- // Write status file for parent to read
279
- writeFileSync(statusFile, JSON.stringify({ pid: process.pid, port, host, shareUrl }));
280
-
281
- // Cleanup on exit
282
- const cleanup = () => {
283
- try { unlinkSync(statusFile); } catch {}
284
- try { unlinkSync(pidFile); } catch {}
285
- tunnel?.stopTunnel();
286
- process.exit(0);
287
- };
288
- process.on("SIGINT", cleanup);
289
- process.on("SIGTERM", cleanup);
371
+ console.log(`Server child ready on port ${port}`);
290
372
  }
@@ -1,4 +1,5 @@
1
1
  export interface PpmConfig {
2
+ device_name: string;
2
3
  port: number;
3
4
  host: string;
4
5
  auth: AuthConfig;
@@ -33,6 +34,7 @@ export interface AIProviderConfig {
33
34
  }
34
35
 
35
36
  export const DEFAULT_CONFIG: PpmConfig = {
37
+ device_name: "",
36
38
  port: 8080,
37
39
  host: "0.0.0.0",
38
40
  auth: { enabled: true, token: "" },
package/src/version.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ const pkg = JSON.parse(readFileSync(resolve(import.meta.dir, "../package.json"), "utf-8"));
5
+
6
+ /** App version from package.json — single source of truth */
7
+ export const VERSION: string = pkg.version;
package/src/web/app.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import { getAuthToken } from "@/lib/api-client";
17
17
  import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
18
18
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
19
+ import { useHealthCheck } from "@/hooks/use-health-check";
19
20
  import { CommandPalette } from "@/components/layout/command-palette";
20
21
 
21
22
  type AuthState = "checking" | "authenticated" | "unauthenticated";
@@ -25,6 +26,7 @@ export function App() {
25
26
  const [drawerOpen, setDrawerOpen] = useState(false);
26
27
  const theme = useSettingsStore((s) => s.theme);
27
28
  const fetchProjects = useProjectStore((s) => s.fetchProjects);
29
+ const fetchServerInfo = useSettingsStore((s) => s.fetchServerInfo);
28
30
  const activeProject = useProjectStore((s) => s.activeProject);
29
31
 
30
32
  // Apply theme on mount and when it changes
@@ -40,6 +42,9 @@ export function App() {
40
42
  }
41
43
  }, [theme]);
42
44
 
45
+ // Fetch server info on mount (before auth — shown on login screen)
46
+ useEffect(() => { fetchServerInfo(); }, [fetchServerInfo]);
47
+
43
48
  // Auth check on mount
44
49
  useEffect(() => {
45
50
  async function checkAuth() {
@@ -73,6 +78,9 @@ export function App() {
73
78
  // Global keyboard shortcuts (Shift+Shift → command palette, Alt+[/] → cycle tabs)
74
79
  const { paletteOpen, closePalette } = useGlobalKeybindings();
75
80
 
81
+ // Health check — detects server crash/restart
82
+ useHealthCheck();
83
+
76
84
  // Fetch projects after auth, then restore from URL if applicable
77
85
  useEffect(() => {
78
86
  if (authState !== "authenticated") return;
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
3
3
  import { Input } from "@/components/ui/input";
4
4
  import { setAuthToken } from "@/lib/api-client";
5
5
  import { Lock, AlertCircle } from "lucide-react";
6
+ import { useSettingsStore } from "@/stores/settings-store";
6
7
 
7
8
  interface LoginScreenProps {
8
9
  onSuccess: () => void;
@@ -10,6 +11,7 @@ interface LoginScreenProps {
10
11
 
11
12
  export function LoginScreen({ onSuccess }: LoginScreenProps) {
12
13
  const [token, setToken] = useState("");
14
+ const deviceName = useSettingsStore((s) => s.deviceName);
13
15
  const [error, setError] = useState<string | null>(null);
14
16
  const [loading, setLoading] = useState(false);
15
17
 
@@ -50,8 +52,13 @@ export function LoginScreen({ onSuccess }: LoginScreenProps) {
50
52
  <Lock className="size-6 text-primary" />
51
53
  </div>
52
54
  <h1 className="text-xl font-semibold text-foreground">PPM</h1>
55
+ {deviceName && (
56
+ <p className="text-xs text-text-subtle bg-surface-elevated inline-block px-2 py-0.5 rounded-full">
57
+ {deviceName}
58
+ </p>
59
+ )}
53
60
  <p className="text-sm text-text-secondary">
54
- Enter your auth token to unlock
61
+ Enter your access password to unlock
55
62
  </p>
56
63
  </div>
57
64
 
@@ -9,6 +9,7 @@ import {
9
9
  DropdownMenuSeparator,
10
10
  DropdownMenuTrigger,
11
11
  } from "@/components/ui/dropdown-menu";
12
+ import { useSettingsStore } from "@/stores/settings-store";
12
13
  import { cn } from "@/lib/utils";
13
14
 
14
15
  /** Max projects shown before needing to search (desktop) */
@@ -18,6 +19,8 @@ export function Sidebar() {
18
19
  const { projects, activeProject, setActiveProject, loading } =
19
20
  useProjectStore();
20
21
  const openTab = useTabStore((s) => s.openTab);
22
+ const deviceName = useSettingsStore((s) => s.deviceName);
23
+ const version = useSettingsStore((s) => s.version);
21
24
  const [query, setQuery] = useState("");
22
25
 
23
26
  const sorted = useMemo(() => sortByRecent(projects), [projects]);
@@ -41,6 +44,11 @@ export function Sidebar() {
41
44
  {/* Logo + project dropdown — same height as tab bar */}
42
45
  <div className="flex items-center gap-2 px-3 h-[41px] border-b border-border shrink-0">
43
46
  <span className="text-sm font-bold text-primary tracking-tight shrink-0">PPM</span>
47
+ {deviceName && (
48
+ <span className="text-[10px] text-text-subtle bg-surface-elevated px-1.5 py-0.5 rounded-full truncate max-w-[100px]" title={deviceName}>
49
+ {deviceName}
50
+ </span>
51
+ )}
44
52
 
45
53
  <DropdownMenu onOpenChange={() => setQuery("")}>
46
54
  <DropdownMenuTrigger asChild>
@@ -121,6 +129,13 @@ export function Sidebar() {
121
129
  </p>
122
130
  </div>
123
131
  )}
132
+
133
+ {/* Version footer */}
134
+ {version && (
135
+ <div className="px-3 py-1.5 border-t border-border shrink-0">
136
+ <span className="text-[10px] text-text-subtle">v{version}</span>
137
+ </div>
138
+ )}
124
139
  </aside>
125
140
  );
126
141
  }
@@ -7,15 +7,18 @@ import {
7
7
  OctagonXIcon,
8
8
  TriangleAlertIcon,
9
9
  } from "lucide-react"
10
- import { useTheme } from "next-themes"
11
10
  import { Toaster as Sonner, type ToasterProps } from "sonner"
11
+ import { useSettingsStore } from "@/stores/settings-store"
12
12
 
13
13
  const Toaster = ({ ...props }: ToasterProps) => {
14
- const { theme = "system" } = useTheme()
14
+ const theme = useSettingsStore((s) => s.theme)
15
+ const resolved = theme === "system"
16
+ ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
17
+ : theme
15
18
 
16
19
  return (
17
20
  <Sonner
18
- theme={theme as ToasterProps["theme"]}
21
+ theme={resolved as ToasterProps["theme"]}
19
22
  className="toaster group"
20
23
  icons={{
21
24
  success: <CircleCheckIcon className="size-4" />,
@@ -26,9 +29,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
26
29
  }}
27
30
  style={
28
31
  {
29
- "--normal-bg": "var(--popover)",
30
- "--normal-text": "var(--popover-foreground)",
31
- "--normal-border": "var(--border)",
32
+ "--normal-bg": "var(--color-popover)",
33
+ "--normal-text": "var(--color-popover-foreground)",
34
+ "--normal-border": "var(--color-border)",
32
35
  "--border-radius": "var(--radius)",
33
36
  } as React.CSSProperties
34
37
  }
@@ -0,0 +1,95 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { toast } from "sonner";
3
+
4
+ const POLL_INTERVAL = 5_000;
5
+ const HEALTH_URL = "/api/health";
6
+ const LOGS_URL = "/api/logs/recent";
7
+ const REPO = "hienlh/ppm";
8
+
9
+ /** Fetch recent server logs for bug report */
10
+ async function fetchRecentLogs(): Promise<string> {
11
+ try {
12
+ const res = await fetch(LOGS_URL, { signal: AbortSignal.timeout(3000) });
13
+ const json = await res.json();
14
+ return json.ok ? json.data.logs : "(failed to fetch logs)";
15
+ } catch {
16
+ return "(server logs unavailable)";
17
+ }
18
+ }
19
+
20
+ /** Open GitHub issue pre-filled with crash context + logs */
21
+ async function openBugReport() {
22
+ const logs = await fetchRecentLogs();
23
+ const title = encodeURIComponent("bug: server crashed unexpectedly");
24
+ const body = encodeURIComponent([
25
+ "## Environment",
26
+ `- URL: ${window.location.href}`,
27
+ `- UserAgent: ${navigator.userAgent}`,
28
+ `- Time: ${new Date().toISOString()}`,
29
+ "",
30
+ "## Description",
31
+ "The PPM server went down and restarted unexpectedly.",
32
+ "",
33
+ "## Steps to Reproduce",
34
+ "1. ",
35
+ "",
36
+ "## Recent Server Logs",
37
+ "```",
38
+ logs,
39
+ "```",
40
+ ].join("\n"));
41
+ window.open(`https://github.com/${REPO}/issues/new?title=${title}&body=${body}`, "_blank");
42
+ }
43
+
44
+ /**
45
+ * Periodically pings /api/health. When server goes down and comes back,
46
+ * shows a toast suggesting the user report a bug.
47
+ */
48
+ export function useHealthCheck() {
49
+ const wasDown = useRef(false);
50
+ const isFirstCheck = useRef(true);
51
+
52
+ useEffect(() => {
53
+ let timer: ReturnType<typeof setInterval>;
54
+
55
+ async function check() {
56
+ try {
57
+ const res = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
58
+ if (res.ok) {
59
+ if (wasDown.current && !isFirstCheck.current) {
60
+ toast.warning("Server was restarted", {
61
+ description: "PPM server went down and recovered. If unexpected, please report it.",
62
+ duration: 15_000,
63
+ action: {
64
+ label: "Report Bug",
65
+ onClick: () => openBugReport(),
66
+ },
67
+ });
68
+ wasDown.current = false;
69
+ }
70
+ isFirstCheck.current = false;
71
+ } else {
72
+ wasDown.current = true;
73
+ }
74
+ } catch {
75
+ if (!wasDown.current && !isFirstCheck.current) {
76
+ toast.error("Server unreachable", {
77
+ description: "PPM server is not responding. It may have crashed.",
78
+ duration: 10_000,
79
+ });
80
+ }
81
+ wasDown.current = true;
82
+ }
83
+ }
84
+
85
+ const initialDelay = setTimeout(() => {
86
+ check();
87
+ timer = setInterval(check, POLL_INTERVAL);
88
+ }, POLL_INTERVAL);
89
+
90
+ return () => {
91
+ clearTimeout(initialDelay);
92
+ clearInterval(timer);
93
+ };
94
+ }, []);
95
+ }
@@ -6,7 +6,10 @@ const STORAGE_KEY = "ppm-settings";
6
6
 
7
7
  interface SettingsState {
8
8
  theme: Theme;
9
+ deviceName: string | null;
10
+ version: string | null;
9
11
  setTheme: (theme: Theme) => void;
12
+ fetchServerInfo: () => Promise<void>;
10
13
  }
11
14
 
12
15
  function loadPersistedTheme(): Theme {
@@ -56,10 +59,26 @@ export function applyThemeClass(theme: Theme) {
56
59
 
57
60
  export const useSettingsStore = create<SettingsState>((set) => ({
58
61
  theme: loadPersistedTheme(),
62
+ deviceName: null,
63
+ version: null,
59
64
 
60
65
  setTheme: (theme) => {
61
66
  persistTheme(theme);
62
67
  applyThemeClass(theme);
63
68
  set({ theme });
64
69
  },
70
+
71
+ fetchServerInfo: async () => {
72
+ try {
73
+ const res = await fetch("/api/info");
74
+ const json = await res.json();
75
+ if (json.ok) {
76
+ const { device_name, version } = json.data;
77
+ set({ deviceName: device_name || null, version: version || null });
78
+ if (device_name) {
79
+ document.title = `PPM — ${device_name}`;
80
+ }
81
+ }
82
+ } catch {}
83
+ },
65
84
  }));