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:
parent
0e0eaea946
commit
09c1cbbb94
7 changed files with 102 additions and 25 deletions
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
85
app/client/components/retention-category-badges.tsx
Normal file
85
app/client/components/retention-category-badges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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: [] }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue