@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.
- package/CHANGELOG.md +53 -0
- package/dist/web/assets/{button-KIZetva8.js → button-CvHWF07y.js} +1 -1
- package/dist/web/assets/{chat-tab-D7dR7kbZ.js → chat-tab-C4ovA2w4.js} +3 -3
- package/dist/web/assets/{code-editor-r8P6Gk4M.js → code-editor-BgiyQO-M.js} +1 -1
- package/dist/web/assets/{dialog-D8ulRTfX.js → dialog-f3IZM-6v.js} +1 -1
- package/dist/web/assets/{diff-viewer-vSvrem_i.js → diff-viewer-8_asmBRZ.js} +1 -1
- package/dist/web/assets/{dist-C4W3AGh3.js → dist-CCBctnax.js} +1 -1
- package/dist/web/assets/{git-graph-Cn-s1k0-.js → git-graph-BiyTIbCz.js} +1 -1
- package/dist/web/assets/{git-status-panel-QjAQzNAi.js → git-status-panel-BifyO31N.js} +1 -1
- package/dist/web/assets/index-DILaVO6p.css +2 -0
- package/dist/web/assets/index-DasstYgw.js +11 -0
- package/dist/web/assets/project-list-C7L3hZct.js +1 -0
- package/dist/web/assets/settings-tab-Cn5Ja0_J.js +1 -0
- package/dist/web/assets/{terminal-tab-DDf6S-Tu.js → terminal-tab-CyjhG4Ao.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/sw.js +1 -1
- package/package.json +3 -2
- package/src/cli/commands/init.ts +11 -1
- package/src/cli/commands/logs.ts +58 -0
- package/src/cli/commands/report.ts +60 -0
- package/src/cli/commands/status.ts +31 -22
- package/src/cli/commands/stop.ts +37 -29
- package/src/index.ts +21 -1
- package/src/server/index.ts +147 -65
- package/src/types/config.ts +2 -0
- package/src/version.ts +7 -0
- package/src/web/app.tsx +8 -0
- package/src/web/components/auth/login-screen.tsx +8 -1
- package/src/web/components/layout/sidebar.tsx +15 -0
- package/src/web/components/ui/sonner.tsx +9 -6
- package/src/web/hooks/use-health-check.ts +95 -0
- package/src/web/stores/settings-store.ts +19 -0
- package/src/web/stores/tab-store.ts +28 -8
- package/dist/web/assets/index-DUBI96T5.css +0 -2
- package/dist/web/assets/index-nk1dAWff.js +0 -10
- package/dist/web/assets/project-list-DqiatpaH.js +0 -1
- package/dist/web/assets/settings-tab-iCGeFFdt.js +0 -1
- /package/dist/web/assets/{dist-PA84y4Ga.js → dist-B6sG2GPc.js} +0 -0
- /package/dist/web/assets/{react-BSLFEYu8.js → react-gOPBns57.js} +0 -0
- /package/dist/web/assets/{utils-DpJF9mAi.js → utils-61GRB9Cb.js} +0 -0
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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 ?? "",
|
|
191
|
+
String(port), host, options.config ?? "",
|
|
68
192
|
],
|
|
69
|
-
stdio: ["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
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
console.log(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
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={
|
|
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
|
}));
|