Compare commits
3 commits
main
...
v0.32.4-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7aa672abf | ||
|
|
a35c85ddfa | ||
|
|
45ae281a6c |
29 changed files with 373 additions and 88 deletions
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
|
|
@ -52,6 +52,7 @@ jobs:
|
||||||
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
echo "ZEROBYTE_DATABASE_URL=./data/zerobyte.db" >> .env.local
|
||||||
echo "BASE_URL=http://localhost:4096" >> .env.local
|
echo "BASE_URL=http://localhost:4096" >> .env.local
|
||||||
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
|
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
|
||||||
|
echo "TZ=Europe/Zurich" >> .env.local
|
||||||
|
|
||||||
- name: Start zerobyte-e2e service
|
- name: Start zerobyte-e2e service
|
||||||
run: bun run start:e2e -- -d
|
run: bun run start:e2e -- -d
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -121,7 +121,7 @@ If you only need to back up locally mounted folders and don't require remote sha
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -160,7 +160,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -235,7 +235,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
|
||||||
20
app/client.tsx
Normal file
20
app/client.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { StrictMode, startTransition } from "react";
|
||||||
|
import { hydrateRoot } from "react-dom/client";
|
||||||
|
import { StartClient } from "@tanstack/react-start/client";
|
||||||
|
import { parseError } from "./client/lib/errors";
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
hydrateRoot(
|
||||||
|
document,
|
||||||
|
<StrictMode>
|
||||||
|
<StartClient />
|
||||||
|
</StrictMode>,
|
||||||
|
{
|
||||||
|
onRecoverableError: (error, errorInfo) => {
|
||||||
|
console.error(
|
||||||
|
`[react-recoverable-error] ${parseError(error)?.message}${errorInfo.componentStack ? `\n${errorInfo.componentStack.trim()}` : ""}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Fragment } from "react";
|
||||||
import { useMatches, Link } from "@tanstack/react-router";
|
import { useMatches, Link } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
|
|
@ -41,7 +42,7 @@ export function AppBreadcrumb() {
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
<Fragment key={`${breadcrumb.label}-${index}`}>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
{isLast || !breadcrumb.href ? (
|
{isLast || !breadcrumb.href ? (
|
||||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||||
|
|
@ -52,7 +53,7 @@ export function AppBreadcrumb() {
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{!isLast && <BreadcrumbSeparator />}
|
{!isLast && <BreadcrumbSeparator />}
|
||||||
</div>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
interface CronInputProps {
|
interface CronInputProps {
|
||||||
|
|
@ -13,6 +13,7 @@ interface CronInputProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
const { isValid, nextRuns, parseError } = useMemo(() => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return { isValid: false, nextRuns: [], parseError: null };
|
return { isValid: false, nextRuns: [], parseError: null };
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
|
|
@ -26,7 +27,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
|
||||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })).catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
|
|
@ -79,12 +80,14 @@ export const SnapshotTreeBrowser = ({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(
|
void queryClient
|
||||||
listSnapshotFilesOptions({
|
.prefetchQuery(
|
||||||
path: { shortId: repositoryId, snapshotId },
|
listSnapshotFilesOptions({
|
||||||
query: { path, offset: 0, limit: pageSize },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
}),
|
query: { path, offset: 0, limit: pageSize },
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
.catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
pathTransform: {
|
pathTransform: {
|
||||||
strip: stripBasePath,
|
strip: stripBasePath,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"
|
||||||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
||||||
volumeId: string;
|
volumeId: string;
|
||||||
|
|
@ -29,12 +30,14 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
void queryClient.prefetchQuery(
|
void queryClient
|
||||||
listFilesOptions({
|
.prefetchQuery(
|
||||||
path: { shortId: volumeId },
|
listFilesOptions({
|
||||||
query: { path },
|
path: { shortId: volumeId },
|
||||||
}),
|
query: { path },
|
||||||
);
|
}),
|
||||||
|
)
|
||||||
|
.catch((e) => logger.error(e));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -581,8 +581,10 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||||
style={{ paddingLeft }}
|
style={{ paddingLeft }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -591,7 +593,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -702,5 +704,7 @@ function compareNodes(a: Node, b: Node): number {
|
||||||
return a.kind === "folder" ? -1 : 1;
|
return a.kind === "folder" ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.name.localeCompare(b.name);
|
if (a.name < b.name) return -1;
|
||||||
|
if (a.name > b.name) return 1;
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { LifeBuoy, LogOut } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
import { GridBackground } from "./grid-background";
|
import { GridBackground } from "./grid-background";
|
||||||
import { Button } from "./ui/button";
|
import { Button, buttonVariants } from "./ui/button";
|
||||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||||
import { AppSidebar } from "./app-sidebar";
|
import { AppSidebar } from "./app-sidebar";
|
||||||
import { authClient } from "../lib/auth-client";
|
import { authClient } from "../lib/auth-client";
|
||||||
|
|
@ -63,20 +63,19 @@ export function Layout({ loaderData }: Props) {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<a
|
||||||
variant="ghost"
|
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||||
size="icon"
|
target="_blank"
|
||||||
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
|
rel="noreferrer"
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "ghost",
|
||||||
|
size: "icon",
|
||||||
|
className:
|
||||||
|
"relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground",
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<a
|
<LifeBuoy className="w-4 h-4" />
|
||||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
</a>
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center justify-center w-full h-full"
|
|
||||||
>
|
|
||||||
<LifeBuoy className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Report an issue</TooltipContent>
|
<TooltipContent>Report an issue</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||||
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||||
import { formatDate } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
import type { UpdateInfoDto } from "~/server/modules/system/system.dto";
|
||||||
|
|
||||||
interface ReleaseNotesDialogProps {
|
interface ReleaseNotesDialogProps {
|
||||||
|
|
@ -12,6 +12,8 @@ interface ReleaseNotesDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
||||||
|
const { formatDate } = useTimeFormat();
|
||||||
|
|
||||||
if (!updates) return null;
|
if (!updates) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/client/components/ui/dialog";
|
} from "~/client/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
|
||||||
import {
|
import {
|
||||||
deleteSnapshotsMutation,
|
deleteSnapshotsMutation,
|
||||||
listSnapshotsQueryKey,
|
listSnapshotsQueryKey,
|
||||||
|
|
@ -49,6 +49,7 @@ type Props = {
|
||||||
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||||
|
|
|
||||||
12
app/client/components/time-ago.tsx
Normal file
12
app/client/components/time-ago.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useTimeFormat, type DateInput } from "~/client/lib/datetime";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
date: DateInput;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimeAgo({ date, className }: Props) {
|
||||||
|
const { formatTimeAgo } = useTimeFormat();
|
||||||
|
|
||||||
|
return <span className={className}>{formatTimeAgo(date)}</span>;
|
||||||
|
}
|
||||||
|
|
@ -60,4 +60,8 @@ describe("datetime formatters", () => {
|
||||||
|
|
||||||
nowSpy.mockRestore();
|
nowSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("formats calendar values with an explicit locale and timezone", () => {
|
||||||
|
expect(formatShortDateTime(sampleDate, { locale: "en-US", timeZone: "UTC" })).toBe("1/10, 2:30 PM");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { formatDistanceToNow, isValid } from "date-fns";
|
import { formatDistanceToNow, isValid } from "date-fns";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Route as RootRoute } from "~/routes/__root";
|
||||||
|
|
||||||
type DateInput = Date | string | number | null | undefined;
|
export type DateInput = Date | string | number | null | undefined;
|
||||||
|
|
||||||
const getLocales = () => (typeof navigator !== "undefined" ? navigator.languages : undefined);
|
type DateFormatOptions = {
|
||||||
|
locale?: string | string[];
|
||||||
|
timeZone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
function formatValidDate(date: DateInput, formatter: (date: Date) => string): string {
|
||||||
if (!date) return "Never";
|
if (!date) return "Never";
|
||||||
|
|
@ -13,10 +18,21 @@ function formatValidDate(date: DateInput, formatter: (date: Date) => string): st
|
||||||
return formatter(parsedDate);
|
return formatter(parsedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDateTimeFormat(
|
||||||
|
locale: DateFormatOptions["locale"],
|
||||||
|
timeZone: DateFormatOptions["timeZone"],
|
||||||
|
options: Intl.DateTimeFormatOptions,
|
||||||
|
) {
|
||||||
|
return Intl.DateTimeFormat(locale, {
|
||||||
|
...options,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 1/10/2026, 2:30 PM
|
// 1/10/2026, 2:30 PM
|
||||||
export function formatDateTime(date: DateInput): string {
|
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -27,9 +43,9 @@ export function formatDateTime(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jan 10, 2026
|
// Jan 10, 2026
|
||||||
export function formatDateWithMonth(date: DateInput): string {
|
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -38,9 +54,9 @@ export function formatDateWithMonth(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10/2026
|
// 1/10/2026
|
||||||
export function formatDate(date: DateInput): string {
|
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -49,9 +65,9 @@ export function formatDate(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10
|
// 1/10
|
||||||
export function formatShortDate(date: DateInput): string {
|
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(validDate),
|
}).format(validDate),
|
||||||
|
|
@ -59,9 +75,9 @@ export function formatShortDate(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1/10, 2:30 PM
|
// 1/10, 2:30 PM
|
||||||
export function formatShortDateTime(date: DateInput): string {
|
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
|
@ -71,9 +87,9 @@ export function formatShortDateTime(date: DateInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2:30 PM
|
// 2:30 PM
|
||||||
export function formatTime(date: DateInput): string {
|
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||||
return formatValidDate(date, (validDate) =>
|
return formatValidDate(date, (validDate) =>
|
||||||
Intl.DateTimeFormat(getLocales(), {
|
getDateTimeFormat(options.locale, options.timeZone, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
}).format(validDate),
|
}).format(validDate),
|
||||||
|
|
@ -83,6 +99,10 @@ export function formatTime(date: DateInput): string {
|
||||||
// 5 minutes ago
|
// 5 minutes ago
|
||||||
export function formatTimeAgo(date: DateInput): string {
|
export function formatTimeAgo(date: DateInput): string {
|
||||||
return formatValidDate(date, (validDate) => {
|
return formatValidDate(date, (validDate) => {
|
||||||
|
if (Math.abs(Date.now() - validDate.getTime()) < 120_000) {
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(validDate, {
|
const timeAgo = formatDistanceToNow(validDate, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
includeSeconds: true,
|
includeSeconds: true,
|
||||||
|
|
@ -91,3 +111,20 @@ export function formatTimeAgo(date: DateInput): string {
|
||||||
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
return timeAgo.replace("about ", "").replace("over ", "").replace("almost ", "").replace("less than ", "");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTimeFormat() {
|
||||||
|
const { locale, timeZone } = RootRoute.useLoaderData();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
formatDateTime: (date: DateInput) => formatDateTime(date, { locale, timeZone }),
|
||||||
|
formatDateWithMonth: (date: DateInput) => formatDateWithMonth(date, { locale, timeZone }),
|
||||||
|
formatDate: (date: DateInput) => formatDate(date, { locale, timeZone }),
|
||||||
|
formatShortDate: (date: DateInput) => formatShortDate(date, { locale, timeZone }),
|
||||||
|
formatShortDateTime: (date: DateInput) => formatShortDateTime(date, { locale, timeZone }),
|
||||||
|
formatTime: (date: DateInput) => formatTime(date, { locale, timeZone }),
|
||||||
|
formatTimeAgo,
|
||||||
|
}),
|
||||||
|
[locale, timeZone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
import { CalendarClock, Database, HardDrive } from "lucide-react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import type { BackupSchedule } from "~/client/lib/types";
|
import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupStatusDot } from "./backup-status-dot";
|
import { BackupStatusDot } from "./backup-status-dot";
|
||||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
|
const { formatShortDateTime } = useTimeFormat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
||||||
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
|
<Card interactive key={schedule.shortId} className="flex flex-col h-full">
|
||||||
|
|
@ -42,7 +45,7 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">Last backup</span>
|
<span className="text-muted-foreground shrink-0">Last backup</span>
|
||||||
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
<div className="flex-1 border-b border-dashed border-border/80 dark:border-border/50" />
|
||||||
<span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
<TimeAgo date={schedule.lastBackupAt} className="text-foreground font-mono text-sm shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm gap-2">
|
<div className="flex items-center text-sm gap-2">
|
||||||
<span className="text-muted-foreground shrink-0">Next backup</span>
|
<span className="text-muted-foreground shrink-0">Next backup</span>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ import type { BackupSchedule } from "~/client/lib/types";
|
||||||
import { BackupProgressCard } from "./backup-progress-card";
|
import { BackupProgressCard } from "./backup-progress-card";
|
||||||
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -35,6 +36,7 @@ type Props = {
|
||||||
export const ScheduleSummary = (props: Props) => {
|
export const ScheduleSummary = (props: Props) => {
|
||||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||||
props;
|
props;
|
||||||
|
const { formatShortDateTime } = useTimeFormat();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||||
|
|
@ -182,7 +184,7 @@ export const ScheduleSummary = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
|
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
|
||||||
<p className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</p>
|
<TimeAgo date={schedule.lastBackupAt} className="font-medium" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
|
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button, buttonVariants } from "~/client/components/ui/button";
|
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||||
import type { Snapshot } from "~/client/lib/types";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||||
|
|
@ -62,6 +62,7 @@ const TreeBrowserFallback = () => (
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
|
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { ListSnapshotsResponse } from "~/client/api-client";
|
||||||
import { ByteSize } from "~/client/components/bytes-size";
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
import { Card, CardContent } from "~/client/components/ui/card";
|
import { Card, CardContent } from "~/client/components/ui/card";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { formatDateWithMonth, formatShortDate, formatTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
|
import { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
|
||||||
|
|
||||||
|
|
@ -45,6 +45,7 @@ interface Props {
|
||||||
export const SnapshotTimeline = (props: Props) => {
|
export const SnapshotTimeline = (props: Props) => {
|
||||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props;
|
const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props;
|
||||||
const selectedRef = useRef<HTMLButtonElement>(null);
|
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const { formatDateWithMonth, formatShortDate, formatTime } = useTimeFormat();
|
||||||
const [sortOrder, setSortOrder] = useState<SnapshotTimelineSortOrder>(initialSortOrder);
|
const [sortOrder, setSortOrder] = useState<SnapshotTimelineSortOrder>(initialSortOrder);
|
||||||
const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]);
|
const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]);
|
||||||
const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]);
|
const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { cn, safeJsonParse } from "~/client/lib/utils";
|
import { cn, safeJsonParse } from "~/client/lib/utils";
|
||||||
import { Card, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
|
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="px-6 py-6 flex flex-col gap-4 h-full">
|
<Card className="px-6 py-6 flex flex-col gap-4 h-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
|
|
@ -58,6 +58,7 @@ type Props = {
|
||||||
|
|
||||||
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
|
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
|
||||||
const [showAllPaths, setShowAllPaths] = useState(false);
|
const [showAllPaths, setShowAllPaths] = useState(false);
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
|
|
||||||
const { data: repository } = useSuspenseQuery({
|
const { data: repository } = useSuspenseQuery({
|
||||||
...getRepositoryOptions({ path: { shortId: repositoryId } }),
|
...getRepositoryOptions({ path: { shortId: repositoryId } }),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import type { Repository } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
||||||
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import {
|
import {
|
||||||
cancelDoctorMutation,
|
cancelDoctorMutation,
|
||||||
deleteRepositoryMutation,
|
deleteRepositoryMutation,
|
||||||
|
|
@ -23,6 +22,8 @@ import {
|
||||||
unlockRepositoryMutation,
|
unlockRepositoryMutation,
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { DoctorReport } from "../components/doctor-report";
|
import { DoctorReport } from "../components/doctor-report";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
@ -41,6 +42,7 @@ const getEffectiveLocalPath = (repository: Repository): string | null => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
|
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
|
||||||
|
const { formatDateTime } = useTimeFormat();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -206,7 +208,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
|
||||||
<p className="text-sm">{formatTimeAgo(repository.lastChecked)}</p>
|
<TimeAgo date={repository.lastChecked} className="text-sm" />
|
||||||
</div>
|
</div>
|
||||||
{hasLocalPath && (
|
{hasLocalPath && (
|
||||||
<div className="flex flex-col gap-1 @medium:col-span-2">
|
<div className="flex flex-col gap-1 @medium:col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
||||||
import { Switch } from "~/client/components/ui/switch";
|
import { Switch } from "~/client/components/ui/switch";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
import { formatDateWithMonth } from "~/client/lib/datetime";
|
import { useTimeFormat } from "~/client/lib/datetime";
|
||||||
import { getOrigin } from "~/client/functions/get-origin";
|
import { getOrigin } from "~/client/functions/get-origin";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
@ -47,6 +47,7 @@ export function SsoSettingsSection({ initialSettings, initialOrigin }: Props) {
|
||||||
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
|
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery, initialData: initialOrigin });
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDateWithMonth } = useTimeFormat();
|
||||||
const { activeOrganization } = useOrganizationContext();
|
const { activeOrganization } = useOrganizationContext();
|
||||||
const [inviteEmail, setInviteEmail] = useState("");
|
const [inviteEmail, setInviteEmail] = useState("");
|
||||||
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
|
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-cl
|
||||||
import { OnOff } from "~/client/components/onoff";
|
import { OnOff } from "~/client/components/onoff";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { formatTimeAgo } from "~/client/lib/datetime";
|
|
||||||
import type { Volume } from "~/client/lib/types";
|
import type { Volume } from "~/client/lib/types";
|
||||||
|
import { TimeAgo } from "~/client/components/time-ago";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
|
|
@ -53,9 +53,11 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||||
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
|
||||||
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
|
{volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
|
||||||
{volume.status !== "unmounted" && (
|
{volume.status !== "unmounted" && (
|
||||||
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
|
<span className="text-xs text-muted-foreground mb-4">
|
||||||
|
Checked <TimeAgo date={volume.lastHealthCheck} />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex justify-between items-center gap-2">
|
<div className="flex justify-between items-center gap-2">
|
||||||
<span className="text-sm">Remount on error</span>
|
<span className="text-sm">Remount on error</span>
|
||||||
<OnOff
|
<OnOff
|
||||||
isOn={volume.autoRemount}
|
isOn={volume.autoRemount}
|
||||||
|
|
@ -69,7 +71,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
||||||
enabledLabel="Enabled"
|
enabledLabel="Enabled"
|
||||||
disabledLabel="Paused"
|
disabledLabel="Paused"
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{volume.status !== "unmounted" && (
|
{volume.status !== "unmounted" && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,9 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
|
|
||||||
const deleteVol = useMutation({
|
const deleteVol = useMutation({
|
||||||
...deleteVolumeMutation(),
|
...deleteVolumeMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
toast.success("Volume deleted successfully");
|
toast.success("Volume deleted successfully");
|
||||||
void navigate({ to: "/volumes" });
|
await navigate({ to: "/volumes" });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete volume", {
|
toast.error("Failed to delete volume", {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getCookie } from "@tanstack/react-start/server";
|
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { isAuthRoute } from "~/lib/auth-routes";
|
import { isAuthRoute } from "~/lib/auth-routes";
|
||||||
|
|
||||||
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
|
@ -16,13 +16,22 @@ const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
|
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchTimeConfig = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
const acceptLanguage = getRequestHeaders().get("accept-language");
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
|
||||||
|
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
server: {
|
server: {
|
||||||
middleware: [apiClientMiddleware],
|
middleware: [apiClientMiddleware],
|
||||||
},
|
},
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const theme = await fetchTheme();
|
const [theme, timeConfig] = await Promise.all([fetchTheme(), fetchTimeConfig()]);
|
||||||
return { theme };
|
return { theme, ...timeConfig };
|
||||||
},
|
},
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
||||||
|
|
@ -53,6 +62,10 @@ export function RootLayout() {
|
||||||
useServerEvents({ enabled: !isAuthRoute(pathname) });
|
useServerEvents({ enabled: !isAuthRoute(pathname) });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.setAttribute("data-app-ready", "true");
|
document.body.setAttribute("data-app-ready", "true");
|
||||||
|
window.addEventListener("vite:preloadError", () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.removeAttribute("data-app-ready");
|
document.body.removeAttribute("data-app-ready");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,11 @@ async function withOidcLoginAttempt(
|
||||||
origins: [],
|
origins: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const browserErrorTracker = trackBrowserErrors(context);
|
const browserErrorTracker = trackBrowserErrors(context, {
|
||||||
|
attach: async (name, body, contentType) => {
|
||||||
|
await test.info().attach(name, { body, contentType });
|
||||||
|
},
|
||||||
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -224,7 +228,7 @@ async function withOidcLoginAttempt(
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertions(page);
|
await assertions(page);
|
||||||
browserErrorTracker.assertNoBrowserErrors();
|
await browserErrorTracker.assertNoBrowserErrors();
|
||||||
} finally {
|
} finally {
|
||||||
await context.close().catch(() => undefined);
|
await context.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,108 @@ const formatConsoleMessage = (message: ConsoleMessage) => {
|
||||||
return `console.error${formattedLocation}\n${message.text()}`;
|
return `console.error${formattedLocation}\n${message.text()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trackBrowserErrors = (context: BrowserContext) => {
|
type BrowserErrorRecord = {
|
||||||
const browserErrors: string[] = [];
|
type: "console.error" | "pageerror" | "requestfailed";
|
||||||
|
pageId: string;
|
||||||
|
pageUrl: string;
|
||||||
|
timestamp: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FirstPageErrorSnapshot = {
|
||||||
|
pageId: string;
|
||||||
|
pageUrl: string;
|
||||||
|
timestamp: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
clientLocale: string;
|
||||||
|
clientLanguages: string[];
|
||||||
|
clientTimeZone: string;
|
||||||
|
appReady: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrackBrowserErrorsOptions = {
|
||||||
|
attach?: (name: string, body: string | Buffer, contentType: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBrowserErrorRecord = (record: BrowserErrorRecord) => {
|
||||||
|
return `[${record.timestamp}] ${record.type} on ${record.pageId} (${record.pageUrl})\n${record.message}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function captureFirstPageErrorSnapshot(
|
||||||
|
page: Page,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<{
|
||||||
|
screenshot: Buffer;
|
||||||
|
snapshot: FirstPageErrorSnapshot;
|
||||||
|
}> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const pageUrl = page.url();
|
||||||
|
const screenshot = await page.screenshot({ fullPage: true, type: "png" });
|
||||||
|
const snapshot = await page.evaluate(
|
||||||
|
({ currentPageId, currentTimestamp }) => ({
|
||||||
|
pageId: currentPageId,
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
timestamp: currentTimestamp,
|
||||||
|
title: document.title,
|
||||||
|
html: document.documentElement.outerHTML,
|
||||||
|
clientLocale: navigator.language,
|
||||||
|
clientLanguages: [...navigator.languages],
|
||||||
|
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
appReady: document.body?.getAttribute("data-app-ready") ?? null,
|
||||||
|
}),
|
||||||
|
{ currentPageId: pageId, currentTimestamp: timestamp },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenshot,
|
||||||
|
snapshot: {
|
||||||
|
...snapshot,
|
||||||
|
pageUrl: snapshot.pageUrl || pageUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackBrowserErrors = (context: BrowserContext, options: TrackBrowserErrorsOptions = {}) => {
|
||||||
|
const browserErrors: BrowserErrorRecord[] = [];
|
||||||
const trackedPages = new WeakSet<Page>();
|
const trackedPages = new WeakSet<Page>();
|
||||||
|
const pageIds = new WeakMap<Page, string>();
|
||||||
|
let nextPageId = 1;
|
||||||
|
let firstPageErrorSnapshotPromise: Promise<Awaited<ReturnType<typeof captureFirstPageErrorSnapshot>>> | undefined;
|
||||||
|
|
||||||
|
void context.addInitScript(() => {
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const reason =
|
||||||
|
event.reason instanceof Error
|
||||||
|
? (event.reason.stack ?? event.reason.message)
|
||||||
|
: typeof event.reason === "string"
|
||||||
|
? event.reason
|
||||||
|
: JSON.stringify(event.reason);
|
||||||
|
|
||||||
|
console.error(`[unhandledrejection]\n${reason}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPageId = (page: Page) => {
|
||||||
|
const existingPageId = pageIds.get(page);
|
||||||
|
if (existingPageId) {
|
||||||
|
return existingPageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageId = `page-${nextPageId++}`;
|
||||||
|
pageIds.set(page, pageId);
|
||||||
|
return pageId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushRecord = (page: Page, type: BrowserErrorRecord["type"], message: string) => {
|
||||||
|
browserErrors.push({
|
||||||
|
type,
|
||||||
|
pageId: getPageId(page),
|
||||||
|
pageUrl: page.url(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const trackPage = (page: Page) => {
|
const trackPage = (page: Page) => {
|
||||||
if (trackedPages.has(page)) {
|
if (trackedPages.has(page)) {
|
||||||
|
|
@ -27,6 +126,7 @@ export const trackBrowserErrors = (context: BrowserContext) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
trackedPages.add(page);
|
trackedPages.add(page);
|
||||||
|
getPageId(page);
|
||||||
|
|
||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
if (message.type() !== "error") {
|
if (message.type() !== "error") {
|
||||||
|
|
@ -37,11 +137,24 @@ export const trackBrowserErrors = (context: BrowserContext) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
browserErrors.push(formatConsoleMessage(message));
|
pushRecord(page, "console.error", formatConsoleMessage(message));
|
||||||
});
|
});
|
||||||
|
|
||||||
page.on("pageerror", (error) => {
|
page.on("pageerror", (error) => {
|
||||||
browserErrors.push(`pageerror\n${error.stack ?? error.message}`);
|
pushRecord(page, "pageerror", error.stack ?? error.message);
|
||||||
|
|
||||||
|
if (!firstPageErrorSnapshotPromise && !page.isClosed()) {
|
||||||
|
firstPageErrorSnapshotPromise = captureFirstPageErrorSnapshot(page, getPageId(page));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on("requestfailed", (request) => {
|
||||||
|
const failure = request.failure();
|
||||||
|
if (!failure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushRecord(page, "requestfailed", `${request.method()} ${request.url()}\n${failure.errorText}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -52,12 +165,59 @@ export const trackBrowserErrors = (context: BrowserContext) => {
|
||||||
context.on("page", trackPage);
|
context.on("page", trackPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assertNoBrowserErrors() {
|
async assertNoBrowserErrors() {
|
||||||
if (browserErrors.length === 0) {
|
const failingBrowserErrors = browserErrors.filter((record) => record.type !== "requestfailed");
|
||||||
|
|
||||||
|
if (failingBrowserErrors.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Browser console errors detected:\n\n${browserErrors.join("\n\n")}`);
|
if (options.attach) {
|
||||||
|
await options.attach(
|
||||||
|
"browser-errors.md",
|
||||||
|
[
|
||||||
|
"# Browser error diagnostics",
|
||||||
|
"",
|
||||||
|
...browserErrors.flatMap((record) => [formatBrowserErrorRecord(record), ""]),
|
||||||
|
].join("\n"),
|
||||||
|
"text/markdown",
|
||||||
|
);
|
||||||
|
await options.attach(
|
||||||
|
"browser-errors.json",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
records: browserErrors,
|
||||||
|
firstPageErrorSnapshot: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPageErrorSnapshot = firstPageErrorSnapshotPromise ? await firstPageErrorSnapshotPromise : undefined;
|
||||||
|
|
||||||
|
if (options.attach && firstPageErrorSnapshot) {
|
||||||
|
await options.attach(
|
||||||
|
"browser-errors.json",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
records: browserErrors,
|
||||||
|
firstPageErrorSnapshot: firstPageErrorSnapshot.snapshot,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
await options.attach("first-pageerror.png", firstPageErrorSnapshot.screenshot, "image/png");
|
||||||
|
await options.attach("first-pageerror.html", firstPageErrorSnapshot.snapshot.html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Browser console errors detected:\n\n${failingBrowserErrors.map(formatBrowserErrorRecord).join("\n\n")}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
10
e2e/test.ts
10
e2e/test.ts
|
|
@ -2,12 +2,16 @@ import { expect, test as base } from "@playwright/test";
|
||||||
import { trackBrowserErrors } from "./helpers/browser-errors";
|
import { trackBrowserErrors } from "./helpers/browser-errors";
|
||||||
|
|
||||||
export const test = base.extend({
|
export const test = base.extend({
|
||||||
context: async ({ context }, use) => {
|
context: async ({ context }, use, testInfo) => {
|
||||||
const browserErrorTracker = trackBrowserErrors(context);
|
const browserErrorTracker = trackBrowserErrors(context, {
|
||||||
|
attach: async (name, body, contentType) => {
|
||||||
|
await testInfo.attach(name, { body, contentType });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await use(context);
|
await use(context);
|
||||||
|
|
||||||
browserErrorTracker.assertNoBrowserErrors();
|
await browserErrorTracker.assertNoBrowserErrors();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue