@nightkatana/kronosys-app 1.0.0-beta.14 → 1.0.0-beta.16
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/app/api/restore/route.ts +151 -0
- package/app/changelog/page.tsx +69 -4
- package/app/implementation/page.tsx +12 -1
- package/app/page.tsx +121 -50
- package/app/settings/page.tsx +130 -2
- package/bin/kronosys.mjs +6 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +46 -21
- package/components/dashboard/SessionListPanel.tsx +11 -4
- package/components/dashboard/TaskFocusPanel.tsx +314 -312
- package/lib/dashboardCopy.ts +6 -6
- package/lib/generatedUserChangelog.ts +29 -0
- package/lib/implementationNotes.ts +8 -2
- package/lib/reportingAggregate.ts +1 -0
- package/lib/settingsCopy.ts +44 -0
- package/lib/userGuideCopy.ts +13 -11
- package/next-env.d.ts +1 -1
- package/package.json +3 -2
- package/server/actionDispatch.ts +2 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
|
|
5
|
+
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
6
|
+
import { ensureDataDirectory } from "@/lib/dataDir";
|
|
7
|
+
import { getSqlite, resetSqliteConnection } from "@/server/db";
|
|
8
|
+
import { writePayload } from "@/server/payloadStore";
|
|
9
|
+
|
|
10
|
+
export const runtime = "nodejs";
|
|
11
|
+
|
|
12
|
+
const DB_NAME = "kronosys.sqlite";
|
|
13
|
+
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
function nowStamp(): string {
|
|
16
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replaceAll(":", "-");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
20
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validatePayloadShape(v: unknown): v is KronosysUpdatePayload {
|
|
24
|
+
if (!isRecord(v)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return typeof v.viewType === "string" && v.viewType.trim().length > 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cleanSqliteSidecars(dbPath: string): void {
|
|
31
|
+
for (const ext of [".wal", ".shm"]) {
|
|
32
|
+
const p = `${dbPath}${ext}`;
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(p)) {
|
|
35
|
+
fs.unlinkSync(p);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
/* ignore */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function restoreFromSqliteBuffer(buf: Buffer): { backupPath: string | null } {
|
|
44
|
+
const dir = ensureDataDirectory();
|
|
45
|
+
const dbPath = path.join(dir, DB_NAME);
|
|
46
|
+
const tmpPath = `${dbPath}.restore-tmp`;
|
|
47
|
+
const backupPath = `${dbPath}.pre-restore-${nowStamp()}.bak`;
|
|
48
|
+
let createdBackup: string | null = null;
|
|
49
|
+
|
|
50
|
+
resetSqliteConnection();
|
|
51
|
+
if (fs.existsSync(dbPath)) {
|
|
52
|
+
fs.copyFileSync(dbPath, backupPath);
|
|
53
|
+
createdBackup = backupPath;
|
|
54
|
+
}
|
|
55
|
+
cleanSqliteSidecars(dbPath);
|
|
56
|
+
fs.writeFileSync(tmpPath, buf);
|
|
57
|
+
fs.renameSync(tmpPath, dbPath);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const db = getSqlite();
|
|
61
|
+
db.pragma("integrity_check");
|
|
62
|
+
db.exec("SELECT 1;");
|
|
63
|
+
} catch (error) {
|
|
64
|
+
resetSqliteConnection();
|
|
65
|
+
if (createdBackup && fs.existsSync(createdBackup)) {
|
|
66
|
+
fs.copyFileSync(createdBackup, dbPath);
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { backupPath: createdBackup };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function POST(req: Request) {
|
|
75
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
76
|
+
const isMultipart = ctype.toLowerCase().includes("multipart/form-data");
|
|
77
|
+
if (!isMultipart) {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ ok: false, error: "unsupported_content_type" },
|
|
80
|
+
{ status: 415 },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const form = await req.formData();
|
|
85
|
+
const file = form.get("file");
|
|
86
|
+
const formatField = form.get("format");
|
|
87
|
+
const formatRaw =
|
|
88
|
+
typeof formatField === "string" ? formatField.trim().toLowerCase() : "";
|
|
89
|
+
if (!(file instanceof File)) {
|
|
90
|
+
return NextResponse.json({ ok: false, error: "file_required" }, { status: 400 });
|
|
91
|
+
}
|
|
92
|
+
if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) {
|
|
93
|
+
return NextResponse.json(
|
|
94
|
+
{ ok: false, error: "invalid_file_size", maxBytes: MAX_UPLOAD_BYTES },
|
|
95
|
+
{ status: 400 },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const lowerName = file.name.toLowerCase();
|
|
100
|
+
let format: "json" | "sqlite";
|
|
101
|
+
if (formatRaw === "json" || formatRaw === "sqlite") {
|
|
102
|
+
format = formatRaw;
|
|
103
|
+
} else {
|
|
104
|
+
format = lowerName.endsWith(".json") ? "json" : "sqlite";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (format === "json") {
|
|
108
|
+
const text = await file.text();
|
|
109
|
+
let parsed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(text);
|
|
112
|
+
} catch {
|
|
113
|
+
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
if (!validatePayloadShape(parsed)) {
|
|
116
|
+
return NextResponse.json(
|
|
117
|
+
{ ok: false, error: "invalid_kronosys_payload" },
|
|
118
|
+
{ status: 400 },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
writePayload(parsed);
|
|
122
|
+
return NextResponse.json({ ok: true, restored: "json" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bytes = await file.arrayBuffer();
|
|
126
|
+
const buf = Buffer.from(bytes);
|
|
127
|
+
if (buf.length < 1024) {
|
|
128
|
+
return NextResponse.json(
|
|
129
|
+
{ ok: false, error: "sqlite_too_small" },
|
|
130
|
+
{ status: 400 },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const header = buf.subarray(0, 16).toString("utf8");
|
|
134
|
+
if (!header.startsWith("SQLite format 3")) {
|
|
135
|
+
return NextResponse.json(
|
|
136
|
+
{ ok: false, error: "invalid_sqlite_file" },
|
|
137
|
+
{ status: 400 },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const { backupPath } = restoreFromSqliteBuffer(buf);
|
|
143
|
+
return NextResponse.json({ ok: true, restored: "sqlite", backupPath });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
146
|
+
return NextResponse.json(
|
|
147
|
+
{ ok: false, error: "restore_failed", detail },
|
|
148
|
+
{ status: 500 },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
package/app/changelog/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Suspense, useMemo } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { useSearchParams } from "next/navigation";
|
|
6
|
+
import ReactMarkdown, { type Components } from "react-markdown";
|
|
6
7
|
import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
|
|
7
8
|
import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
|
|
8
9
|
import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
|
|
@@ -18,6 +19,55 @@ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
|
|
|
18
19
|
|
|
19
20
|
type LiveShape = { language?: string };
|
|
20
21
|
|
|
22
|
+
type ChangelogItemGroup = {
|
|
23
|
+
heading: string;
|
|
24
|
+
items: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const CHANGELOG_MARKDOWN_COMPONENTS: Components = {
|
|
28
|
+
p: ({ children }) => <span>{children}</span>,
|
|
29
|
+
a: ({ children, href }) => (
|
|
30
|
+
<a
|
|
31
|
+
href={href}
|
|
32
|
+
className="text-violet-700 underline decoration-violet-400/80 underline-offset-2 hover:text-violet-900 dark:text-violet-300 dark:decoration-violet-500/60 dark:hover:text-violet-200"
|
|
33
|
+
target="_blank"
|
|
34
|
+
rel="noopener noreferrer"
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</a>
|
|
38
|
+
),
|
|
39
|
+
code: ({ children }) => (
|
|
40
|
+
<code className="rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[0.82em] dark:bg-zinc-700/80">
|
|
41
|
+
{children}
|
|
42
|
+
</code>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function groupChangelogItems(items: string[]): ChangelogItemGroup[] {
|
|
47
|
+
const groups: ChangelogItemGroup[] = [];
|
|
48
|
+
const indexByHeading = new Map<string, number>();
|
|
49
|
+
const fallbackHeading = "Notes";
|
|
50
|
+
|
|
51
|
+
for (const raw of items) {
|
|
52
|
+
const item = raw.trim();
|
|
53
|
+
const m = /^([^—:]+)\s*[—:]\s*(.+)$/u.exec(item);
|
|
54
|
+
const heading = m?.[1]?.trim() || fallbackHeading;
|
|
55
|
+
const content = m?.[2]?.trim() || item;
|
|
56
|
+
if (!content) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const existingIndex = indexByHeading.get(heading);
|
|
60
|
+
if (existingIndex !== undefined) {
|
|
61
|
+
groups[existingIndex].items.push(content);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
indexByHeading.set(heading, groups.length);
|
|
65
|
+
groups.push({ heading, items: [content] });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return groups;
|
|
69
|
+
}
|
|
70
|
+
|
|
21
71
|
function ChangelogBody() {
|
|
22
72
|
const searchParams = useSearchParams();
|
|
23
73
|
const dashboardSessionNavId = searchParams.get("session");
|
|
@@ -90,11 +140,26 @@ function ChangelogBody() {
|
|
|
90
140
|
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
|
91
141
|
v{entry.version}
|
|
92
142
|
</h2>
|
|
93
|
-
<
|
|
94
|
-
{entry.items.map((
|
|
95
|
-
<
|
|
143
|
+
<div className="mt-3 space-y-3">
|
|
144
|
+
{groupChangelogItems(entry.items).map((group) => (
|
|
145
|
+
<div key={group.heading} className="space-y-1.5">
|
|
146
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
147
|
+
{group.heading}
|
|
148
|
+
</h3>
|
|
149
|
+
<ul className="list-inside list-disc space-y-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
|
|
150
|
+
{group.items.map((item) => (
|
|
151
|
+
<li key={`${group.heading}:${item}`}>
|
|
152
|
+
<ReactMarkdown
|
|
153
|
+
components={CHANGELOG_MARKDOWN_COMPONENTS}
|
|
154
|
+
>
|
|
155
|
+
{item}
|
|
156
|
+
</ReactMarkdown>
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
</div>
|
|
96
161
|
))}
|
|
97
|
-
</
|
|
162
|
+
</div>
|
|
98
163
|
</section>
|
|
99
164
|
))
|
|
100
165
|
)}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { useSearchParams } from "next/navigation";
|
|
6
|
-
import { Check, FileCode2, X } from "lucide-react";
|
|
6
|
+
import { Check, FileCode2, ScrollText, X } from "lucide-react";
|
|
7
7
|
import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
|
|
8
8
|
import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
|
|
9
9
|
import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
|
|
@@ -153,6 +153,17 @@ function ImplementationBody() {
|
|
|
153
153
|
<div className="flex w-full justify-end">
|
|
154
154
|
<div className={appShellHeaderToolbarClassName}>
|
|
155
155
|
<AppShellCommandCenterPlaceholder />
|
|
156
|
+
<Link
|
|
157
|
+
href={withDashboardSessionParam(
|
|
158
|
+
"/changelog",
|
|
159
|
+
dashboardSessionNavId,
|
|
160
|
+
)}
|
|
161
|
+
className="inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-zinc-400 hover:bg-zinc-50 hover:text-violet-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:border-zinc-600 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-violet-300"
|
|
162
|
+
aria-label={notes.openChangelogAria}
|
|
163
|
+
title={notes.openChangelogTooltip}
|
|
164
|
+
>
|
|
165
|
+
<ScrollText size={18} />
|
|
166
|
+
</Link>
|
|
156
167
|
<AppShellRouteNav
|
|
157
168
|
current="implementation"
|
|
158
169
|
labels={nav}
|
package/app/page.tsx
CHANGED
|
@@ -232,6 +232,7 @@ function DashboardHome() {
|
|
|
232
232
|
>("keep");
|
|
233
233
|
const [tourOpen, setTourOpen] = useState(false);
|
|
234
234
|
const [newSessionModalOpen, setNewSessionModalOpen] = useState(false);
|
|
235
|
+
const pendingNewSessionFocusRef = useRef(false);
|
|
235
236
|
const [gitBannerDismissed, setGitBannerDismissed] = useState(false);
|
|
236
237
|
const [gitIdentitySetupModalOpen, setGitIdentitySetupModalOpen] =
|
|
237
238
|
useState(false);
|
|
@@ -894,6 +895,32 @@ function DashboardHome() {
|
|
|
894
895
|
});
|
|
895
896
|
}, []);
|
|
896
897
|
|
|
898
|
+
const focusTaskLauncherInput = useCallback(() => {
|
|
899
|
+
document.getElementById("dashboard-col-tasks")?.scrollIntoView({
|
|
900
|
+
behavior: "smooth",
|
|
901
|
+
block: "start",
|
|
902
|
+
});
|
|
903
|
+
const tryFocus = () => {
|
|
904
|
+
const input = document.getElementById(
|
|
905
|
+
"kronosys-task-launcher-input",
|
|
906
|
+
) as HTMLInputElement | null;
|
|
907
|
+
if (!input) {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
input.focus();
|
|
911
|
+
input.select();
|
|
912
|
+
return true;
|
|
913
|
+
};
|
|
914
|
+
globalThis.requestAnimationFrame(() => {
|
|
915
|
+
if (tryFocus()) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
globalThis.setTimeout(() => {
|
|
919
|
+
void tryFocus();
|
|
920
|
+
}, 140);
|
|
921
|
+
});
|
|
922
|
+
}, []);
|
|
923
|
+
|
|
897
924
|
const scrollToTaskInPanel = useCallback((taskId: string) => {
|
|
898
925
|
const el =
|
|
899
926
|
document.getElementById(`kronosys-active-task-${taskId}`) ??
|
|
@@ -1209,6 +1236,45 @@ function DashboardHome() {
|
|
|
1209
1236
|
await refresh();
|
|
1210
1237
|
}, [pathname, refresh, router, searchParams, sessionQueryMode]);
|
|
1211
1238
|
|
|
1239
|
+
useEffect(() => {
|
|
1240
|
+
if (!pendingNewSessionFocusRef.current) {
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const liveSid =
|
|
1244
|
+
typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
|
|
1245
|
+
if (!liveSid) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
pendingNewSessionFocusRef.current = false;
|
|
1249
|
+
focusTaskLauncherInput();
|
|
1250
|
+
}, [focusTaskLauncherInput, live?.sessionId]);
|
|
1251
|
+
|
|
1252
|
+
const createNewSessionAndFocus = useCallback(
|
|
1253
|
+
async (sessionScope: unknown) => {
|
|
1254
|
+
setNewSessionModalOpen(false);
|
|
1255
|
+
pendingNewSessionFocusRef.current = true;
|
|
1256
|
+
await post({ type: "newSession", sessionScope });
|
|
1257
|
+
if (sessionQueryMode) {
|
|
1258
|
+
router.replace(
|
|
1259
|
+
pathnameWithUpdatedSessionQuery(
|
|
1260
|
+
pathname,
|
|
1261
|
+
searchParams.toString(),
|
|
1262
|
+
null,
|
|
1263
|
+
),
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
focusTaskLauncherInput();
|
|
1267
|
+
},
|
|
1268
|
+
[
|
|
1269
|
+
focusTaskLauncherInput,
|
|
1270
|
+
pathname,
|
|
1271
|
+
post,
|
|
1272
|
+
router,
|
|
1273
|
+
searchParams,
|
|
1274
|
+
sessionQueryMode,
|
|
1275
|
+
],
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1212
1278
|
const openSessionInNewTab = useCallback(
|
|
1213
1279
|
(sessionId: string) => {
|
|
1214
1280
|
const url = `${
|
|
@@ -1576,7 +1642,7 @@ function DashboardHome() {
|
|
|
1576
1642
|
<div className="grid w-full grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,2.25fr)_minmax(0,1fr)] xl:items-start xl:gap-x-6 2xl:gap-x-10">
|
|
1577
1643
|
<div
|
|
1578
1644
|
id="dashboard-col-sessions"
|
|
1579
|
-
className="flex min-w-0 flex-col xl:
|
|
1645
|
+
className="flex min-w-0 flex-col xl:min-h-0 xl:overflow-visible xl:pb-6 xl:pr-3 2xl:pr-4"
|
|
1580
1646
|
>
|
|
1581
1647
|
<div className="w-full min-w-0 xl:flex xl:min-h-0 xl:flex-1 xl:flex-col">
|
|
1582
1648
|
<div className="mx-auto flex w-full max-w-md flex-col gap-8 sm:max-w-lg xl:mx-0 xl:max-w-none xl:min-h-0 xl:flex-1 xl:flex-col">
|
|
@@ -1611,53 +1677,59 @@ function DashboardHome() {
|
|
|
1611
1677
|
? renderSelectedSessionSidebarCard()
|
|
1612
1678
|
: null}
|
|
1613
1679
|
|
|
1614
|
-
<
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
sessionDurationAlertThresholdMinutes
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1680
|
+
<div className="min-h-[14rem] xl:h-full xl:min-h-0 xl:flex-1">
|
|
1681
|
+
<SessionListPanel
|
|
1682
|
+
sessions={sessionListPanelSessions}
|
|
1683
|
+
lang={lang}
|
|
1684
|
+
displayTimeZone={dashboardDisplayTimeZone}
|
|
1685
|
+
use24HourClock={dashboardUse24HourClock}
|
|
1686
|
+
liveSessionId={live?.sessionId}
|
|
1687
|
+
selectedSessionId={selectedSessionId}
|
|
1688
|
+
t={dt}
|
|
1689
|
+
onSelectSession={(id) => void handleSelectSession(id)}
|
|
1690
|
+
onOpenSessionInNewTab={openSessionInNewTab}
|
|
1691
|
+
onEndLiveSession={() =>
|
|
1692
|
+
void handleRequestEndLiveSession()
|
|
1693
|
+
}
|
|
1694
|
+
onArchiveSession={confirmArchiveSession}
|
|
1695
|
+
onDeleteSession={(id) => setDeleteSessionId(id)}
|
|
1696
|
+
onOpenArchives={() =>
|
|
1697
|
+
void router.push(
|
|
1698
|
+
withDashboardSessionParam(
|
|
1699
|
+
"/settings#settings-archived-sessions",
|
|
1700
|
+
selectedSessionId,
|
|
1701
|
+
),
|
|
1702
|
+
)
|
|
1703
|
+
}
|
|
1704
|
+
archivedCount={historyArchived.length}
|
|
1705
|
+
mongoPushEnabled={mongoPushEnabled}
|
|
1706
|
+
onPushSessionToMongo={(id) =>
|
|
1707
|
+
void handlePushSessionToMongo(id)
|
|
1708
|
+
}
|
|
1709
|
+
pushingSessionId={mongoPushBusyId}
|
|
1710
|
+
sessionDurationAlertThresholdMinutes={
|
|
1711
|
+
sessionDurationAlertThresholdMinutes
|
|
1712
|
+
}
|
|
1713
|
+
onOpenSessionGantt={openGanttForSessionId}
|
|
1714
|
+
sessionRowExitAnimateId={sessionRowExitAnimateId}
|
|
1715
|
+
onSessionRowExitAnimationDone={
|
|
1716
|
+
clearSessionRowExitStates
|
|
1717
|
+
}
|
|
1718
|
+
liveChromeExitSessionId={endLiveListExitAnimateId}
|
|
1719
|
+
sortPinSessionId={endLiveListExitAnimateId}
|
|
1720
|
+
sessionDetailInline={
|
|
1721
|
+
archiveSessionInMainHistoryList &&
|
|
1722
|
+
typeof columnArchiveId === "string" &&
|
|
1723
|
+
columnArchiveId.length > 0
|
|
1724
|
+
? {
|
|
1725
|
+
sessionId: columnArchiveId,
|
|
1726
|
+
content: renderSelectedSessionSidebarCard(),
|
|
1727
|
+
}
|
|
1728
|
+
: null
|
|
1729
|
+
}
|
|
1730
|
+
forcePageScroll
|
|
1731
|
+
/>
|
|
1732
|
+
</div>
|
|
1661
1733
|
</div>
|
|
1662
1734
|
</div>
|
|
1663
1735
|
</div>
|
|
@@ -1742,8 +1814,7 @@ function DashboardHome() {
|
|
|
1742
1814
|
t={dt}
|
|
1743
1815
|
onCancel={() => setNewSessionModalOpen(false)}
|
|
1744
1816
|
onConfirm={(sessionScope) => {
|
|
1745
|
-
|
|
1746
|
-
void post({ type: "newSession", sessionScope });
|
|
1817
|
+
void createNewSessionAndFocus(sessionScope);
|
|
1747
1818
|
}}
|
|
1748
1819
|
/>
|
|
1749
1820
|
<GlobalPauseConfirmModal
|
package/app/settings/page.tsx
CHANGED
|
@@ -45,7 +45,6 @@ import { showWorkspaceFoldersEmptyMessage } from "@/lib/usageProfile";
|
|
|
45
45
|
import { readDashboardUse24HourClockFromCfg } from "@/lib/dashboardClockFormat";
|
|
46
46
|
import {
|
|
47
47
|
DASHBOARD_TIME_ZONE_SELECT_OPTIONS,
|
|
48
|
-
isValidIanaTimeZone,
|
|
49
48
|
readDashboardTimeZoneFromCfg,
|
|
50
49
|
} from "@/lib/dashboardTimeZone";
|
|
51
50
|
import { SESSION_NAME_TEMPLATE_CFG_MAX_LEN } from "@/lib/formatSessionNameTemplate";
|
|
@@ -648,7 +647,7 @@ function SettingsToc({
|
|
|
648
647
|
return (
|
|
649
648
|
<nav
|
|
650
649
|
aria-label={s.tocNavAriaLabel}
|
|
651
|
-
className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 lg:max-h-[calc(
|
|
650
|
+
className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-scroll lg:overscroll-contain dark:border-zinc-800 dark:bg-zinc-900/40"
|
|
652
651
|
>
|
|
653
652
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
|
|
654
653
|
{s.tocHeading}
|
|
@@ -952,6 +951,10 @@ function SettingsPageContent() {
|
|
|
952
951
|
>(null);
|
|
953
952
|
const [clearHistoryConfirmOpen, setClearHistoryConfirmOpen] = useState(false);
|
|
954
953
|
const [clearHistoryBusy, setClearHistoryBusy] = useState(false);
|
|
954
|
+
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
|
|
955
|
+
const [restoreBusy, setRestoreBusy] = useState(false);
|
|
956
|
+
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
|
957
|
+
const restoreFileInputRef = useRef<HTMLInputElement | null>(null);
|
|
955
958
|
const [settingsTocFilter, setSettingsTocFilter] = useState("");
|
|
956
959
|
const [settingsTourOpen, setSettingsTourOpen] = useState(false);
|
|
957
960
|
|
|
@@ -1557,6 +1560,56 @@ function SettingsPageContent() {
|
|
|
1557
1560
|
}
|
|
1558
1561
|
}, [refresh, router]);
|
|
1559
1562
|
|
|
1563
|
+
const restoreBackup = useCallback(async () => {
|
|
1564
|
+
if (!restoreFile) {
|
|
1565
|
+
setSettingsDialogAlert(s.dangerRestoreNoFile);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
setRestoreBusy(true);
|
|
1569
|
+
try {
|
|
1570
|
+
const fd = new FormData();
|
|
1571
|
+
fd.set("file", restoreFile);
|
|
1572
|
+
const format = restoreFile.name.toLowerCase().endsWith(".json")
|
|
1573
|
+
? "json"
|
|
1574
|
+
: "sqlite";
|
|
1575
|
+
fd.set("format", format);
|
|
1576
|
+
const res = await fetch("/api/restore", {
|
|
1577
|
+
method: "POST",
|
|
1578
|
+
body: fd,
|
|
1579
|
+
});
|
|
1580
|
+
const text = await res.text();
|
|
1581
|
+
let data: Record<string, unknown> = {};
|
|
1582
|
+
try {
|
|
1583
|
+
data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
1584
|
+
} catch {
|
|
1585
|
+
data = {};
|
|
1586
|
+
}
|
|
1587
|
+
if (!res.ok || data.ok !== true) {
|
|
1588
|
+
const detail =
|
|
1589
|
+
typeof data.detail === "string"
|
|
1590
|
+
? data.detail
|
|
1591
|
+
: typeof data.error === "string"
|
|
1592
|
+
? data.error
|
|
1593
|
+
: text || `HTTP ${res.status}`;
|
|
1594
|
+
setSettingsDialogAlert(
|
|
1595
|
+
`${s.dangerRestoreFailed.replace("{detail}", detail)}`,
|
|
1596
|
+
);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
setRestoreConfirmOpen(false);
|
|
1600
|
+
setRestoreFile(null);
|
|
1601
|
+
if (restoreFileInputRef.current) {
|
|
1602
|
+
restoreFileInputRef.current.value = "";
|
|
1603
|
+
}
|
|
1604
|
+
await refresh({ preserveForm: false, routerInvalidate: true });
|
|
1605
|
+
setSettingsDialogAlert(
|
|
1606
|
+
format === "json" ? s.dangerRestoreDoneJson : s.dangerRestoreDoneSqlite,
|
|
1607
|
+
);
|
|
1608
|
+
} finally {
|
|
1609
|
+
setRestoreBusy(false);
|
|
1610
|
+
}
|
|
1611
|
+
}, [refresh, restoreFile, s]);
|
|
1612
|
+
|
|
1560
1613
|
const applyResetToDefaults = useCallback(async () => {
|
|
1561
1614
|
setResetDefaultsBusy(true);
|
|
1562
1615
|
setSettingsAck(null);
|
|
@@ -3491,6 +3544,64 @@ function SettingsPageContent() {
|
|
|
3491
3544
|
{s.sectionDangerZone}
|
|
3492
3545
|
</h2>
|
|
3493
3546
|
<div className="max-w-xl rounded-xl border border-red-300/90 bg-red-50/80 p-4 dark:border-red-900/55 dark:bg-red-950/30">
|
|
3547
|
+
<h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
|
|
3548
|
+
{s.dangerBackupRestoreTitle}
|
|
3549
|
+
</h3>
|
|
3550
|
+
<p className="mt-2 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
|
|
3551
|
+
{s.dangerBackupRestoreIntro}
|
|
3552
|
+
</p>
|
|
3553
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
3554
|
+
<a
|
|
3555
|
+
href="/api/backup?format=json"
|
|
3556
|
+
download
|
|
3557
|
+
className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
|
|
3558
|
+
>
|
|
3559
|
+
{s.dangerClearHistoryBackupJson}
|
|
3560
|
+
</a>
|
|
3561
|
+
<a
|
|
3562
|
+
href="/api/backup?format=csv-zip"
|
|
3563
|
+
download
|
|
3564
|
+
className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
|
|
3565
|
+
>
|
|
3566
|
+
{s.dangerClearHistoryBackupCsvZip}
|
|
3567
|
+
</a>
|
|
3568
|
+
<a
|
|
3569
|
+
href="/api/backup?format=sqlite"
|
|
3570
|
+
download
|
|
3571
|
+
className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
|
|
3572
|
+
>
|
|
3573
|
+
{s.dangerClearHistoryBackupSqlite}
|
|
3574
|
+
</a>
|
|
3575
|
+
</div>
|
|
3576
|
+
<div className="mt-4 space-y-2">
|
|
3577
|
+
<label className="block text-xs font-medium text-red-900/90 dark:text-red-200/90">
|
|
3578
|
+
{s.dangerRestorePickFile}
|
|
3579
|
+
</label>
|
|
3580
|
+
<input
|
|
3581
|
+
ref={restoreFileInputRef}
|
|
3582
|
+
type="file"
|
|
3583
|
+
accept=".json,.sqlite,.db,application/json,application/vnd.sqlite3"
|
|
3584
|
+
disabled={restoreBusy || clearHistoryBusy}
|
|
3585
|
+
className="block w-full rounded-lg border border-red-300 bg-white px-3 py-2 text-xs text-zinc-800 file:mr-3 file:rounded file:border-0 file:bg-red-100 file:px-2.5 file:py-1 file:text-xs file:font-medium file:text-red-900 dark:border-red-900/60 dark:bg-zinc-950 dark:text-zinc-200 dark:file:bg-red-900/35 dark:file:text-red-200"
|
|
3586
|
+
onChange={(e) => {
|
|
3587
|
+
const f = e.target.files?.[0] ?? null;
|
|
3588
|
+
setRestoreFile(f);
|
|
3589
|
+
}}
|
|
3590
|
+
/>
|
|
3591
|
+
<p className="text-[11px] leading-relaxed text-red-900/80 dark:text-red-300/80">
|
|
3592
|
+
{s.dangerRestoreHint}
|
|
3593
|
+
</p>
|
|
3594
|
+
<button
|
|
3595
|
+
type="button"
|
|
3596
|
+
disabled={!restoreFile || restoreBusy || clearHistoryBusy}
|
|
3597
|
+
className="rounded-lg border border-red-600/80 bg-red-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-500 dark:bg-red-800/90 dark:hover:bg-red-700"
|
|
3598
|
+
onClick={() => setRestoreConfirmOpen(true)}
|
|
3599
|
+
>
|
|
3600
|
+
{restoreBusy
|
|
3601
|
+
? s.dangerRestoreBusy
|
|
3602
|
+
: s.dangerRestoreButton}
|
|
3603
|
+
</button>
|
|
3604
|
+
</div>
|
|
3494
3605
|
<h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
|
|
3495
3606
|
{s.dangerClearHistoryTitle}
|
|
3496
3607
|
</h3>
|
|
@@ -3535,6 +3646,23 @@ function SettingsPageContent() {
|
|
|
3535
3646
|
onCancel={() => setMongoResyncConfirmOpen(false)}
|
|
3536
3647
|
onConfirm={() => void executeMongoResync()}
|
|
3537
3648
|
/>
|
|
3649
|
+
<DashboardConfirmModal
|
|
3650
|
+
open={restoreConfirmOpen}
|
|
3651
|
+
title={s.dangerRestoreButton}
|
|
3652
|
+
message={s.dangerRestoreConfirm}
|
|
3653
|
+
cancelLabel={s.dialogCancelBtn}
|
|
3654
|
+
confirmLabel={s.dialogConfirmBtn}
|
|
3655
|
+
confirmVariant="danger"
|
|
3656
|
+
pending={restoreBusy}
|
|
3657
|
+
onCancel={() => {
|
|
3658
|
+
if (!restoreBusy) {
|
|
3659
|
+
setRestoreConfirmOpen(false);
|
|
3660
|
+
}
|
|
3661
|
+
}}
|
|
3662
|
+
onConfirm={() => {
|
|
3663
|
+
void restoreBackup();
|
|
3664
|
+
}}
|
|
3665
|
+
/>
|
|
3538
3666
|
<DashboardConfirmModal
|
|
3539
3667
|
open={clearHistoryConfirmOpen}
|
|
3540
3668
|
title={s.dangerClearHistoryTitle}
|
package/bin/kronosys.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Supported commands:
|
|
6
6
|
* start -> next start -p 5555 (production, default; auto-build if needed)
|
|
7
7
|
* dev -> next dev --webpack -H kronosys -p 5555
|
|
8
|
+
* --version, -v, version -> print installed Kronosys version
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { spawn } from "node:child_process";
|
|
@@ -29,6 +30,11 @@ const psBin = process.platform === "win32" ? "powershell.exe" : null;
|
|
|
29
30
|
|
|
30
31
|
const [, , command = "start", ...rest] = process.argv;
|
|
31
32
|
|
|
33
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
34
|
+
console.log(packageVersion);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
const commands = {
|
|
33
39
|
start: [npxBin, "next", "start", "-p", "5555", ...rest],
|
|
34
40
|
dev: [
|