Compare commits
3 commits
main
...
v0.32.4-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
572af732ef | ||
|
|
a35c85ddfa | ||
|
|
45ae281a6c |
31 changed files with 401 additions and 89 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 "BASE_URL=http://localhost:4096" >> .env.local
|
||||
echo "E2E_DEX_ORIGIN=http://dex:5557" >> .env.local
|
||||
echo "TZ=Europe/Zurich" >> .env.local
|
||||
|
||||
- name: Start zerobyte-e2e service
|
||||
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
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -121,7 +121,7 @@ If you only need to back up locally mounted folders and don't require remote sha
|
|||
```yaml
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -160,7 +160,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
|
@ -235,7 +235,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
|||
```diff
|
||||
services:
|
||||
zerobyte:
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.30
|
||||
image: ghcr.io/nicotsx/zerobyte:v0.32
|
||||
container_name: zerobyte
|
||||
restart: unless-stopped
|
||||
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 {
|
||||
Breadcrumb,
|
||||
|
|
@ -41,7 +42,7 @@ export function AppBreadcrumb() {
|
|||
const isLast = index === breadcrumbs.length - 1;
|
||||
|
||||
return (
|
||||
<div key={`${breadcrumb.label}-${index}`} className="contents">
|
||||
<Fragment key={`${breadcrumb.label}-${index}`}>
|
||||
<BreadcrumbItem>
|
||||
{isLast || !breadcrumb.href ? (
|
||||
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
|
||||
|
|
@ -52,7 +53,7 @@ export function AppBreadcrumb() {
|
|||
)}
|
||||
</BreadcrumbItem>
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { AlertCircle, CheckCircle2 } from "lucide-react";
|
|||
import { useMemo } from "react";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
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";
|
||||
|
||||
interface CronInputProps {
|
||||
|
|
@ -13,6 +13,7 @@ interface CronInputProps {
|
|||
}
|
||||
|
||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
||||
if (!value) {
|
||||
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 { parseError } from "~/client/lib/errors";
|
||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
|
||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||
initialPath?: string;
|
||||
|
|
@ -26,7 +27,7 @@ export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps
|
|||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { 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 { parseError } from "~/client/lib/errors";
|
||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
|
||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||
repositoryId: string;
|
||||
|
|
@ -79,12 +80,14 @@ export const SnapshotTreeBrowser = ({
|
|||
);
|
||||
},
|
||||
prefetchFolder: (path) => {
|
||||
void queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: { path, offset: 0, limit: pageSize },
|
||||
}),
|
||||
);
|
||||
void queryClient
|
||||
.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { shortId: repositoryId, snapshotId },
|
||||
query: { path, offset: 0, limit: pageSize },
|
||||
}),
|
||||
)
|
||||
.catch((e) => logger.error(e));
|
||||
},
|
||||
pathTransform: {
|
||||
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 { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { logger } from "~/client/lib/logger";
|
||||
|
||||
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
||||
volumeId: string;
|
||||
|
|
@ -29,12 +30,14 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
|||
);
|
||||
},
|
||||
prefetchFolder: (path) => {
|
||||
void queryClient.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { shortId: volumeId },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
void queryClient
|
||||
.prefetchQuery(
|
||||
listFilesOptions({
|
||||
path: { shortId: volumeId },
|
||||
query: { path },
|
||||
}),
|
||||
)
|
||||
.catch((e) => logger.error(e));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -581,8 +581,10 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
|||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
// 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)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={onClick}
|
||||
|
|
@ -591,7 +593,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
|||
>
|
||||
{icon}
|
||||
<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.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 { type AppContext } from "~/context";
|
||||
import { GridBackground } from "./grid-background";
|
||||
import { Button } from "./ui/button";
|
||||
import { Button, buttonVariants } from "./ui/button";
|
||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
import { authClient } from "../lib/auth-client";
|
||||
|
|
@ -63,20 +63,19 @@ export function Layout({ loaderData }: Props) {
|
|||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
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
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
target="_blank"
|
||||
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
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-center w-full h-full"
|
||||
>
|
||||
<LifeBuoy className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<LifeBuoy className="w-4 h-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Report an issue</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import ReactMarkdown from "react-markdown";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||
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";
|
||||
|
||||
interface ReleaseNotesDialogProps {
|
||||
|
|
@ -12,6 +12,8 @@ interface ReleaseNotesDialogProps {
|
|||
}
|
||||
|
||||
export function ReleaseNotesDialog({ open, onOpenChange, updates }: ReleaseNotesDialogProps) {
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
if (!updates) return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import {
|
|||
DialogTitle,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { useTimeFormat } from "~/client/lib/datetime";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import { formatDateTime } from "~/client/lib/datetime";
|
||||
import {
|
||||
deleteSnapshotsMutation,
|
||||
listSnapshotsQueryKey,
|
||||
|
|
@ -49,6 +49,7 @@ type Props = {
|
|||
export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshotsQueryOptions }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
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();
|
||||
});
|
||||
|
||||
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 { 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 {
|
||||
if (!date) return "Never";
|
||||
|
|
@ -13,10 +18,21 @@ function formatValidDate(date: DateInput, formatter: (date: Date) => string): st
|
|||
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
|
||||
export function formatDateTime(date: DateInput): string {
|
||||
export function formatDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
Intl.DateTimeFormat(getLocales(), {
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
@ -27,9 +43,9 @@ export function formatDateTime(date: DateInput): string {
|
|||
}
|
||||
|
||||
// Jan 10, 2026
|
||||
export function formatDateWithMonth(date: DateInput): string {
|
||||
export function formatDateWithMonth(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
Intl.DateTimeFormat(getLocales(), {
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
@ -38,9 +54,9 @@ export function formatDateWithMonth(date: DateInput): string {
|
|||
}
|
||||
|
||||
// 1/10/2026
|
||||
export function formatDate(date: DateInput): string {
|
||||
export function formatDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
Intl.DateTimeFormat(getLocales(), {
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
@ -49,9 +65,9 @@ export function formatDate(date: DateInput): string {
|
|||
}
|
||||
|
||||
// 1/10
|
||||
export function formatShortDate(date: DateInput): string {
|
||||
export function formatShortDate(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
Intl.DateTimeFormat(getLocales(), {
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
}).format(validDate),
|
||||
|
|
@ -59,9 +75,9 @@ export function formatShortDate(date: DateInput): string {
|
|||
}
|
||||
|
||||
// 1/10, 2:30 PM
|
||||
export function formatShortDateTime(date: DateInput): string {
|
||||
export function formatShortDateTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
Intl.DateTimeFormat(getLocales(), {
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
|
|
@ -71,9 +87,9 @@ export function formatShortDateTime(date: DateInput): string {
|
|||
}
|
||||
|
||||
// 2:30 PM
|
||||
export function formatTime(date: DateInput): string {
|
||||
export function formatTime(date: DateInput, options: DateFormatOptions = {}): string {
|
||||
return formatValidDate(date, (validDate) =>
|
||||
Intl.DateTimeFormat(getLocales(), {
|
||||
getDateTimeFormat(options.locale, options.timeZone, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(validDate),
|
||||
|
|
@ -83,6 +99,10 @@ export function formatTime(date: DateInput): string {
|
|||
// 5 minutes ago
|
||||
export function formatTimeAgo(date: DateInput): string {
|
||||
return formatValidDate(date, (validDate) => {
|
||||
if (Math.abs(Date.now() - validDate.getTime()) < 120_000) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const timeAgo = formatDistanceToNow(validDate, {
|
||||
addSuffix: true,
|
||||
includeSeconds: true,
|
||||
|
|
@ -91,3 +111,20 @@ export function formatTimeAgo(date: DateInput): string {
|
|||
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 { Link } from "@tanstack/react-router";
|
||||
import { TimeAgo } from "~/client/components/time-ago";
|
||||
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 { BackupStatusDot } from "./backup-status-dot";
|
||||
import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||
const { formatShortDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
|
||||
<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">
|
||||
<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" />
|
||||
<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 className="flex items-center text-sm gap-2">
|
||||
<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 { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
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 { TimeAgo } from "~/client/components/time-ago";
|
||||
import { useTimeFormat } from "~/client/lib/datetime";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -35,6 +36,7 @@ type Props = {
|
|||
export const ScheduleSummary = (props: Props) => {
|
||||
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
|
||||
props;
|
||||
const { formatShortDateTime } = useTimeFormat();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
|
||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||
|
|
@ -182,7 +184,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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 { Button, buttonVariants } from "~/client/components/ui/button";
|
||||
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 { Link } from "@tanstack/react-router";
|
||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||
|
|
@ -62,6 +62,7 @@ const TreeBrowserFallback = () => (
|
|||
|
||||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
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 { Card, CardContent } from "~/client/components/ui/card";
|
||||
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 { RetentionCategoryBadges } from "~/client/components/retention-category-badges";
|
||||
|
||||
|
|
@ -45,6 +45,7 @@ interface Props {
|
|||
export const SnapshotTimeline = (props: Props) => {
|
||||
const { snapshots, snapshotId, loading, onSnapshotSelect, error, initialSortOrder = "asc" } = props;
|
||||
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||
const { formatDateWithMonth, formatShortDate, formatTime } = useTimeFormat();
|
||||
const [sortOrder, setSortOrder] = useState<SnapshotTimelineSortOrder>(initialSortOrder);
|
||||
const sortedSnapshots = useMemo(() => getSortedSnapshots(snapshots, sortOrder), [snapshots, sortOrder]);
|
||||
const snapshotRange = useMemo(() => getSnapshotRange(snapshots), [snapshots]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
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 { Card, CardTitle } from "~/client/components/ui/card";
|
||||
|
||||
|
|
@ -23,6 +23,8 @@ type Props = {
|
|||
};
|
||||
|
||||
export const DoctorReport = ({ result, repositoryStatus }: Props) => {
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<Card className="px-6 py-6 flex flex-col gap-4 h-full">
|
||||
<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 { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
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 { useState } from "react";
|
||||
import { Database } from "lucide-react";
|
||||
|
|
@ -58,6 +58,7 @@ type Props = {
|
|||
|
||||
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
|
||||
const [showAllPaths, setShowAllPaths] = useState(false);
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const { data: repository } = useSuspenseQuery({
|
||||
...getRepositoryOptions({ path: { shortId: repositoryId } }),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
|
||||
import { formatDateTime, formatTimeAgo } from "~/client/lib/datetime";
|
||||
import {
|
||||
cancelDoctorMutation,
|
||||
deleteRepositoryMutation,
|
||||
|
|
@ -23,6 +22,8 @@ import {
|
|||
unlockRepositoryMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
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 { parseError } from "~/client/lib/errors";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -41,6 +42,7 @@ const getEffectiveLocalPath = (repository: Repository): string | null => {
|
|||
};
|
||||
|
||||
export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) => {
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -206,7 +208,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
|
|||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
{hasLocalPath && (
|
||||
<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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
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 { authClient } from "~/client/lib/auth-client";
|
||||
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 navigate = useNavigate();
|
||||
const { formatDateWithMonth } = useTimeFormat();
|
||||
const { activeOrganization } = useOrganizationContext();
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { healthCheckVolumeMutation, updateVolumeMutation } from "~/client/api-cl
|
|||
import { OnOff } from "~/client/components/onoff";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
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 { TimeAgo } from "~/client/components/time-ago";
|
||||
|
||||
type Props = {
|
||||
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.status === "mounted" && <span className="text-md text-success">Healthy</span>}
|
||||
{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>
|
||||
<OnOff
|
||||
isOn={volume.autoRemount}
|
||||
|
|
@ -69,7 +71,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
|
|||
enabledLabel="Enabled"
|
||||
disabledLabel="Paused"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{volume.status !== "unmounted" && (
|
||||
<div className="flex justify-center">
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export function VolumesPage() {
|
|||
</TableRow>
|
||||
{filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
key={volume.name}
|
||||
key={volume.shortId}
|
||||
className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
|
||||
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -81,9 +81,9 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
|
||||
const deleteVol = useMutation({
|
||||
...deleteVolumeMutation(),
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
toast.success("Volume deleted successfully");
|
||||
void navigate({ to: "/volumes" });
|
||||
await navigate({ to: "/volumes" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete volume", {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useServerEvents } from "~/client/hooks/use-server-events";
|
|||
import { useEffect } from "react";
|
||||
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||
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";
|
||||
|
||||
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";
|
||||
});
|
||||
|
||||
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 }>()({
|
||||
server: {
|
||||
middleware: [apiClientMiddleware],
|
||||
},
|
||||
loader: async () => {
|
||||
const theme = await fetchTheme();
|
||||
return { theme };
|
||||
const [theme, timeConfig] = await Promise.all([fetchTheme(), fetchTimeConfig()]);
|
||||
return { theme, ...timeConfig };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
|
||||
|
|
@ -53,6 +62,10 @@ export function RootLayout() {
|
|||
useServerEvents({ enabled: !isAuthRoute(pathname) });
|
||||
useEffect(() => {
|
||||
document.body.setAttribute("data-app-ready", "true");
|
||||
window.addEventListener("vite:preloadError", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.removeAttribute("data-app-ready");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const listVolumes = async () => {
|
|||
const organizationId = getOrganizationId();
|
||||
const volumes = await db.query.volumesTable.findMany({
|
||||
where: { organizationId: organizationId },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
return volumes;
|
||||
|
|
|
|||
|
|
@ -207,7 +207,11 @@ async function withOidcLoginAttempt(
|
|||
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();
|
||||
|
||||
try {
|
||||
|
|
@ -224,7 +228,7 @@ async function withOidcLoginAttempt(
|
|||
}
|
||||
|
||||
await assertions(page);
|
||||
browserErrorTracker.assertNoBrowserErrors();
|
||||
await browserErrorTracker.assertNoBrowserErrors();
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,26 @@ const IGNORED_PATTERNS: RegExp[] = [
|
|||
|
||||
const isIgnoredError = (text: string) => IGNORED_PATTERNS.some((pattern) => pattern.test(text));
|
||||
|
||||
const isAbortedFetchConsoleError = (record: BrowserErrorRecord, records: BrowserErrorRecord[]) => {
|
||||
if (record.type !== "console.error" || !record.message.includes("TypeError: Failed to fetch")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recordTimestamp = Date.parse(record.timestamp);
|
||||
|
||||
return records.some((candidate) => {
|
||||
if (candidate.type !== "requestfailed" || candidate.pageId !== record.pageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!candidate.message.includes("net::ERR_ABORTED")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Math.abs(Date.parse(candidate.timestamp) - recordTimestamp) <= 1000;
|
||||
});
|
||||
};
|
||||
|
||||
const formatConsoleMessage = (message: ConsoleMessage) => {
|
||||
const location = message.location();
|
||||
const hasLocation = !!location.url;
|
||||
|
|
@ -17,9 +37,108 @@ const formatConsoleMessage = (message: ConsoleMessage) => {
|
|||
return `console.error${formattedLocation}\n${message.text()}`;
|
||||
};
|
||||
|
||||
export const trackBrowserErrors = (context: BrowserContext) => {
|
||||
const browserErrors: string[] = [];
|
||||
type BrowserErrorRecord = {
|
||||
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 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) => {
|
||||
if (trackedPages.has(page)) {
|
||||
|
|
@ -27,6 +146,7 @@ export const trackBrowserErrors = (context: BrowserContext) => {
|
|||
}
|
||||
|
||||
trackedPages.add(page);
|
||||
getPageId(page);
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (message.type() !== "error") {
|
||||
|
|
@ -37,11 +157,24 @@ export const trackBrowserErrors = (context: BrowserContext) => {
|
|||
return;
|
||||
}
|
||||
|
||||
browserErrors.push(formatConsoleMessage(message));
|
||||
pushRecord(page, "console.error", formatConsoleMessage(message));
|
||||
});
|
||||
|
||||
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 +185,65 @@ export const trackBrowserErrors = (context: BrowserContext) => {
|
|||
context.on("page", trackPage);
|
||||
|
||||
return {
|
||||
assertNoBrowserErrors() {
|
||||
if (browserErrors.length === 0) {
|
||||
async assertNoBrowserErrors() {
|
||||
const failingBrowserErrors = browserErrors.filter((record) => {
|
||||
if (record.type === "requestfailed") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isAbortedFetchConsoleError(record, browserErrors);
|
||||
});
|
||||
|
||||
if (failingBrowserErrors.length === 0) {
|
||||
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";
|
||||
|
||||
export const test = base.extend({
|
||||
context: async ({ context }, use) => {
|
||||
const browserErrorTracker = trackBrowserErrors(context);
|
||||
context: async ({ context }, use, testInfo) => {
|
||||
const browserErrorTracker = trackBrowserErrors(context, {
|
||||
attach: async (name, body, contentType) => {
|
||||
await testInfo.attach(name, { body, contentType });
|
||||
},
|
||||
});
|
||||
|
||||
await use(context);
|
||||
|
||||
browserErrorTracker.assertNoBrowserErrors();
|
||||
await browserErrorTracker.assertNoBrowserErrors();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue