Improve catalog studio UX and aggressive docker cleanup
CI / check (push) Failing after 1m25s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

Catalog Studio:
- Cross-parent drag & drop now uses optimistic updates with rollback
  on failure (no more full tree reload / visible delay)
- Subpage creation adds the node optimistically then refreshes parent
  only (was full tree reload)
- Single page deletion refreshes only the affected parent (was full
  tree reload)
- Root page creation replaces native prompt() with an inline input
  in the root tab bar
- Escape key no longer closes the dialog when an input field is focused
- TreeNodeUpdate type now supports parentId and orderNum for
  optimistic structural changes

Docker:
- docker-prune.sh default mode now aggressively cleans all unreferenced
  build cache, images >1h old, and stopped containers >1h old
  (was 72h/7d/24h which let cache grow past 80% on every push)
This commit is contained in:
openhands committed 2026-09-14 16:52:04 +02:00
1 parent 349188af10
commit 1a9e1e791a
6 files changed
+217 -40

No files matched your search

+10 -12
View File
@@ -2,17 +2,15 @@
# Reclaim Docker's unused cache so host storage stays bounded. # Reclaim Docker's unused cache so host storage stays bounded.
# #
# Modes: # Modes:
# (default) — gentle, age-windowed (keeps rollback + rebuild speed): # (default) — post-deploy cleanup (safe, fast):
# - BuildKit cache older than 72h, hard-capped at 4 GB (Debian /pnpm store # - ALL unreferenced build cache (new build will re-populate what it needs).
# is shared across builds; everything newer speeds up rebuilds). # - ALL unreferenced images older than 1h (keeps current image + very recent).
# - Images referenced by NO container and older than 7 days. # - ALL stopped containers older than 1h.
# - Containers stopped for more than 24h. # --force — emergency mode ("never let the disk max out"): drops everything
# --force — emergency mode ("never let the disk max out"): drops every age # with no age windows:
# window and reclaims all unused bytes Docker can free:
# - ALL unreferenced build cache, # - ALL unreferenced build cache,
# - ALL unreferenced images (no 7-day grace), # - ALL unreferenced images (no age grace),
# - ALL stopped containers. # - ALL stopped containers.
# Trade-off: waiting rebuilds re-fetch deps/images later.
# #
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database. # Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
# Idempotent; exits 0 when Docker is unavailable. # Idempotent; exits 0 when Docker is unavailable.
@@ -41,9 +39,9 @@ if (( FORCE )); then
docker image prune -af >>"$LOG_FILE" 2>&1 || true docker image prune -af >>"$LOG_FILE" 2>&1 || true
docker container prune -f >>"$LOG_FILE" 2>&1 || true docker container prune -f >>"$LOG_FILE" 2>&1 || true
else else
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true docker builder prune -af >>"$LOG_FILE" 2>&1 || true
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true docker image prune -af --filter "until=1h" >>"$LOG_FILE" 2>&1 || true
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true docker container prune -f --filter "until=1h" >>"$LOG_FILE" 2>&1 || true
fi fi
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE" printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
@@ -200,6 +200,9 @@ function CatalogManagerInner({
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (embedded || event.key !== "Escape" || event.defaultPrevented) return; if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
// Never steal Escape from inputs/comboboxes (e.g. root name field).
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const focusedDialog = target.closest?.("[role=dialog]"); const focusedDialog = target.closest?.("[role=dialog]");
if ( if (
focusedDialog && focusedDialog &&
@@ -282,26 +285,26 @@ function CatalogManagerInner({
dispatch({ type: "SET_ACTIVE_TAB", id }); dispatch({ type: "SET_ACTIVE_TAB", id });
}; };
const handleCreateRoot = async () => { const handleCreateRoot = async (caption: string): Promise<boolean> => {
if (!canEdit) return; if (!canEdit) return false;
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try { try {
const result = const result =
catalogType === "bc" catalogType === "bc"
? await createBcPage({ ? await createBcPage({
parentId: -1, parentId: -1,
caption: caption.trim(), caption,
}) })
: await createCatalogPage({ : await createCatalogPage({
parentId: -1, parentId: -1,
caption: caption.trim(), caption,
}); });
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
await loadRootPages(); await loadRootPages();
toast.success("Root page created"); toast.success("Root page created");
return true;
} catch { } catch {
toast.error("Failed to create root page"); toast.error("Failed to create root page");
return false;
} }
}; };
@@ -25,9 +25,10 @@ import {
Power, Power,
PowerOff, PowerOff,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteTreePage, deleteTreePage,
@@ -49,6 +50,7 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation"; import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog"; import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate"; import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
@@ -64,7 +66,7 @@ interface RootTabsProps {
activeTabId: number | null; activeTabId: number | null;
onTabClick: (id: number) => void | Promise<void>; onTabClick: (id: number) => void | Promise<void>;
onEditTab: (id: number) => void | Promise<void>; onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void; onCreateRoot: (caption: string) => Promise<boolean> | boolean;
loading?: boolean; loading?: boolean;
onMutation?: () => void; onMutation?: () => void;
} }
@@ -85,10 +87,35 @@ export function RootTabs({
null, null,
); );
const [activeDragId, setActiveDragId] = useState<number | null>(null); const [activeDragId, setActiveDragId] = useState<number | null>(null);
const [creatingRoot, setCreatingRoot] = useState(false);
const [newRootName, setNewRootName] = useState("");
const newRootInputRef = useRef<HTMLInputElement>(null);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
); );
const startCreateRoot = useCallback(() => {
if (!canEdit) return;
setCreatingRoot(true);
setNewRootName("");
requestAnimationFrame(() => {
newRootInputRef.current?.focus();
});
}, [canEdit]);
const handleCreateRootSubmit = async () => {
if (!canEdit || !newRootName.trim()) return;
try {
const ok = await onCreateRoot(newRootName.trim());
if (ok) {
setCreatingRoot(false);
setNewRootName("");
}
} catch {
// Keep the input open so the operator can retry.
}
};
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as number); setActiveDragId(event.active.id as number);
}, []); }, []);
@@ -228,18 +255,61 @@ export function RootTabs({
)} )}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
{canEdit && ( {canEdit && !creatingRoot && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 shrink-0 ml-1" className="h-7 w-7 shrink-0 ml-1"
title="New root page" title="New root page"
onClick={onCreateRoot} onClick={startCreateRoot}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
{canEdit && creatingRoot && (
<div className="ml-1 flex shrink-0 items-center gap-1.5 rounded-md border bg-muted/40 p-1">
<Input
ref={newRootInputRef}
value={newRootName}
onChange={(e) => setNewRootName(e.target.value)}
placeholder="Root page name…"
className="h-7 w-40 text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreateRootSubmit();
}
if (e.key === "Escape") {
e.preventDefault();
setCreatingRoot(false);
setNewRootName("");
}
}}
/>
<Button
type="button"
size="sm"
className="h-7 shrink-0 px-2"
disabled={!newRootName.trim()}
onClick={() => void handleCreateRootSubmit()}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2"
onClick={() => {
setCreatingRoot(false);
setNewRootName("");
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div> </div>
); );
} }
@@ -183,7 +183,21 @@ export function SortableTree({
} }
}, [activeTabId, dispatch, loadChildren]); }, [activeTabId, dispatch, loadChildren]);
// Expose reload for mutations (delete/move/create) /** Reload a specific parent's children after mutation (targeted refresh). */
const refreshParent = useCallback(
async (parentId: number) => {
try {
const pages = await loadChildren(parentId);
dispatch({ type: "SET_CHILDREN", parentId, children: pages });
} catch {
// Fallback to full reload only on error
await loadTabTree();
}
},
[loadChildren, dispatch, loadTabTree],
);
// Expose reload for mutations spanning multiple parents (bulk/duplicate)
const loadFullTree = loadTabTree; const loadFullTree = loadTabTree;
// Load tab children when active root changes (same as Catalog Structure expand) // Load tab children when active root changes (same as Catalog Structure expand)
@@ -442,6 +456,7 @@ export function SortableTree({
if (!activeNode || !overNode) return; if (!activeNode || !overNode) return;
if (activeNode.parentId === overNode.parentId) { if (activeNode.parentId === overNode.parentId) {
// ── Same-parent reorder ──────────────────────────────
const parentId = activeNode.parentId; const parentId = activeNode.parentId;
const siblings = childrenMap.get(parentId) || []; const siblings = childrenMap.get(parentId) || [];
const oldIndex = siblings.indexOf(active.id as number); const oldIndex = siblings.indexOf(active.id as number);
@@ -456,7 +471,6 @@ export function SortableTree({
.filter((n): n is NonNullable<typeof n> => Boolean(n)), .filter((n): n is NonNullable<typeof n> => Boolean(n)),
}); });
if (!canEdit) return;
const reorder = const reorder =
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages; catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
try { try {
@@ -470,27 +484,88 @@ export function SortableTree({
onTreeMutation(); onTreeMutation();
} catch { } catch {
toast.error("Failed to reorder"); toast.error("Failed to reorder");
loadFullTree(); await refreshParent(parentId);
} }
} else { } else {
if (!canEdit) return; // ── Cross-parent move (optimistic) ───────────────────
const movedId = active.id as number;
const newParentId = overNode.parentId;
const oldParentId = activeNode.parentId;
const overIndex = (childrenMap.get(newParentId) || []).indexOf(
over.id as number,
);
const newOrderNum =
overIndex >= 0
? overIndex + 1
: (childrenMap.get(newParentId) || []).length + 1;
// Snapshot for rollback
const prevOldSiblings = [...(childrenMap.get(oldParentId) || [])];
const prevNewSiblings = [...(childrenMap.get(newParentId) || [])];
// Optimistically move: remove from old parent, insert into new parent
const newSiblings = [...prevNewSiblings];
const insertIdx = newSiblings.indexOf(over.id as number);
if (insertIdx >= 0) newSiblings.splice(insertIdx, 0, movedId);
else newSiblings.push(movedId);
dispatch({
type: "SET_CHILDREN",
parentId: oldParentId,
children: prevOldSiblings
.filter((id) => id !== movedId)
.map((id) => nodes.get(id))
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
});
dispatch({
type: "SET_CHILDREN",
parentId: newParentId,
children: newSiblings
.map((id) => nodes.get(id))
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
});
dispatch({
type: "UPDATE_NODE",
update: { id: movedId, parentId: newParentId },
});
const reorder = const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage; catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const newParentId = overNode.parentId;
const siblings = childrenMap.get(newParentId) || [];
const overIndex = siblings.indexOf(over.id as number);
try { try {
const result = await reorder({ const result = await reorder({
pageId: active.id as number, pageId: movedId,
newParentId, newParentId,
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1, newOrderNum,
}); });
if (!result.ok) throw new Error(); if (!result.ok) throw new Error();
await loadFullTree(); // Refresh both parents to get correct server-side order
await Promise.all([
refreshParent(oldParentId),
refreshParent(newParentId),
]);
onTreeMutation(); onTreeMutation();
toast.success("Page moved"); toast.success("Page moved");
} catch { } catch {
toast.error("Failed to move page"); toast.error("Failed to move page");
// Rollback: restore both parent children lists
dispatch({
type: "SET_CHILDREN",
parentId: oldParentId,
children: prevOldSiblings
.map((id) => nodes.get(id))
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
});
dispatch({
type: "SET_CHILDREN",
parentId: newParentId,
children: prevNewSiblings
.map((id) => nodes.get(id))
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
});
dispatch({
type: "UPDATE_NODE",
update: { id: movedId, parentId: oldParentId },
});
} }
} }
}; };
@@ -505,22 +580,43 @@ export function SortableTree({
const handleCreateSubpage = async () => { const handleCreateSubpage = async () => {
if (!canEdit || subpageParentId === null || !subpageName.trim()) return; if (!canEdit || subpageParentId === null || !subpageName.trim()) return;
setCreatingSubpage(true); setCreatingSubpage(true);
const parentId = subpageParentId;
const name = subpageName.trim();
try { try {
const result = const result =
catalogType === "bc" catalogType === "bc"
? await createBcPage({ ? await createBcPage({
parentId: subpageParentId, parentId,
caption: subpageName.trim(), caption: name,
}) })
: await createCatalogPage({ : await createCatalogPage({
parentId: subpageParentId, parentId,
caption: subpageName.trim(), caption: name,
}); });
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true });
await loadFullTree();
const data = result.data as Record<string, unknown>; const data = result.data as Record<string, unknown>;
onSelectPage(data.id as number); const newId = data.id as number;
// Optimistically add the new node to the tree
const newNode: TreeNodeData = {
id: newId,
parentId,
caption: name,
enabled: "1",
visible: "1",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 0,
itemCount: 0,
orderNum: 0,
};
dispatch({ type: "SET_NODES", nodes: [newNode] });
dispatch({ type: "SET_EXPANDED", id: parentId, expanded: true });
// Refresh parent children to get correct server-side order
await refreshParent(parentId);
onSelectPage(newId);
onTreeMutation(); onTreeMutation();
setSubpageParentId(null); setSubpageParentId(null);
setSubpageName(""); setSubpageName("");
@@ -567,6 +663,7 @@ export function SortableTree({
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!canEdit) return; if (!canEdit) return;
const node = nodes.get(id); const node = nodes.get(id);
const parentId = node?.parentId ?? -1;
const childInfo = const childInfo =
node && node.childCount > 0 node && node.childCount > 0
? ` Its ${node.childCount} child page(s) will be moved to the parent.` ? ` Its ${node.childCount} child page(s) will be moved to the parent.`
@@ -589,7 +686,8 @@ export function SortableTree({
if (!result.ok) throw new Error(); if (!result.ok) throw new Error();
dispatch({ type: "REMOVE_NODE", id }); dispatch({ type: "REMOVE_NODE", id });
if (selectedPageId === id) onSelectPage(-1); if (selectedPageId === id) onSelectPage(-1);
await loadFullTree(); // Children were reparented up — refresh only the affected parent.
await refreshParent(parentId);
onTreeMutation(); onTreeMutation();
} catch { } catch {
toast.error("Failed to delete page"); toast.error("Failed to delete page");
@@ -233,6 +233,12 @@ function reducer(
...(action.update.iconImage !== undefined && { ...(action.update.iconImage !== undefined && {
iconImage: action.update.iconImage, iconImage: action.update.iconImage,
}), }),
...(action.update.parentId !== undefined && {
parentId: action.update.parentId,
}),
...(action.update.orderNum !== undefined && {
orderNum: action.update.orderNum,
}),
}); });
return { ...state, nodes: nextNodes }; return { ...state, nodes: nextNodes };
} }
+2
View File
@@ -39,6 +39,8 @@ export interface TreeNodeUpdate {
visible?: string; visible?: string;
pageLayout?: string; pageLayout?: string;
iconImage?: number; iconImage?: number;
parentId?: number;
orderNum?: number;
} }
export interface RootTab { export interface RootTab {