feat: implement snapshot restore functionality and enhance graph state management
All checks were successful
On Push to Main / test (push) Successful in 40s
On Push to Main / publish (push) Successful in 1m10s
On Push to Main / deploy-dev (push) Successful in 1m35s

This commit is contained in:
RaymondVerhoef
2026-05-27 17:43:18 +02:00
parent 6ea8860b96
commit 6309ae716b
4 changed files with 164 additions and 11 deletions

View File

@@ -25,9 +25,12 @@ const KnowledgeGraph = () => {
const [showExcludeNodes, setShowExcludeNodes] = useState(false); const [showExcludeNodes, setShowExcludeNodes] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState(null); const [analyzeError, setAnalyzeError] = useState(null);
const [isRestoring, setIsRestoring] = useState(false);
const { topics, relations, reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave } = const {
useGraphData(); topics, relations, snapshotMeta,
reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
} = useGraphData();
// ── Canvas sizing ──────────────────────────────────────────────────────────── // ── Canvas sizing ────────────────────────────────────────────────────────────
@@ -177,6 +180,24 @@ const KnowledgeGraph = () => {
} }
}, [selectedNode, deleteTopic]); }, [selectedNode, deleteTopic]);
// ── Snapshot restore ─────────────────────────────────────────────────────────
const handleRestore = useCallback(async () => {
if (
!confirm(
`Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze & Optimize run. The snapshot will be cleared after restoring.`,
)
)
return;
setIsRestoring(true);
try {
await restoreSnapshot();
setSelectedNode(null);
} finally {
setIsRestoring(false);
}
}, [restoreSnapshot, snapshotMeta]);
// ── AI graph analysis ──────────────────────────────────────────────────────── // ── AI graph analysis ────────────────────────────────────────────────────────
const analyzeGraph = useCallback(async () => { const analyzeGraph = useCallback(async () => {
@@ -309,6 +330,9 @@ Do not return the entire graph — only the actions to take.`;
analyzeError={analyzeError} analyzeError={analyzeError}
disabled={topics.length === 0} disabled={topics.length === 0}
onApplied={reload} onApplied={reload}
snapshotMeta={snapshotMeta}
onRestore={handleRestore}
isRestoring={isRestoring}
/> />
<NodeDetailPanel <NodeDetailPanel
selectedNode={selectedNode} selectedNode={selectedNode}

View File

@@ -1,10 +1,10 @@
import { RefreshCw, AlertCircle } from 'lucide-react'; import { RotateCcw, RefreshCw, AlertCircle } from 'lucide-react';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import SuggestionsQueue from '../SuggestionsQueue'; import SuggestionsQueue from '../SuggestionsQueue';
/** /**
* Sidebar controls panel: the exclude-nodes toggle, the AI analysis button, * Sidebar controls panel: the exclude-nodes toggle, the AI analysis button,
* and the R42 suggestions queue. * the snapshot restore row, and the R42 suggestions queue.
* *
* Kept intentionally thin — all async logic lives in the KnowledgeGraph * Kept intentionally thin — all async logic lives in the KnowledgeGraph
* orchestrator and is injected via props. * orchestrator and is injected via props.
@@ -17,7 +17,14 @@ export default function GraphControls({
analyzeError, analyzeError,
disabled, disabled,
onApplied, onApplied,
snapshotMeta,
onRestore,
isRestoring,
}) { }) {
const snapshotLabel = snapshotMeta
? new Date(snapshotMeta.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: null;
return ( return (
<div className="mb-6 pb-4 border-b border-bg-warm space-y-3"> <div className="mb-6 pb-4 border-b border-bg-warm space-y-3">
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer"> <label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
@@ -30,18 +37,33 @@ export default function GraphControls({
Show Excluded Nodes (Reference Material) Show Excluded Nodes (Reference Material)
</label> </label>
<div> <div className="space-y-2">
<Button <Button
onClick={onAnalyze} onClick={onAnalyze}
disabled={isAnalyzing || disabled} disabled={isAnalyzing || isRestoring || disabled}
className="w-full flex justify-center items-center gap-2" className="w-full flex justify-center items-center gap-2"
> >
<RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} /> <RefreshCw size={16} className={isAnalyzing ? 'animate-spin' : ''} />
{isAnalyzing ? 'Analyzing Graph…' : 'Analyze & Optimize Graph'} {isAnalyzing ? 'Analyzing Graph…' : 'Analyze & Optimize Graph'}
</Button> </Button>
{/* Restore row — only visible after a bulkSave has written a snapshot */}
{snapshotMeta && (
<button
onClick={onRestore}
disabled={isAnalyzing || isRestoring}
className="w-full flex items-center justify-center gap-1.5 text-xs text-fg-muted hover:text-teal disabled:opacity-40 disabled:cursor-not-allowed py-1 transition-colors"
title={`Restore graph to state from ${snapshotLabel} (${snapshotMeta.topicCount} topics, ${snapshotMeta.relationCount} relations)`}
>
<RotateCcw size={12} className={isRestoring ? 'animate-spin' : ''} />
{isRestoring
? 'Restoring…'
: `Restore to ${snapshotLabel} · ${snapshotMeta.topicCount}t ${snapshotMeta.relationCount}r`}
</button>
)}
{analyzeError && ( {analyzeError && (
<p className="mt-2 text-xs text-red-600 flex items-start gap-1"> <p className="text-xs text-red-600 flex items-start gap-1">
<AlertCircle size={14} className="shrink-0 mt-0.5" /> <AlertCircle size={14} className="shrink-0 mt-0.5" />
{analyzeError} {analyzeError}
</p> </p>

View File

@@ -10,11 +10,20 @@ import * as db from '../lib/db';
* Relation shape contract: source and target are always plain strings. * Relation shape contract: source and target are always plain strings.
* The normalization in db.getRelations() enforces this at the DB boundary; * The normalization in db.getRelations() enforces this at the DB boundary;
* the filter here drops any orphaned edges whose endpoints no longer exist. * the filter here drops any orphaned edges whose endpoints no longer exist.
*
* Snapshot contract:
* bulkSave() writes a snapshot of the CURRENT state to PocketBase before
* overwriting it — so the admin can always roll back one step. snapshotMeta
* is non-null whenever a rollback point exists; restoreSnapshot() applies it
* and then clears it so it cannot be applied twice.
*/ */
export function useGraphData() { export function useGraphData() {
const [topics, setTopics] = useState([]); const [topics, setTopics] = useState([]);
const [relations, setRelations] = useState([]); const [relations, setRelations] = useState([]);
// { ts: number, topicCount: number, relationCount: number }
const [snapshotMeta, setSnapshotMeta] = useState(null);
// Refs that always hold the latest state — lets mutation callbacks avoid // Refs that always hold the latest state — lets mutation callbacks avoid
// stale-closure bugs without including state in their dependency arrays. // stale-closure bugs without including state in their dependency arrays.
const topicsRef = useRef([]); const topicsRef = useRef([]);
@@ -41,11 +50,19 @@ export function useGraphData() {
return () => window.removeEventListener('respellion:kb-updated', reload); return () => window.removeEventListener('respellion:kb-updated', reload);
}, [reload]); }, [reload]);
// On mount, check whether a snapshot from a previous session exists.
useEffect(() => {
db.getGraphSnapshot().then(snap => {
if (snap?.ts) {
setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount });
}
});
}, []);
// ── Single-topic mutations ─────────────────────────────────────────────────── // ── Single-topic mutations ───────────────────────────────────────────────────
/** /**
* Persist a topic update and mirror it into local state. * Persist a topic update and mirror it into local state.
* Automatically locks learning_relevance when the admin changes it.
* Returns the saved topic object. * Returns the saved topic object.
*/ */
const updateTopic = useCallback(async (topic) => { const updateTopic = useCallback(async (topic) => {
@@ -74,7 +91,7 @@ export function useGraphData() {
/** /**
* Add a relation if not already present. * Add a relation if not already present.
* Returns true if the relation was added, false if it was a duplicate. * Returns true if added, false if it was a duplicate.
*/ */
const addRelation = useCallback(async (relation) => { const addRelation = useCallback(async (relation) => {
const isDup = relationsRef.current.some( const isDup = relationsRef.current.some(
@@ -99,24 +116,60 @@ export function useGraphData() {
// ── Bulk mutations (used by AI analysis) ──────────────────────────────────── // ── Bulk mutations (used by AI analysis) ────────────────────────────────────
/** /**
* Atomically replace the entire graph in PocketBase and local state. * Snapshot the current graph then replace everything in PocketBase and local
* Called by analyzeGraph after all AI actions have been applied locally. * state. The snapshot is written BEFORE the destructive delete-all/create-all
* so it survives a mid-operation failure.
*
* Called by analyzeGraph after all AI actions have been applied in memory.
*/ */
const bulkSave = useCallback(async (newTopics, newRelations) => { const bulkSave = useCallback(async (newTopics, newRelations) => {
// Persist a rollback point of the state we are about to overwrite.
await db.saveGraphSnapshot(topicsRef.current, relationsRef.current);
setSnapshotMeta({
ts: Date.now(),
topicCount: topicsRef.current.length,
relationCount: relationsRef.current.length,
});
await db.saveTopics(newTopics); await db.saveTopics(newTopics);
await db.saveRelations(newRelations); await db.saveRelations(newRelations);
setTopics(newTopics); setTopics(newTopics);
setRelations(newRelations); setRelations(newRelations);
}, []); }, []);
/**
* Load the latest snapshot from PocketBase and apply it as the current graph.
* Clears the snapshot afterwards so the admin cannot restore the same point
* twice (which would silently re-apply the AI changes they just rolled back).
*
* Returns true on success, false if no snapshot exists.
*/
const restoreSnapshot = useCallback(async () => {
const snap = await db.getGraphSnapshot();
if (!snap?.topics) return false;
// Apply without taking a new snapshot — we don't want to overwrite
// the only rollback point we have.
await db.saveTopics(snap.topics);
await db.saveRelations(snap.relations);
setTopics(snap.topics);
setRelations(snap.relations);
await db.clearGraphSnapshot();
setSnapshotMeta(null);
return true;
}, []);
return { return {
topics, topics,
relations, relations,
snapshotMeta,
reload, reload,
updateTopic, updateTopic,
deleteTopic, deleteTopic,
addRelation, addRelation,
removeRelation, removeRelation,
bulkSave, bulkSave,
restoreSnapshot,
}; };
} }

View File

@@ -284,6 +284,60 @@ export function setSetting(key, value) {
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) }); return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) });
} }
// ── Graph Snapshots ───────────────────────────────────────────────────────────
//
// A single "latest" snapshot is persisted in the settings collection before
// every bulk graph write. This survives browser/tab close and lets the admin
// roll back an AI analysis that produced unwanted results.
//
// The value is stored as a JSON string because the settings collection only
// has a plain text `value` field. Large graphs (150+ topics) can produce
// ~100 KB of JSON, which SQLite handles without issue.
const SNAPSHOT_KEY = 'graph_snapshot:latest';
/**
* Persist the current graph state as a rollback point.
* Called by useGraphData.bulkSave() before overwriting the graph.
*
* @param {object[]} topics — the graph state to preserve
* @param {object[]} relations — the graph state to preserve
*/
export async function saveGraphSnapshot(topics, relations) {
const payload = JSON.stringify({
ts: Date.now(),
topicCount: topics.length,
relationCount: relations.length,
topics,
relations,
});
return setSetting(SNAPSHOT_KEY, payload);
}
/**
* Read the latest snapshot back from PocketBase.
* Returns null when no snapshot exists or the stored value is malformed.
*
* @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null}
*/
export async function getGraphSnapshot() {
const raw = await getSetting(SNAPSHOT_KEY, null);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Delete the stored snapshot (e.g. after a successful restore so the
* admin cannot accidentally restore twice).
*/
export function clearGraphSnapshot() {
return setSetting(SNAPSHOT_KEY, '');
}
// ── Curriculum Versions (v2) ────────────────────────────────────────────────── // ── Curriculum Versions (v2) ──────────────────────────────────────────────────
export async function getCurriculumVersions(status) { export async function getCurriculumVersions(status) {