diff --git a/web/src/lib/docker-list-query.test.mjs b/web/src/lib/docker-list-query.test.mjs new file mode 100644 index 0000000..6eb58dd --- /dev/null +++ b/web/src/lib/docker-list-query.test.mjs @@ -0,0 +1,26 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19/equals"; + +import { + buildDockerListPageUrl, + dockerSearchDebounceMs, +} from "./docker-list-query.ts"; + +Deno.test( + "Docker list URL builder keeps debounced search state shareable", + () => { + assertEquals(dockerSearchDebounceMs, 300); + assertEquals( + buildDockerListPageUrl( + "/nodes/n1/docker/containers", + { + page: 1, + search: "redis", + sortBy: "name", + sortDirection: "asc", + }, + "name", + ), + "/nodes/n1/docker/containers?search=redis", + ); + }, +); diff --git a/web/src/lib/docker-list-query.ts b/web/src/lib/docker-list-query.ts index 32f3d0e..a35a093 100644 --- a/web/src/lib/docker-list-query.ts +++ b/web/src/lib/docker-list-query.ts @@ -1,4 +1,5 @@ export const dockerListPageSize = 20; +export const dockerSearchDebounceMs = 300; export type DockerListSortDirection = "asc" | "desc"; diff --git a/web/src/lib/filter-query.test.mjs b/web/src/lib/filter-query.test.mjs new file mode 100644 index 0000000..307d117 --- /dev/null +++ b/web/src/lib/filter-query.test.mjs @@ -0,0 +1,14 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19/equals"; + +import { filterQuerySignature } from "./filter-query.ts"; + +Deno.test("filter query signatures change only when URL filters change", () => { + assertEquals( + filterQuerySignature(1, { status: ["running"], nodeId: ["n1"] }), + "status=running&nodeId=n1", + ); + assertEquals( + filterQuerySignature(2, { status: ["running"], nodeId: ["n1"] }), + "page=2&status=running&nodeId=n1", + ); +}); diff --git a/web/src/lib/filter-query.ts b/web/src/lib/filter-query.ts index f80aa92..c5fc9a7 100644 --- a/web/src/lib/filter-query.ts +++ b/web/src/lib/filter-query.ts @@ -33,6 +33,22 @@ export function buildFilterPageUrl( page: number, filters: QueryFilterValues, ): string { + const params = buildFilterSearchParams(page, filters); + const query = params.toString(); + return query ? `${path}?${query}` : path; +} + +export function filterQuerySignature( + page: number, + filters: QueryFilterValues, +): string { + return buildFilterSearchParams(page, filters).toString(); +} + +function buildFilterSearchParams( + page: number, + filters: QueryFilterValues, +): URLSearchParams { const params = new URLSearchParams(); if (page > 1) { params.set("page", page.toString()); @@ -44,8 +60,7 @@ export function buildFilterPageUrl( } } - const query = params.toString(); - return query ? `${path}?${query}` : path; + return params; } export function filterValuesEqual( diff --git a/web/src/lib/i18n/messages/de.ts b/web/src/lib/i18n/messages/de.ts index ce71ba3..acc0615 100644 --- a/web/src/lib/i18n/messages/de.ts +++ b/web/src/lib/i18n/messages/de.ts @@ -537,7 +537,7 @@ export const de = { selectTargetNode: "Ziel-Node auswählen", restoreConfirmTitle: "Restore bestätigen", restoreWarning: - "Diese Wiederherstellung kann Dateien, Verzeichnisse oder Docker-Volumes mit denselben Namen auf Node {nodeId} überschreiben.", + "Diese Wiederherstellung löscht passende Dateien, Verzeichnisse oder Docker-Volumes auf Node {nodeId}, bevor Backup-Daten wiederhergestellt werden.", noArtifact: "Keine Artifact-Ref für dieses Backup aufgezeichnet.", filters: { allDataNames: "Alle Datennamen", @@ -611,6 +611,12 @@ export const de = { lastTask: "Letzter Task", started: "Rustic {action} gestartet", failedToStart: "Starten von Rustic {action} fehlgeschlagen.", + confirmTitle: "Rustic-Wartung bestätigen", + confirmAction: "Wartung ausführen", + forgetWarning: + "Rustic forget entfernt Snapshots gemäß Aufbewahrungsrichtlinie. Dies kann nicht rückgängig gemacht werden.", + pruneWarning: + "Rustic prune löscht nicht referenzierte Repository-Daten. Dies kann nicht rückgängig gemacht werden.", }, }, docker: { diff --git a/web/src/lib/i18n/messages/en-us.ts b/web/src/lib/i18n/messages/en-us.ts index e269737..9dcb54a 100644 --- a/web/src/lib/i18n/messages/en-us.ts +++ b/web/src/lib/i18n/messages/en-us.ts @@ -536,7 +536,7 @@ export const enUS = { selectTargetNode: "Select target node", restoreConfirmTitle: "Confirm restore", restoreWarning: - "This restore may overwrite files, directories, or Docker volumes with the same names on node {nodeId}.", + "This restore clears matching files, directories, or Docker volumes on node {nodeId} before restoring backup data.", noArtifact: "No Artifact Ref recorded for this backup.", filters: { allDataNames: "All data names", @@ -610,6 +610,12 @@ export const enUS = { lastTask: "Last task", started: "Rustic {action} started", failedToStart: "Failed to start rustic {action}.", + confirmTitle: "Confirm Rustic maintenance", + confirmAction: "Run maintenance", + forgetWarning: + "Rustic forget removes snapshots according to retention policy. This cannot be undone.", + pruneWarning: + "Rustic prune deletes unreferenced repository data. This cannot be undone.", }, }, docker: { diff --git a/web/src/lib/i18n/messages/fr.ts b/web/src/lib/i18n/messages/fr.ts index 69a2bef..6ba1c48 100644 --- a/web/src/lib/i18n/messages/fr.ts +++ b/web/src/lib/i18n/messages/fr.ts @@ -541,7 +541,7 @@ export const fr = { selectTargetNode: "Sélectionner le nœud cible", restoreConfirmTitle: "Confirmer la restauration", restoreWarning: - "Cette restauration peut écraser des fichiers, répertoires ou volumes Docker portant les mêmes noms sur le nœud {nodeId}.", + "Cette restauration efface les fichiers, répertoires ou volumes Docker correspondants sur le nœud {nodeId} avant de restaurer les données sauvegardées.", noArtifact: "Aucune réf. artefact enregistrée pour cette sauvegarde.", filters: { allDataNames: "Tous les noms de données", @@ -615,6 +615,12 @@ export const fr = { lastTask: "Dernière tâche", started: "Rustic {action} démarré", failedToStart: "Échec du démarrage de rustic {action}.", + confirmTitle: "Confirmer la maintenance Rustic", + confirmAction: "Exécuter la maintenance", + forgetWarning: + "Rustic forget supprime les instantanés selon la politique de rétention. Cette action est irréversible.", + pruneWarning: + "Rustic prune supprime les données de dépôt non référencées. Cette action est irréversible.", }, }, docker: { diff --git a/web/src/lib/i18n/messages/ja.ts b/web/src/lib/i18n/messages/ja.ts index c63bbf2..d1812ee 100644 --- a/web/src/lib/i18n/messages/ja.ts +++ b/web/src/lib/i18n/messages/ja.ts @@ -538,7 +538,7 @@ export const ja = { selectTargetNode: "ターゲットノードを選択", restoreConfirmTitle: "復元を確認", restoreWarning: - "この復元により、ノード {nodeId} 上の同名のファイル、ディレクトリ、または Docker ボリュームが上書きされる可能性があります。", + "この復元は、バックアップデータを復元する前にノード {nodeId} 上の一致するファイル、ディレクトリ、または Docker ボリュームを消去します。", noArtifact: "このバックアップにはアーティファクト参照が記録されていません。", filters: { @@ -613,6 +613,12 @@ export const ja = { lastTask: "最後のタスク", started: "Rustic {action} を開始しました", failedToStart: "Rustic {action} の開始に失敗しました。", + confirmTitle: "Rustic メンテナンスを確認", + confirmAction: "メンテナンスを実行", + forgetWarning: + "Rustic forget は保持ポリシーに従ってスナップショットを削除します。この操作は元に戻せません。", + pruneWarning: + "Rustic prune は参照されていないリポジトリデータを削除します。この操作は元に戻せません。", }, }, docker: { diff --git a/web/src/lib/i18n/messages/zh-hans.ts b/web/src/lib/i18n/messages/zh-hans.ts index cdf0c52..1d56dcf 100644 --- a/web/src/lib/i18n/messages/zh-hans.ts +++ b/web/src/lib/i18n/messages/zh-hans.ts @@ -516,7 +516,7 @@ export const zhHans = { selectTargetNode: "选择目标节点", restoreConfirmTitle: "确认恢复", restoreWarning: - "该恢复可能覆盖节点 {nodeId} 上同名的文件、目录或 Docker volume。", + "此恢复会先清空节点 {nodeId} 上匹配的文件、目录或 Docker volume,然后恢复备份数据。", noArtifact: "此备份没有记录 Artifact Ref。", filters: { allDataNames: "所有数据名称", @@ -590,6 +590,10 @@ export const zhHans = { lastTask: "上次任务", started: "Rustic {action} 已启动", failedToStart: "启动 Rustic {action} 失败。", + confirmTitle: "确认 Rustic 维护", + confirmAction: "运行维护", + forgetWarning: "Rustic forget 会按保留策略删除快照。此操作无法撤销。", + pruneWarning: "Rustic prune 会删除未引用的仓库数据。此操作无法撤销。", }, }, docker: { diff --git a/web/src/lib/i18n/messages/zh-hant.ts b/web/src/lib/i18n/messages/zh-hant.ts index eebe936..f9cfb1c 100644 --- a/web/src/lib/i18n/messages/zh-hant.ts +++ b/web/src/lib/i18n/messages/zh-hant.ts @@ -516,7 +516,7 @@ export const zhHant = { selectTargetNode: "選擇目標節點", restoreConfirmTitle: "確認還原", restoreWarning: - "該還原可能覆蓋節點 {nodeId} 上同名的檔案、目錄或 Docker volume。", + "此還原會先清除節點 {nodeId} 上相符的檔案、目錄或 Docker volume,然後還原備份資料。", noArtifact: "此備份沒有記錄 Artifact Ref。", filters: { allDataNames: "所有資料名稱", @@ -590,6 +590,10 @@ export const zhHant = { lastTask: "上次任務", started: "Rustic {action} 已啟動", failedToStart: "啟動 Rustic {action} 失敗。", + confirmTitle: "確認 Rustic 維護", + confirmAction: "執行維護", + forgetWarning: "Rustic forget 會依保留策略刪除快照。此操作無法復原。", + pruneWarning: "Rustic prune 會刪除未引用的儲存庫資料。此操作無法復原。", }, }, docker: { diff --git a/web/src/lib/server/container-action-queue.test.mjs b/web/src/lib/server/container-action-queue.test.mjs new file mode 100644 index 0000000..689f42b --- /dev/null +++ b/web/src/lib/server/container-action-queue.test.mjs @@ -0,0 +1,62 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19/equals"; + +import { serializeContainerAction } from "./container-action-queue.ts"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +Deno.test("container actions run serially per container", async () => { + const firstGate = deferred(); + const events = []; + + const first = serializeContainerAction("node-a:container-a", async () => { + events.push("first:start"); + await firstGate.promise; + events.push("first:end"); + return "first"; + }); + + await Promise.resolve(); + + const second = serializeContainerAction("node-a:container-a", async () => { + events.push("second:start"); + return "second"; + }); + + await Promise.resolve(); + assertEquals(events, ["first:start"]); + + firstGate.resolve(); + assertEquals(await first, "first"); + assertEquals(await second, "second"); + assertEquals(events, ["first:start", "first:end", "second:start"]); +}); + +Deno.test("container action queues are independent per container", async () => { + const firstGate = deferred(); + const events = []; + + const first = serializeContainerAction("node-a:container-b", async () => { + events.push("first:start"); + await firstGate.promise; + return "first"; + }); + + const second = serializeContainerAction("node-a:container-c", async () => { + events.push("second:start"); + return "second"; + }); + + await Promise.resolve(); + await Promise.resolve(); + assertEquals(events, ["first:start", "second:start"]); + + firstGate.resolve(); + assertEquals(await first, "first"); + assertEquals(await second, "second"); +}); diff --git a/web/src/lib/server/container-action-queue.ts b/web/src/lib/server/container-action-queue.ts new file mode 100644 index 0000000..5d011c4 --- /dev/null +++ b/web/src/lib/server/container-action-queue.ts @@ -0,0 +1,18 @@ +const containerActionQueues = new Map>(); + +export async function serializeContainerAction( + key: string, + action: () => Promise, +): Promise { + const previous = containerActionQueues.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(action); + containerActionQueues.set(key, next); + + try { + return await next; + } finally { + if (containerActionQueues.get(key) === next) { + containerActionQueues.delete(key); + } + } +} diff --git a/web/src/routes/(app)/+page.svelte b/web/src/routes/(app)/+page.svelte index 06ac2ab..068fd00 100644 --- a/web/src/routes/(app)/+page.svelte +++ b/web/src/routes/(app)/+page.svelte @@ -116,7 +116,7 @@ {#each recentTasks as task} {/each} - {:else} + {:else if !data.error}
{$messages.tasks.noTasks}
{/if} @@ -138,7 +138,7 @@ {#each recentBackups as backup} {/each} - {:else} + {:else if !data.error}
{$messages.backups.noBackups}
{/if} diff --git a/web/src/routes/(app)/backups/+page.svelte b/web/src/routes/(app)/backups/+page.svelte index 23ed3e4..3e0e3fa 100644 --- a/web/src/routes/(app)/backups/+page.svelte +++ b/web/src/routes/(app)/backups/+page.svelte @@ -44,7 +44,11 @@ import { startPolling } from "$lib/refresh"; import { taskStatusLabel } from "$lib/presenters"; import { cn } from "$lib/utils"; - import { buildFilterPageUrl, filterValuesEqual } from "$lib/filter-query"; + import { + buildFilterPageUrl, + filterQuerySignature, + filterValuesEqual, + } from "$lib/filter-query"; import BackupRow from "$lib/components/app/backup-row.svelte"; interface Props { data: PageData; @@ -107,6 +111,7 @@ let serviceTriggerRef = $state(null); let nodeTriggerRef = $state(null); let dataTriggerRef = $state(null); + let appliedDataFilterSignature = $state(""); const activeFilterCount = $derived( [ @@ -183,6 +188,12 @@ ); $effect(() => { + const nextSignature = dataFilterSignature(); + if (nextSignature === appliedDataFilterSignature) { + return; + } + + appliedDataFilterSignature = nextSignature; currentPage = data.page; currentStatuses = [...data.status] as BackupStatusFilter[]; excludedStatuses = [...data.excludeStatus] as BackupStatusFilter[]; @@ -405,6 +416,19 @@ : $messages.tasks.filters.exclude; } + function dataFilterSignature(): string { + return filterQuerySignature(data.page, { + status: data.status, + excludeStatus: data.excludeStatus, + serviceName: data.serviceName, + excludeServiceName: data.excludeServiceName, + nodeId: data.nodeId, + excludeNodeId: data.excludeNodeId, + dataName: data.dataName, + excludeDataName: data.excludeDataName, + }); + } + function pageUrl( page: number, statuses: string[], @@ -954,7 +978,9 @@ - {#if data.backups.length} + {#if data.error} + + {:else if data.backups.length} {$messages.backups.tableCaption} diff --git a/web/src/routes/(app)/backups/[id]/+page.svelte b/web/src/routes/(app)/backups/[id]/+page.svelte index 9bdfd0a..4300ac8 100644 --- a/web/src/routes/(app)/backups/[id]/+page.svelte +++ b/web/src/routes/(app)/backups/[id]/+page.svelte @@ -62,9 +62,7 @@ ); let defaultRestoreNodeId = $derived( eligibleRestoreNodes.find((node) => node.nodeId === data.backup?.nodeId) - ?.nodeId ?? - eligibleRestoreNodes[0]?.nodeId ?? - "", + ?.nodeId ?? "", ); let restoreWarning = $derived( $messages.backups.restoreWarning.replace("{nodeId}", targetNodeId || "-"), diff --git a/web/src/routes/(app)/nodes/+page.svelte b/web/src/routes/(app)/nodes/+page.svelte index f09ac43..027666d 100644 --- a/web/src/routes/(app)/nodes/+page.svelte +++ b/web/src/routes/(app)/nodes/+page.svelte @@ -48,7 +48,9 @@ - {#if data.nodes.length} + {#if data.error} + + {:else if data.nodes.length}
{$messages.nodes.tableCaption} diff --git a/web/src/routes/(app)/nodes/[id]/docker/containers/+page.svelte b/web/src/routes/(app)/nodes/[id]/docker/containers/+page.svelte index 6642cb1..e57007b 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/containers/+page.svelte +++ b/web/src/routes/(app)/nodes/[id]/docker/containers/+page.svelte @@ -28,6 +28,7 @@ } from '$lib/components/ui/pagination'; import { buildDockerListPageUrl, + dockerSearchDebounceMs, type DockerListSortDirection, } from '$lib/docker-list-query'; import { @@ -68,6 +69,8 @@ const defaultSortField: DockerContainerSortField = 'name'; let searchQuery = $state(''); + let debouncedSearchQuery = $state(''); + let searchDebounceTimer = $state | null>(null); let sortField = $state(defaultSortField); let sortDirection = $state('asc'); let currentPage = $state(1); @@ -89,6 +92,7 @@ refreshing = false; currentPage = data.page; searchQuery = data.search; + debouncedSearchQuery = data.search; sortField = data.sortBy as DockerContainerSortField; sortDirection = data.sortDirection as DockerListSortDirection; }); @@ -100,7 +104,7 @@ if ( currentPage === data.page && - searchQuery === data.search && + debouncedSearchQuery === data.search && sortField === data.sortBy && sortDirection === data.sortDirection ) { @@ -108,11 +112,11 @@ } refreshing = true; - void goto(pageUrl(currentPage, searchQuery, sortField, sortDirection), { + void goto(pageUrl(currentPage, debouncedSearchQuery, sortField, sortDirection), { keepFocus: true, noScroll: true, replaceState: - searchQuery !== data.search || + debouncedSearchQuery !== data.search || sortField !== data.sortBy || sortDirection !== data.sortDirection, }); @@ -169,8 +173,8 @@ } } - function isActionBusy(containerId: string, action: 'start' | 'stop' | 'restart') { - return actionBusyId === `${containerId}:${action}`; + function isActionBusy(containerId: string) { + return actionBusyId.startsWith(`${containerId}:`); } function openRemoveDialog(container: DockerContainerSummary) { @@ -238,7 +242,16 @@ : $messages.common.delete, ); + function flushSearchDebounce() { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } + debouncedSearchQuery = searchQuery; + } + function handleSort(field: string) { + flushSearchDebounce(); if (sortField === field) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -250,11 +263,17 @@ function handleSearchInput() { currentPage = 1; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + debouncedSearchQuery = searchQuery; + searchDebounceTimer = null; + }, dockerSearchDebounceMs); } function clearSearch() { searchQuery = ''; currentPage = 1; + flushSearchDebounce(); } @@ -420,7 +439,7 @@ variant="outline" size="sm" onclick={() => void queueContainerAction(container.id, 'start')} - disabled={isActionBusy(container.id, 'start') || container.state.toLowerCase() === 'running'} + disabled={isActionBusy(container.id) || container.state.toLowerCase() === 'running'} > {$messages.docker.containers.actions.start} @@ -428,7 +447,7 @@ variant="outline" size="sm" onclick={() => void queueContainerAction(container.id, 'stop')} - disabled={isActionBusy(container.id, 'stop') || container.state.toLowerCase() !== 'running'} + disabled={isActionBusy(container.id) || container.state.toLowerCase() !== 'running'} > {$messages.docker.containers.actions.stop} @@ -436,7 +455,7 @@ variant="outline" size="sm" onclick={() => void queueContainerAction(container.id, 'restart')} - disabled={isActionBusy(container.id, 'restart')} + disabled={isActionBusy(container.id)} > {$messages.docker.containers.actions.restart} @@ -444,7 +463,7 @@ variant="destructive" size="sm" onclick={() => openRemoveDialog(container)} - disabled={removeBusyId === container.id} + disabled={isActionBusy(container.id) || removeBusyId === container.id} > {$messages.docker.containers.actions.remove} diff --git a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/actions/[action]/+server.ts b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/actions/[action]/+server.ts index 08dac5c..151a860 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/actions/[action]/+server.ts +++ b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/actions/[action]/+server.ts @@ -8,6 +8,7 @@ import { runContainerAction, type ContainerAction, } from "$lib/server/controller"; +import { serializeContainerAction } from "$lib/server/container-action-queue"; import { jsonControllerError } from "$lib/server/controller-route"; export const POST: RequestHandler = async ({ params }) => { @@ -24,11 +25,11 @@ export const POST: RequestHandler = async ({ params }) => { ); } + const action = params.action; + const containerId = svelteKitRouteParam(params.cid); return json( - await runContainerAction( - params.id, - svelteKitRouteParam(params.cid), - params.action, + await serializeContainerAction(`${params.id}:${containerId}`, () => + runContainerAction(params.id, containerId, action), ), ); } catch (error) { diff --git a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/remove/+server.ts b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/remove/+server.ts index b4d0ad1..5e521bb 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/remove/+server.ts +++ b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/remove/+server.ts @@ -4,6 +4,7 @@ import type { RequestHandler } from "./$types"; import { svelteKitRouteParam } from "$lib/server/docker-route"; import { controllerConfig, removeNodeContainer } from "$lib/server/controller"; +import { serializeContainerAction } from "$lib/server/container-action-queue"; import { jsonControllerError } from "$lib/server/controller-route"; export const POST: RequestHandler = async ({ params, request }) => { @@ -14,11 +15,14 @@ export const POST: RequestHandler = async ({ params, request }) => { try { const payload = await request.json().catch(() => ({})); + const containerId = svelteKitRouteParam(params.cid); return json( - await removeNodeContainer(params.id, svelteKitRouteParam(params.cid), { - force: payload.force === true, - removeVolumes: payload.removeVolumes === true, - }), + await serializeContainerAction(`${params.id}:${containerId}`, () => + removeNodeContainer(params.id, containerId, { + force: payload.force === true, + removeVolumes: payload.removeVolumes === true, + }), + ), ); } catch (error) { return jsonControllerError(error, "Failed to remove container"); diff --git a/web/src/routes/(app)/nodes/[id]/docker/images/+page.svelte b/web/src/routes/(app)/nodes/[id]/docker/images/+page.svelte index 38e2230..8b882e9 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/images/+page.svelte +++ b/web/src/routes/(app)/nodes/[id]/docker/images/+page.svelte @@ -28,6 +28,7 @@ } from '$lib/components/ui/pagination'; import { buildDockerListPageUrl, + dockerSearchDebounceMs, type DockerListSortDirection, } from '$lib/docker-list-query'; import { formatBytes, formatDockerTimestamp, formatShortId } from '$lib/presenters'; @@ -65,6 +66,8 @@ const defaultSortField: DockerImageSortField = 'name'; let searchQuery = $state(''); + let debouncedSearchQuery = $state(''); + let searchDebounceTimer = $state | null>(null); let sortField = $state(defaultSortField); let sortDirection = $state('asc'); let currentPage = $state(1); @@ -85,6 +88,7 @@ refreshing = false; currentPage = data.page; searchQuery = data.search; + debouncedSearchQuery = data.search; sortField = data.sortBy as DockerImageSortField; sortDirection = data.sortDirection as DockerListSortDirection; }); @@ -96,7 +100,7 @@ if ( currentPage === data.page && - searchQuery === data.search && + debouncedSearchQuery === data.search && sortField === data.sortBy && sortDirection === data.sortDirection ) { @@ -104,11 +108,11 @@ } refreshing = true; - void goto(pageUrl(currentPage, searchQuery, sortField, sortDirection), { + void goto(pageUrl(currentPage, debouncedSearchQuery, sortField, sortDirection), { keepFocus: true, noScroll: true, replaceState: - searchQuery !== data.search || + debouncedSearchQuery !== data.search || sortField !== data.sortBy || sortDirection !== data.sortDirection, }); @@ -198,7 +202,16 @@ return image.containersCount > 0 || (image.repoTags?.length ?? 0) > 1; } + function flushSearchDebounce() { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } + debouncedSearchQuery = searchQuery; + } + function handleSort(field: string) { + flushSearchDebounce(); if (sortField === field) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -210,11 +223,17 @@ function handleSearchInput() { currentPage = 1; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + debouncedSearchQuery = searchQuery; + searchDebounceTimer = null; + }, dockerSearchDebounceMs); } function clearSearch() { searchQuery = ''; currentPage = 1; + flushSearchDebounce(); } diff --git a/web/src/routes/(app)/nodes/[id]/docker/networks/+page.svelte b/web/src/routes/(app)/nodes/[id]/docker/networks/+page.svelte index 3211ee8..8249d5e 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/networks/+page.svelte +++ b/web/src/routes/(app)/nodes/[id]/docker/networks/+page.svelte @@ -28,6 +28,7 @@ } from '$lib/components/ui/pagination'; import { buildDockerListPageUrl, + dockerSearchDebounceMs, type DockerListSortDirection, } from '$lib/docker-list-query'; import { formatDockerTimestamp, formatShortId } from '$lib/presenters'; @@ -66,6 +67,8 @@ const defaultSortField: DockerNetworkSortField = 'name'; let searchQuery = $state(''); + let debouncedSearchQuery = $state(''); + let searchDebounceTimer = $state | null>(null); let sortField = $state(defaultSortField); let sortDirection = $state('asc'); let currentPage = $state(1); @@ -86,6 +89,7 @@ refreshing = false; currentPage = data.page; searchQuery = data.search; + debouncedSearchQuery = data.search; sortField = data.sortBy as DockerNetworkSortField; sortDirection = data.sortDirection as DockerListSortDirection; }); @@ -97,7 +101,7 @@ if ( currentPage === data.page && - searchQuery === data.search && + debouncedSearchQuery === data.search && sortField === data.sortBy && sortDirection === data.sortDirection ) { @@ -105,11 +109,11 @@ } refreshing = true; - void goto(pageUrl(currentPage, searchQuery, sortField, sortDirection), { + void goto(pageUrl(currentPage, debouncedSearchQuery, sortField, sortDirection), { keepFocus: true, noScroll: true, replaceState: - searchQuery !== data.search || + debouncedSearchQuery !== data.search || sortField !== data.sortBy || sortDirection !== data.sortDirection, }); @@ -195,7 +199,16 @@ return 'outline'; } + function flushSearchDebounce() { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } + debouncedSearchQuery = searchQuery; + } + function handleSort(field: string) { + flushSearchDebounce(); if (sortField === field) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -207,11 +220,17 @@ function handleSearchInput() { currentPage = 1; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + debouncedSearchQuery = searchQuery; + searchDebounceTimer = null; + }, dockerSearchDebounceMs); } function clearSearch() { searchQuery = ''; currentPage = 1; + flushSearchDebounce(); } diff --git a/web/src/routes/(app)/nodes/[id]/docker/volumes/+page.svelte b/web/src/routes/(app)/nodes/[id]/docker/volumes/+page.svelte index 9ebab66..0021a44 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/volumes/+page.svelte +++ b/web/src/routes/(app)/nodes/[id]/docker/volumes/+page.svelte @@ -28,6 +28,7 @@ } from '$lib/components/ui/pagination'; import { buildDockerListPageUrl, + dockerSearchDebounceMs, type DockerListSortDirection, } from '$lib/docker-list-query'; import { formatBytes, formatDockerTimestamp } from '$lib/presenters'; @@ -63,6 +64,8 @@ const defaultSortField: DockerVolumeSortField = 'name'; let searchQuery = $state(''); + let debouncedSearchQuery = $state(''); + let searchDebounceTimer = $state | null>(null); let sortField = $state(defaultSortField); let sortDirection = $state('asc'); let currentPage = $state(1); @@ -83,6 +86,7 @@ refreshing = false; currentPage = data.page; searchQuery = data.search; + debouncedSearchQuery = data.search; sortField = data.sortBy as DockerVolumeSortField; sortDirection = data.sortDirection as DockerListSortDirection; }); @@ -94,7 +98,7 @@ if ( currentPage === data.page && - searchQuery === data.search && + debouncedSearchQuery === data.search && sortField === data.sortBy && sortDirection === data.sortDirection ) { @@ -102,11 +106,11 @@ } refreshing = true; - void goto(pageUrl(currentPage, searchQuery, sortField, sortDirection), { + void goto(pageUrl(currentPage, debouncedSearchQuery, sortField, sortDirection), { keepFocus: true, noScroll: true, replaceState: - searchQuery !== data.search || + debouncedSearchQuery !== data.search || sortField !== data.sortBy || sortDirection !== data.sortDirection, }); @@ -180,7 +184,16 @@ let removeActionLabel = $derived($messages.common.delete); + function flushSearchDebounce() { + if (searchDebounceTimer) { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = null; + } + debouncedSearchQuery = searchQuery; + } + function handleSort(field: string) { + flushSearchDebounce(); if (sortField === field) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -192,11 +205,17 @@ function handleSearchInput() { currentPage = 1; + if (searchDebounceTimer) clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + debouncedSearchQuery = searchQuery; + searchDebounceTimer = null; + }, dockerSearchDebounceMs); } function clearSearch() { searchQuery = ''; currentPage = 1; + flushSearchDebounce(); } diff --git a/web/src/routes/(app)/services/+page.svelte b/web/src/routes/(app)/services/+page.svelte index 7f093d5..5ad384b 100644 --- a/web/src/routes/(app)/services/+page.svelte +++ b/web/src/routes/(app)/services/+page.svelte @@ -96,7 +96,9 @@ - {#if data.services.length} + {#if data.error} + + {:else if data.services.length}
{$messages.services.tableCaption} diff --git a/web/src/routes/(app)/settings/+page.svelte b/web/src/routes/(app)/settings/+page.svelte index 4f87dda..58a8324 100644 --- a/web/src/routes/(app)/settings/+page.svelte +++ b/web/src/routes/(app)/settings/+page.svelte @@ -19,6 +19,15 @@ AlertTitle, } from "$lib/components/ui/alert"; import { Button } from "$lib/components/ui/button"; + import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogTitle, + } from "$lib/components/ui/dialog"; import { Card, CardContent, @@ -47,6 +56,8 @@ let rusticBusy = $state<"init" | "forget" | "prune" | "">(""); let rusticError = $state(""); let rusticTaskId = $state(""); + let rusticConfirmOpen = $state(false); + let rusticConfirmAction = $state<"forget" | "prune" | null>(null); let syncResult = $state<{ headRevision?: string; syncStatus?: string; @@ -205,6 +216,24 @@ } } + function confirmRusticAction(action: "forget" | "prune") { + if (rusticBusy) return; + rusticConfirmAction = action; + rusticConfirmOpen = true; + } + + function closeRusticConfirm() { + rusticConfirmOpen = false; + rusticConfirmAction = null; + } + + async function runConfirmedRusticAction() { + const action = rusticConfirmAction; + if (!action) return; + closeRusticConfirm(); + await runRusticAction(action); + } + async function runRusticAction(action: "init" | "forget" | "prune") { rusticBusy = action; rusticError = ""; @@ -353,9 +382,9 @@ + + + + diff --git a/web/src/routes/(app)/tasks/+page.svelte b/web/src/routes/(app)/tasks/+page.svelte index d5a1928..e081cd4 100644 --- a/web/src/routes/(app)/tasks/+page.svelte +++ b/web/src/routes/(app)/tasks/+page.svelte @@ -45,7 +45,11 @@ TableRow, } from "$lib/components/ui/table"; import { cn } from "$lib/utils"; - import { buildFilterPageUrl, filterValuesEqual } from "$lib/filter-query"; + import { + buildFilterPageUrl, + filterQuerySignature, + filterValuesEqual, + } from "$lib/filter-query"; import TaskRow from "$lib/components/app/task-row.svelte"; interface Props { data: PageData; @@ -157,6 +161,7 @@ let serviceTriggerRef = $state(null); let nodeTriggerRef = $state(null); let typeTriggerRef = $state(null); + let appliedDataFilterSignature = $state(""); const activeFilterCount = $derived( [ @@ -219,6 +224,12 @@ ); $effect(() => { + const nextSignature = dataFilterSignature(); + if (nextSignature === appliedDataFilterSignature) { + return; + } + + appliedDataFilterSignature = nextSignature; currentPage = data.page; currentStatuses = [...data.status] as TaskStatusFilter[]; excludedStatuses = [...data.excludeStatus] as TaskStatusFilter[]; @@ -427,6 +438,19 @@ : $messages.tasks.filters.exclude; } + function dataFilterSignature(): string { + return filterQuerySignature(data.page, { + status: data.status, + excludeStatus: data.excludeStatus, + serviceName: data.serviceName, + excludeServiceName: data.excludeServiceName, + nodeId: data.nodeId, + excludeNodeId: data.excludeNodeId, + type: data.type, + excludeType: data.excludeType, + }); + } + function pageUrl( page: number, statuses: string[], @@ -977,7 +1001,9 @@ - {#if data.tasks.length} + {#if data.error} + + {:else if data.tasks.length}
{$messages.tasks.tableCaption}