feat: show retention tags in snapshots timeline
This commit is contained in:
parent
0e0eaea946
commit
bb47054990
7 changed files with 98 additions and 25 deletions
|
|
@ -15,4 +15,4 @@ export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
|
||||||
override?: Config<ClientOptions & T>,
|
override?: Config<ClientOptions & T>,
|
||||||
) => Config<Required<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
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://localhost:4096" | (string & {});
|
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetStatusData = {
|
export type GetStatusData = {
|
||||||
|
|
@ -1643,6 +1643,7 @@ export type ListSnapshotsResponses = {
|
||||||
200: Array<{
|
200: Array<{
|
||||||
duration: number;
|
duration: number;
|
||||||
paths: Array<string>;
|
paths: Array<string>;
|
||||||
|
retentionCategories: Array<string>;
|
||||||
short_id: string;
|
short_id: string;
|
||||||
size: number;
|
size: number;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
|
|
@ -1711,6 +1712,7 @@ export type GetSnapshotDetailsResponses = {
|
||||||
200: {
|
200: {
|
||||||
duration: number;
|
duration: number;
|
||||||
paths: Array<string>;
|
paths: Array<string>;
|
||||||
|
retentionCategories: Array<string>;
|
||||||
short_id: string;
|
short_id: string;
|
||||||
size: number;
|
size: number;
|
||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
|
|
|
||||||
81
app/client/components/retention-category-badges.tsx
Normal file
81
app/client/components/retention-category-badges.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
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>
|
||||||
|
<div className={cn("cursor-pointer", className)}>
|
||||||
|
<Badge category={`${categories.length} tags`} />
|
||||||
|
</div>
|
||||||
|
</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 { Card, CardContent } from "~/client/components/ui/card";
|
||||||
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
|
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshots: ListSnapshotsResponse;
|
snapshots: ListSnapshotsResponse;
|
||||||
|
|
@ -58,10 +59,9 @@ export const SnapshotTimeline = (props: Props) => {
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
|
<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 date = new Date(snapshot.time);
|
||||||
const isSelected = snapshotId === snapshot.short_id;
|
const isSelected = snapshotId === snapshot.short_id;
|
||||||
const isLatest = index === snapshots.length - 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -69,7 +69,7 @@ export const SnapshotTimeline = (props: Props) => {
|
||||||
key={snapshot.short_id}
|
key={snapshot.short_id}
|
||||||
onClick={() => onSnapshotSelect(snapshot.short_id)}
|
onClick={() => onSnapshotSelect(snapshot.short_id)}
|
||||||
className={cn(
|
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-2 cursor-pointer",
|
||||||
{
|
{
|
||||||
"border-primary bg-primary/10 shadow-md": isSelected,
|
"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">
|
<div className="text-xs text-muted-foreground opacity-75">
|
||||||
<ByteSize bytes={snapshot.size} />
|
<ByteSize bytes={snapshot.size} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<RetentionCategoryBadges categories={snapshot.retentionCategories} className="mt-1" />
|
||||||
aria-hidden={!isLatest}
|
|
||||||
className={cn("text-xs font-semibold text-primary px-2 py-0.5 bg-primary/20 rounded", {
|
|
||||||
invisible: !isLatest,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Latest
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
||||||
fallback={
|
fallback={
|
||||||
<SnapshotFileBrowser
|
<SnapshotFileBrowser
|
||||||
repositoryId={id}
|
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) {
|
if (backupId) {
|
||||||
try {
|
try {
|
||||||
const schedule = await backupsService.getScheduleByShortId(backupId);
|
const schedule = await backupsService.getScheduleByShortId(backupId);
|
||||||
if (schedule?.retentionPolicy) {
|
|
||||||
const snapshotsForCategories = res.map((snapshot) => ({
|
const snapshotsForCategories = res.map((snapshot) => ({
|
||||||
short_id: snapshot.short_id,
|
short_id: snapshot.short_id,
|
||||||
time: new Date(snapshot.time).getTime(),
|
time: new Date(snapshot.time).getTime(),
|
||||||
}));
|
}));
|
||||||
retentionCategories = computeRetentionCategories(snapshotsForCategories, schedule.retentionPolicy);
|
retentionCategories = computeRetentionCategories(snapshotsForCategories, schedule.retentionPolicy);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to fetch retention policy for backup ID ${backupId}`, toMessage(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) => {
|
export const computeRetentionCategories = (snapshots: SnapshotInfo[], policy: RetentionPolicy | null) => {
|
||||||
const categories = new Map<string, RetentionCategory[]>();
|
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);
|
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) ?? [];
|
const tags = categories.get(id) ?? [];
|
||||||
if (!tags.includes(tag)) categories.set(id, [...tags, tag]);
|
if (!tags.includes(tag)) categories.set(id, [...tags, tag]);
|
||||||
};
|
};
|
||||||
|
addTag(sorted[0].short_id, "latest");
|
||||||
|
|
||||||
if (policy.keepLast && policy.keepLast > 0) {
|
if (!policy) return categories;
|
||||||
sorted.slice(0, 1).forEach((s) => addTag(s.short_id, "latest"));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { prop, tag, fmt } of RETENTION_RULES) {
|
for (const { prop, tag, fmt } of RETENTION_RULES) {
|
||||||
const limit = policy[prop];
|
const limit = policy[prop];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue