feat: show retention tags in snapshots timeline (#462)

* feat: show retention tags in snapshots timeline

* refactor: use button for accesibility
This commit is contained in:
Nico 2026-02-08 16:14:43 +01:00 committed by GitHub
parent 0e0eaea946
commit 09c1cbbb94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 102 additions and 25 deletions

View file

@ -15,4 +15,4 @@ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }));
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://192.168.2.42:4096" }));

View file

@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: "http://localhost:4096" | (string & {});
baseUrl: "http://192.168.2.42:4096" | (string & {});
};
export type GetStatusData = {
@ -1643,6 +1643,7 @@ export type ListSnapshotsResponses = {
200: Array<{
duration: number;
paths: Array<string>;
retentionCategories: Array<string>;
short_id: string;
size: number;
tags: Array<string>;
@ -1711,6 +1712,7 @@ export type GetSnapshotDetailsResponses = {
200: {
duration: number;
paths: Array<string>;
retentionCategories: Array<string>;
short_id: string;
size: number;
tags: Array<string>;

View file

@ -0,0 +1,85 @@
import { cn } from "~/client/lib/utils";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
interface RetentionCategoryBadgesProps {
categories: string[];
className?: string;
}
const categoryColors: Record<string, string> = {
latest: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
hourly: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
daily: "bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
weekly: "bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30",
monthly: "bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
yearly: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
};
const categoryLabels: Record<string, string> = {
latest: "Latest",
hourly: "Hourly",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
yearly: "Yearly",
};
function Badge({ category }: { category: string }) {
return (
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
categoryColors[category] || "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
)}
>
{categoryLabels[category] || category}
</span>
);
}
export function RetentionCategoryBadges({ categories, className }: RetentionCategoryBadgesProps) {
if (categories.length === 0) {
return null;
}
const order = ["latest", "hourly", "daily", "weekly", "monthly", "yearly"];
const sortedCategories = [...categories].sort((a, b) => {
const indexA = order.indexOf(a);
const indexB = order.indexOf(b);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
const firstCategory = sortedCategories[0];
const hasMore = sortedCategories.length > 1;
if (!hasMore) {
return (
<div className={className}>
<Badge category={firstCategory} />
</div>
);
}
return (
<HoverCard>
<HoverCardTrigger asChild>
<button
type="button"
className={cn("cursor-pointer bg-transparent p-0 border-0", className)}
aria-label={`View ${categories.length} retention categories`}
>
<Badge category={`${categories.length} tags`} />
</button>
</HoverCardTrigger>
<HoverCardContent className="w-auto p-2">
<div className="flex flex-wrap gap-1">
{sortedCategories.map((category) => (
<Badge key={category} category={category} />
))}
</div>
</HoverCardContent>
</HoverCard>
);
}

View file

@ -4,6 +4,7 @@ import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent } from "~/client/components/ui/card";
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
interface Props {
snapshots: ListSnapshotsResponse;
@ -58,10 +59,9 @@ export const SnapshotTimeline = (props: Props) => {
<div className="relative flex items-center">
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
{snapshots.map((snapshot, index) => {
{snapshots.map((snapshot) => {
const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id;
const isLatest = index === snapshots.length - 1;
return (
<button
@ -69,7 +69,7 @@ export const SnapshotTimeline = (props: Props) => {
key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)}
className={cn(
"shrink-0 flex flex-col items-center gap-2 p-3 rounded-lg transition-all",
"shrink-0 flex flex-col items-center gap-2 p-3 rounded-lg transition-all w-25",
"border-2 cursor-pointer",
{
"border-primary bg-primary/10 shadow-md": isSelected,
@ -82,14 +82,7 @@ export const SnapshotTimeline = (props: Props) => {
<div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} />
</div>
<div
aria-hidden={!isLatest}
className={cn("text-xs font-semibold text-primary px-2 py-0.5 bg-primary/20 rounded", {
invisible: !isLatest,
})}
>
Latest
</div>
<RetentionCategoryBadges categories={snapshot.retentionCategories} className="mt-1" />
</button>
);
})}

View file

@ -107,7 +107,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
fallback={
<SnapshotFileBrowser
repositoryId={id}
snapshot={{ duration: 0, paths: [], short_id: "", size: 0, tags: [], time: 0 }}
snapshot={{ duration: 0, paths: [], short_id: "", size: 0, tags: [], time: 0, retentionCategories: [] }}
/>
}
>

View file

@ -96,13 +96,11 @@ export const repositoriesController = new Hono()
if (backupId) {
try {
const schedule = await backupsService.getScheduleByShortId(backupId);
if (schedule?.retentionPolicy) {
const snapshotsForCategories = res.map((snapshot) => ({
short_id: snapshot.short_id,
time: new Date(snapshot.time).getTime(),
}));
retentionCategories = computeRetentionCategories(snapshotsForCategories, schedule.retentionPolicy);
}
const snapshotsForCategories = res.map((snapshot) => ({
short_id: snapshot.short_id,
time: new Date(snapshot.time).getTime(),
}));
retentionCategories = computeRetentionCategories(snapshotsForCategories, schedule.retentionPolicy);
} catch (error) {
logger.warn(`Failed to fetch retention policy for backup ID ${backupId}`, toMessage(error));
}

View file

@ -19,7 +19,7 @@ const RETENTION_RULES = [
export const computeRetentionCategories = (snapshots: SnapshotInfo[], policy: RetentionPolicy | null) => {
const categories = new Map<string, RetentionCategory[]>();
if (!policy || snapshots.length === 0) return categories;
if (snapshots.length === 0) return categories;
const sorted = [...snapshots].sort((a, b) => b.time - a.time);
@ -27,10 +27,9 @@ export const computeRetentionCategories = (snapshots: SnapshotInfo[], policy: Re
const tags = categories.get(id) ?? [];
if (!tags.includes(tag)) categories.set(id, [...tags, tag]);
};
addTag(sorted[0].short_id, "latest");
if (policy.keepLast && policy.keepLast > 0) {
sorted.slice(0, 1).forEach((s) => addTag(s.short_id, "latest"));
}
if (!policy) return categories;
for (const { prop, tag, fmt } of RETENTION_RULES) {
const limit = policy[prop];