From eadfc335a196af57b789cf334495afb76ce51d2e Mon Sep 17 00:00:00 2001 From: knowinglyAnonymous Date: Mon, 1 Jun 2026 13:11:11 +0530 Subject: [PATCH 1/3] feat(settings): show + edit proxy cache TTL on Remote repo Settings tab (#448) Adds a Proxy Cache section to the repo Settings tab that displays and edits the proxy cache TTL on Remote (proxy) repositories. Hidden for Local / Virtual / Staging repos -- those have no proxy cache and the backend rejects PUT /:key/cache-ttl on them anyway. The TTL endpoints (GET / PUT /api/v1/repositories/{key}/cache-ttl) already exist and the SDK already exports getCacheTtl / setCacheTtl, so this is a pure UI change. Behaviour: - Section is gated on repository.repo_type === 'remote'. - Initial value comes from GET /:key/cache-ttl, which falls back to DEFAULT_CACHE_TTL_SECS (86400 = 24h) when nothing is stored -- matching the contract documented by the docs PR #71. - Inline validation mirrors the backend's validate_cache_ttl range (1..=2_592_000). Out-of-range values get an aria-invalid input, an inline error, and disable the Save button. - Helper line shows the human-readable equivalent ('approximately 24 hours', '1 day 6 hours') so operators don't have to mentally convert seconds. - Plugs into the existing hasChanges / Save Changes / Discard workflow. Save dispatches the general-fields update and the new setCacheTtl in parallel via Promise.allSettled; failures on one side don't roll back the other, and each mutation has its own onError toast so the operator can tell which side failed. Adds repositoriesApi.getCacheTtl / setCacheTtl wrappers using the typed SDK calls. No hand-written apiFetch needed. Tests: - getCacheTtl happy path + error surfacing. - setCacheTtl happy path with body-shape pin (cache_ttl_seconds, NOT the legacy 'value' form that the docs PR #71 corrected). - setCacheTtl error surfacing including the backend's 'remote (proxy) repositories' rejection. Closes #448 --- .../_components/repo-settings-tab.tsx | 184 +++++++++++++++++- src/lib/api/__tests__/repositories.test.ts | 73 +++++++ src/lib/api/repositories.ts | 37 ++++ 3 files changed, 288 insertions(+), 6 deletions(-) diff --git a/src/app/(app)/repositories/_components/repo-settings-tab.tsx b/src/app/(app)/repositories/_components/repo-settings-tab.tsx index 63d30518..c5e437de 100644 --- a/src/app/(app)/repositories/_components/repo-settings-tab.tsx +++ b/src/app/(app)/repositories/_components/repo-settings-tab.tsx @@ -48,6 +48,37 @@ import { type QuotaUnit = "MB" | "GB"; +// Backend constraints from `validate_cache_ttl` in repositories.rs: 1s..=30d. +// The constants live here (not on the SDK) so the UI can show a clear inline +// validation error before submitting; the backend would otherwise reject with +// a 400 + opaque message. +const CACHE_TTL_MIN_SECONDS = 1; +const CACHE_TTL_MAX_SECONDS = 30 * 24 * 60 * 60; // 2,592,000 + +/** + * Format a TTL in seconds as a short human-readable hint + * ("24 hours", "1 day 6 hours", "30 minutes"). Used as a helper line under + * the TTL input so operators don't have to compute "is 86400 a sensible + * number?" in their head. + */ +function formatTtlHint(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return ""; + const day = 24 * 60 * 60; + const hour = 60 * 60; + const minute = 60; + const days = Math.floor(seconds / day); + const hours = Math.floor((seconds % day) / hour); + const minutes = Math.floor((seconds % hour) / minute); + const secs = seconds % minute; + + const parts: string[] = []; + if (days) parts.push(`${days} day${days === 1 ? "" : "s"}`); + if (hours) parts.push(`${hours} hour${hours === 1 ? "" : "s"}`); + if (minutes) parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`); + if (secs && parts.length === 0) parts.push(`${secs} second${secs === 1 ? "" : "s"}`); + return parts.join(" "); +} + export interface UpdateRepositoryFields { key?: string; name?: string; @@ -105,6 +136,38 @@ export function RepoSettingsTab({ repository }: RepoSettingsTabProps) { const quotaValue = quotaOverrides.value ?? quotaDefaults.value; const quotaUnit = quotaOverrides.unit ?? quotaDefaults.unit; + // -- Proxy cache TTL state (#448) -- + // Only meaningful for Remote (proxy) repos; the section is hidden for + // Local / Virtual / Staging because writes against those types are + // rejected upstream with 400 (see is_cache_ttl_configurable). We still + // run the GET unconditionally if the section is visible so the read uses + // the same code path the backend tests pin (#917). + const isRemote = repository.repo_type === "remote"; + const { data: cacheTtlData, isLoading: cacheTtlLoading } = useQuery({ + queryKey: ["cache-ttl", repository.key], + queryFn: () => repositoriesApi.getCacheTtl(repository.key), + enabled: isRemote, + }); + const currentCacheTtlSeconds = cacheTtlData?.cache_ttl_seconds; + // String-typed override so the input stays controlled while the user is + // typing (e.g. mid-edit "8" before they finish "86400") without snapping + // to the parsed number on every keystroke. + const [cacheTtlOverride, setCacheTtlOverride] = useState(undefined); + const cacheTtlInputValue = + cacheTtlOverride ?? + (currentCacheTtlSeconds != null ? String(currentCacheTtlSeconds) : ""); + const parsedCacheTtl = + cacheTtlInputValue.trim() === "" ? null : Number(cacheTtlInputValue); + const cacheTtlIsValid = + parsedCacheTtl != null && + Number.isInteger(parsedCacheTtl) && + parsedCacheTtl >= CACHE_TTL_MIN_SECONDS && + parsedCacheTtl <= CACHE_TTL_MAX_SECONDS; + const cacheTtlChanged = + isRemote && + cacheTtlOverride !== undefined && + parsedCacheTtl !== currentCacheTtlSeconds; + // Detect whether the form has unsaved changes const hasChanges = useMemo(() => { if (form.key !== repository.key) return true; @@ -114,8 +177,9 @@ export function RepoSettingsTab({ repository }: RepoSettingsTabProps) { const currentQuotaBytes = quotaToBytes(quotaValue, quotaUnit); const originalQuotaBytes = repository.quota_bytes ?? null; if (currentQuotaBytes !== originalQuotaBytes) return true; + if (cacheTtlChanged) return true; return false; - }, [form, quotaValue, quotaUnit, repository]); + }, [form, quotaValue, quotaUnit, repository, cacheTtlChanged]); // -- Save mutation -- const saveMutation = useMutation({ @@ -135,7 +199,26 @@ export function RepoSettingsTab({ repository }: RepoSettingsTabProps) { onError: mutationErrorToast("Failed to save repository settings"), }); - const handleSave = useCallback(() => { + // -- Cache TTL mutation (#448, separate endpoint from `update`) -- + const setCacheTtlMutation = useMutation({ + mutationFn: (seconds: number) => + repositoriesApi.setCacheTtl(repository.key, seconds), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["cache-ttl", repository.key] }); + setCacheTtlOverride(undefined); + toast.success("Cache TTL saved"); + }, + onError: mutationErrorToast("Failed to save cache TTL"), + }); + + const handleSave = useCallback(async () => { + // The general-fields update and the cache-TTL update are two separate + // backend endpoints, so dispatch them independently. We deliberately do + // NOT short-circuit one on the other's failure: a bad TTL value + // shouldn't roll back a good name change, and the per-mutation toast + // already tells the operator which side failed. The promises are run + // in parallel because both are idempotent and the round-trips are + // independent. const fields: UpdateRepositoryFields = {}; if (form.name !== repository.name) fields.name = form.name; if (form.description !== (repository.description ?? "")) @@ -150,12 +233,34 @@ export function RepoSettingsTab({ repository }: RepoSettingsTabProps) { fields.quota_bytes = newQuota; } - saveMutation.mutate(fields); - }, [form, quotaValue, quotaUnit, repository, keyChanged, saveMutation]); + const promises: Promise[] = []; + if (Object.keys(fields).length > 0) { + promises.push(saveMutation.mutateAsync(fields)); + } + if (cacheTtlChanged && cacheTtlIsValid && parsedCacheTtl != null) { + promises.push(setCacheTtlMutation.mutateAsync(parsedCacheTtl)); + } + // Awaited via Promise.allSettled so a 4xx on one side doesn't surface + // as an unhandled rejection — each mutation already wired its own + // onError toast. + await Promise.allSettled(promises); + }, [ + form, + quotaValue, + quotaUnit, + repository, + keyChanged, + saveMutation, + cacheTtlChanged, + cacheTtlIsValid, + parsedCacheTtl, + setCacheTtlMutation, + ]); const handleDiscard = useCallback(() => { setOverrides({}); setQuotaOverrides({}); + setCacheTtlOverride(undefined); }, []); // -- Lifecycle policies -- @@ -343,6 +448,67 @@ export function RepoSettingsTab({ repository }: RepoSettingsTabProps) { + {/* -- Proxy Cache Section (#448, Remote-only) -- */} + {isRemote && ( + <> +
+

+ Proxy Cache +

+
+

+ How long the proxy keeps cached upstream artifacts before + re-validating against upstream. Applies repository-wide; per- + artifact eviction is available from the artifact details + dialog. +

+ +
+ + {cacheTtlLoading ? ( + + ) : ( + <> + setCacheTtlOverride(e.target.value)} + aria-invalid={ + cacheTtlOverride !== undefined && !cacheTtlIsValid + } + /> +
+ + Range: {CACHE_TTL_MIN_SECONDS}s to{" "} + {CACHE_TTL_MAX_SECONDS.toLocaleString()}s (30 days) + + {parsedCacheTtl != null && cacheTtlIsValid && ( + + ≈ {formatTtlHint(parsedCacheTtl)} + + )} +
+ {cacheTtlOverride !== undefined && !cacheTtlIsValid && ( +

+ Must be a whole number between{" "} + {CACHE_TTL_MIN_SECONDS} and{" "} + {CACHE_TTL_MAX_SECONDS.toLocaleString()}. +

+ )} + + )} +
+
+
+ + + + )} + {/* -- Cleanup Policies Section -- */}
@@ -433,9 +599,15 @@ export function RepoSettingsTab({ repository }: RepoSettingsTabProps) {
-