Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.

Commit 49193bf

Browse files
authored
Merge pull request #13 from initstring/codex/simplify-/settings/database-page
Simplify data management settings
2 parents 62faa1b + 85b846d commit 49193bf

14 files changed

Lines changed: 863 additions & 1150 deletions

File tree

docs/dev/DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Unified pattern across tabs: SettingsHeader + EntityListCard + EntityModal; Inli
9191
- Users: create/edit; role picker; delete via ConfirmModal.
9292
- Groups: create/edit; manage membership; one Tag per Group; delete via ConfirmModal.
9393
- Taxonomy: Tags, Tool Categories (by type), Tools, Threat Actors (attach ATT&CK techniques), Crown Jewels, Log Sources.
94-
- Database: stats; Backup (download JSON, selectable sections), Restore (ConfirmModal + file, selectable sections, optional clear), Clear Data (ConfirmModal per selection).
94+
- Data: overview metrics; export/import a combined operations + taxonomy backup (always replaces existing data); clear-all confirmation.
9595

9696
## Data & Validation
9797

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
5+
import { CheckCircle } from "lucide-react";
6+
7+
import ConfirmModal from "@components/ui/confirm-modal";
8+
import { Button, Card, CardContent, CardHeader, CardTitle } from "@components/ui";
9+
import { logger } from "@lib/logger";
10+
import { api } from "@/trpc/react";
11+
12+
export default function DataSettingsPage() {
13+
const [restoreFile, setRestoreFile] = useState<File | null>(null);
14+
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
15+
const [showClearConfirm, setShowClearConfirm] = useState(false);
16+
const [toastMessage, setToastMessage] = useState<string | null>(null);
17+
const [restoreError, setRestoreError] = useState<string | null>(null);
18+
const fileInputRef = useRef<HTMLInputElement | null>(null);
19+
20+
const resetRestoreSelection = () => {
21+
setRestoreFile(null);
22+
if (fileInputRef.current) {
23+
fileInputRef.current.value = "";
24+
}
25+
};
26+
27+
useEffect(() => {
28+
if (!toastMessage) return;
29+
30+
const timeout = window.setTimeout(() => setToastMessage(null), 4000);
31+
32+
return () => window.clearTimeout(timeout);
33+
}, [toastMessage]);
34+
35+
const utils = api.useUtils();
36+
const { data: stats, refetch: refetchStats } = api.data.getStats.useQuery();
37+
38+
const backupMutation = api.data.backup.useMutation({
39+
onSuccess: (data) => {
40+
const blob = new Blob([data], { type: "application/json" });
41+
const url = URL.createObjectURL(blob);
42+
const a = document.createElement("a");
43+
a.href = url;
44+
a.download = `ttpx-data-${new Date().toISOString().split("T")[0]}.json`;
45+
document.body.appendChild(a);
46+
a.click();
47+
document.body.removeChild(a);
48+
URL.revokeObjectURL(url);
49+
},
50+
});
51+
52+
const restoreMutation = api.data.restore.useMutation({
53+
onSuccess: () => {
54+
void refetchStats();
55+
void utils.invalidate();
56+
resetRestoreSelection();
57+
setShowRestoreConfirm(false);
58+
setToastMessage("Operations and taxonomy data have been imported.");
59+
setRestoreError(null);
60+
},
61+
onError: (error) => {
62+
setShowRestoreConfirm(false);
63+
setRestoreError(error.message);
64+
resetRestoreSelection();
65+
},
66+
});
67+
68+
const clearDataMutation = api.data.clearData.useMutation({
69+
onSuccess: () => {
70+
void refetchStats();
71+
void utils.invalidate();
72+
setShowClearConfirm(false);
73+
setToastMessage("Operations and taxonomy data have been cleared.");
74+
},
75+
onError: () => {
76+
setShowClearConfirm(false);
77+
},
78+
});
79+
80+
const handleRestore = async () => {
81+
if (!restoreFile) return;
82+
83+
try {
84+
const text = await restoreFile.text();
85+
setRestoreError(null);
86+
restoreMutation.mutate({ backupData: text });
87+
} catch (error) {
88+
logger.error("Failed to read backup file", error);
89+
}
90+
};
91+
92+
return (
93+
<div className="space-y-6">
94+
{toastMessage && (
95+
<div className="fixed bottom-6 right-6 z-50">
96+
<div
97+
role="status"
98+
aria-live="polite"
99+
className="flex items-start space-x-3 rounded-[var(--radius-lg)] border border-[var(--color-border-light)] bg-[var(--color-surface-elevated)] px-4 py-3 shadow-[var(--shadow-lg)]"
100+
>
101+
<CheckCircle className="h-5 w-5 text-[var(--status-success-fg)]" aria-hidden />
102+
<div>
103+
<p className="text-sm font-medium text-[var(--color-text-primary)]">Success</p>
104+
<p className="text-xs text-[var(--color-text-secondary)]">{toastMessage}</p>
105+
</div>
106+
</div>
107+
</div>
108+
)}
109+
110+
<div>
111+
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Data Management</h1>
112+
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
113+
Export, import, or clear operations and taxonomy data.
114+
</p>
115+
</div>
116+
117+
{showRestoreConfirm && (
118+
<ConfirmModal
119+
open
120+
title="Import data from backup?"
121+
description="This will replace all existing operations and taxonomy data with the selected backup. All operations will be made visible to every user because group assignments are not restored. This action cannot be undone."
122+
confirmLabel="Import"
123+
cancelLabel="Cancel"
124+
onConfirm={handleRestore}
125+
onCancel={() => {
126+
resetRestoreSelection();
127+
setShowRestoreConfirm(false);
128+
}}
129+
loading={restoreMutation.isPending}
130+
/>
131+
)}
132+
133+
<Card>
134+
<CardHeader>
135+
<CardTitle>Data Overview</CardTitle>
136+
</CardHeader>
137+
<CardContent>
138+
{stats ? (
139+
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
140+
{[
141+
{ label: "Operations", value: stats.operations },
142+
{ label: "Techniques", value: stats.techniques },
143+
{ label: "Outcomes", value: stats.outcomes },
144+
{ label: "Threat Actors", value: stats.threatActors },
145+
{ label: "Crown Jewels", value: stats.crownJewels },
146+
{ label: "Tags", value: stats.tags },
147+
{ label: "Tools", value: stats.tools },
148+
{ label: "Log Sources", value: stats.logSources },
149+
].map(({ label, value }) => (
150+
<div key={label} className="text-center">
151+
<div className="text-2xl font-bold text-[var(--color-accent)]">{value}</div>
152+
<div className="text-sm text-[var(--color-text-secondary)]">{label}</div>
153+
</div>
154+
))}
155+
</div>
156+
) : (
157+
<div className="py-4 text-center text-[var(--color-text-secondary)]">Loading statistics...</div>
158+
)}
159+
</CardContent>
160+
</Card>
161+
162+
<div className="grid gap-6 md:grid-cols-3">
163+
<Card>
164+
<CardHeader>
165+
<CardTitle>Export Data</CardTitle>
166+
</CardHeader>
167+
<CardContent className="space-y-4">
168+
<p className="text-sm text-[var(--color-text-secondary)]">
169+
Download a JSON file containing all operations and taxonomy records.
170+
</p>
171+
<Button
172+
variant="secondary"
173+
size="sm"
174+
onClick={() => backupMutation.mutate()}
175+
disabled={backupMutation.isPending}
176+
>
177+
{backupMutation.isPending ? "Preparing export..." : "Download data"}
178+
</Button>
179+
</CardContent>
180+
</Card>
181+
182+
<Card>
183+
<CardHeader>
184+
<CardTitle>Import Data</CardTitle>
185+
</CardHeader>
186+
<CardContent className="space-y-4">
187+
<p className="text-sm text-[var(--color-text-secondary)]">
188+
Import a backup to replace the current operations and taxonomy data set.
189+
</p>
190+
<input
191+
ref={fileInputRef}
192+
type="file"
193+
accept=".json"
194+
className="hidden"
195+
onChange={(event) => {
196+
const file = event.target.files?.[0] ?? null;
197+
198+
if (!file) {
199+
resetRestoreSelection();
200+
setShowRestoreConfirm(false);
201+
return;
202+
}
203+
204+
setRestoreFile(file);
205+
setRestoreError(null);
206+
setShowRestoreConfirm(true);
207+
event.target.value = "";
208+
}}
209+
/>
210+
<Button
211+
variant="secondary"
212+
size="sm"
213+
onClick={() => fileInputRef.current?.click()}
214+
disabled={restoreMutation.isPending}
215+
>
216+
{restoreMutation.isPending ? "Importing..." : "Import data"}
217+
</Button>
218+
{restoreError && <div className="text-sm text-[var(--color-error)]">{restoreError}</div>}
219+
</CardContent>
220+
</Card>
221+
222+
<Card>
223+
<CardHeader>
224+
<CardTitle className="text-[var(--color-error)]">⚠️ Clear Data</CardTitle>
225+
</CardHeader>
226+
<CardContent className="space-y-4">
227+
<p className="text-sm text-[var(--color-text-secondary)]">
228+
Permanently delete all operations and taxonomy data. This cannot be undone.
229+
</p>
230+
<Button
231+
variant="danger"
232+
size="sm"
233+
onClick={() => setShowClearConfirm(true)}
234+
disabled={clearDataMutation.isPending}
235+
>
236+
{clearDataMutation.isPending ? "Clearing..." : "Clear all data"}
237+
</Button>
238+
{clearDataMutation.error && (
239+
<div className="text-sm text-[var(--color-error)]">{clearDataMutation.error.message}</div>
240+
)}
241+
</CardContent>
242+
</Card>
243+
</div>
244+
245+
{showClearConfirm && (
246+
<ConfirmModal
247+
open
248+
title="Delete all data?"
249+
description="This will permanently delete all operations and taxonomy data. This action cannot be undone."
250+
confirmLabel="Delete"
251+
cancelLabel="Cancel"
252+
onConfirm={() => clearDataMutation.mutate()}
253+
onCancel={() => setShowClearConfirm(false)}
254+
loading={clearDataMutation.isPending}
255+
/>
256+
)}
257+
</div>
258+
);
259+
}

0 commit comments

Comments
 (0)