feat: polish release v2 experience (#6800)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-07 19:17:37 +02:00
1 parent 925b0fb801
commit a4a903d7c3
295 files changed
+10627 -7115

No files matched your search

@@ -1,32 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback } from "react";
import { usePathname } from "next/navigation";
import type { AccordionProps } from "@mantine/core";
import { Accordion } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
type ActiveTabAccordionProps = PropsWithChildren<Omit<AccordionProps<false>, "onChange">>;
// Replace state without fetchign new data
const replace = (newUrl: string) => {
window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl);
};
export const ActiveTabAccordion = ({ children, ...props }: ActiveTabAccordionProps) => {
const pathname = usePathname();
const onChange = useCallback((tab: string | null) => (tab ? replace(`?tab=${tab}`) : replace(pathname)), [pathname]);
useShallowEffect(() => {
if (props.defaultValue) {
replace(`?tab=${props.defaultValue}`);
}
}, [props.defaultValue]);
return (
<Accordion {...props} onChange={onChange}>
{children}
</Accordion>
);
};
@@ -1,9 +1,8 @@
import type { UIMessage } from "ai";
const inlineAttachmentUrlPattern = /^data:[^,]+;base64,/u;
import { isRecord } from "@homarr/common";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const inlineAttachmentUrlPattern = /^data:[^,]+;base64,/u;
const isFilePart = (value: unknown): value is Record<string, unknown> & { type: "file" } =>
isRecord(value) && value.type === "file";
@@ -1,3 +1,5 @@
import { isRecord } from "@homarr/common";
export interface AssistantConversationExportMessage {
id: string;
parentId: string | null;
@@ -25,8 +27,7 @@ const bearerTokenPattern = /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/giu;
const commonApiKeyPattern = /\bsk-(?:or-v1-)?[A-Za-z0-9_-]{16,}\b/giu;
const maxDebugStringLength = 12_000;
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
const redactDebugValue = (value: unknown, key?: string): unknown => {
if (key && sensitiveKeyPattern.test(key)) return "[redacted]";
@@ -1,5 +1,7 @@
import type { UIMessage } from "ai";
import { isRecord } from "@homarr/common";
export type AssistantUsage = {
inputTokens?: number;
outputTokens?: number;
@@ -140,12 +142,11 @@ const getWebSearchSources = (value: unknown): AssistantWebSearchSource[] => {
if (!Array.isArray(value)) return [];
const sources = new Map<string, AssistantWebSearchSource>();
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const candidate = item as Record<string, unknown>;
if (typeof candidate.url !== "string" || !URL.canParse(candidate.url)) continue;
const url = new URL(candidate.url);
if (!isRecord(item)) continue;
if (typeof item.url !== "string" || !URL.canParse(item.url)) continue;
const url = new URL(item.url);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) continue;
const title = typeof candidate.title === "string" ? candidate.title.trim().slice(0, 200) : "";
const title = typeof item.title === "string" ? item.title.trim().slice(0, 200) : "";
if (!sources.has(url.href)) sources.set(url.href, { url: url.href, ...(title ? { title } : {}) });
if (sources.size >= 12) break;
}
@@ -153,10 +154,10 @@ const getWebSearchSources = (value: unknown): AssistantWebSearchSource[] => {
};
export const getAssistantUsage = (metadata: unknown): AssistantUsage | null => {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || !("usage" in metadata)) return null;
if (!isRecord(metadata) || !("usage" in metadata)) return null;
const usage = metadata.usage;
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
const value = usage as Record<string, unknown>;
if (!isRecord(usage)) return null;
const value = usage;
return {
...(getFiniteNonNegativeNumber(value.inputTokens) !== undefined
? { inputTokens: getFiniteNonNegativeNumber(value.inputTokens) }
@@ -180,11 +181,11 @@ export const getAssistantUsage = (metadata: unknown): AssistantUsage | null => {
};
export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTelemetry | null => {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
const custom = (metadata as { custom?: unknown }).custom;
if (!custom || typeof custom !== "object" || Array.isArray(custom)) return null;
const telemetry = (custom as { telemetry?: unknown }).telemetry;
if (!telemetry || typeof telemetry !== "object" || Array.isArray(telemetry)) return null;
if (!isRecord(metadata)) return null;
const custom = metadata.custom;
if (!isRecord(custom)) return null;
const telemetry = custom.telemetry;
if (!isRecord(telemetry)) return null;
const value = telemetry as Partial<AssistantRequestTelemetry>;
if (
typeof value.requestId !== "string" ||
@@ -196,8 +197,8 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
}
const steps = Array.isArray(value.steps)
? value.steps.flatMap((step): AssistantRequestStep[] => {
if (!step || typeof step !== "object" || Array.isArray(step)) return [];
const candidate = step as Record<string, unknown>;
if (!isRecord(step)) return [];
const candidate = step;
const index = getFiniteNonNegativeNumber(candidate.index);
const durationMs = getFiniteNonNegativeNumber(candidate.durationMs);
const modelDurationMs = getFiniteNonNegativeNumber(candidate.modelDurationMs);
@@ -1,5 +1,6 @@
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
import { isRecord } from "@homarr/common";
const asRecord = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);
export const getAssistantIconSearchQuery = (toolName: string, args: unknown) => {
if (toolName !== "icon_findIcons") return null;
@@ -20,6 +20,7 @@ import {
import { IconCheck, IconChevronDown, IconLink, IconRobot, IconX } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { isRecord } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
@@ -154,9 +155,9 @@ const ToolResultPresentation = ({ result, toolName }: { result: unknown; toolNam
};
const getToolResultNavigation = (result: unknown) => {
if (typeof result !== "object" || result === null || Array.isArray(result)) return [];
if (!isRecord(result)) return [];
const record = result as Record<string, unknown>;
const record = result;
const candidates = [
{ href: record.previewPath, translationKey: "toolNavigation.openPreview" as const },
{ href: record.managementPath, translationKey: "toolNavigation.openWidget" as const },
@@ -1,3 +1,5 @@
import { isRecord } from "@homarr/common";
const collectionKeys = [
"items",
"results",
@@ -86,8 +88,7 @@ interface ToolResultPresentationOptions {
toolName?: string;
}
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
const asRecord = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);
const toDisplayValue = (value: unknown): ToolResultPrimitive | undefined => {
if (typeof value === "string") {
@@ -1,8 +1,10 @@
import { isRecord } from "@homarr/common";
const targetKeys = ["name", "title", "query", "searchText", "appName", "widgetName"] as const;
export const getAssistantToolTraceTarget = (args: unknown): string | null => {
if (!args || typeof args !== "object" || Array.isArray(args)) return null;
const record = args as Record<string, unknown>;
if (!isRecord(args)) return null;
const record = args;
for (const key of targetKeys) {
const value = record[key];
@@ -8,6 +8,7 @@ import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } fro
import "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { RestoreStep } from "./types";
@@ -26,6 +27,7 @@ const DEFAULT_RESTART_DELAY_MS = 500;
const RESTART_READINESS_TIMEOUT_MS = 45_000;
const RESTART_POLL_INTERVAL_MS = 750;
const RESTART_REQUEST_TIMEOUT_MS = 5_000;
const MAX_BACKUP_FILE_BYTES = 256 * 1024 * 1024;
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
@@ -105,6 +107,7 @@ export const waitForServerReadinessAsync = async ({
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useI18n("management.page.tool.backup.restore");
const tCommon = useI18n("common");
const { formatBytes } = useByteFormatter();
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
@@ -221,7 +224,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
multiple={false}
maxSize={1024 * 1024 * 256}
maxSize={MAX_BACKUP_FILE_BYTES}
radius="md"
>
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
@@ -245,7 +248,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
{t("dropzone.title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{t("dropzone.description")}
{t("dropzone.description", { maxSize: formatBytes(MAX_BACKUP_FILE_BYTES) })}
</Text>
</div>
</Group>
@@ -264,7 +267,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
{file?.name}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
{file ? formatBytes(file.size) : ""}
</Text>
</Group>
</Group>
@@ -1,5 +1,6 @@
import type { QueryKey } from "@tanstack/react-query";
import { isRecord } from "@homarr/common";
import type { WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets/definition";
import { normalizeWidgetQuery, widgetQueryValueEquals } from "@homarr/widgets/definition";
@@ -78,6 +79,3 @@ const containsValue = (container: unknown, expected: unknown, seen = new WeakSet
return Object.values(container).some((value) => containsValue(value, expected, seen));
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
@@ -0,0 +1,58 @@
interface UnsavedChangesGuardOptions {
isDirty(): boolean;
confirmNavigation(href: string): void;
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const eventTarget = event.target instanceof Element ? event.target : null;
const anchor = eventTarget?.closest<HTMLAnchorElement>("a[href]");
if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.origin !== current.origin) return;
if (destination.pathname === current.pathname && destination.search === current.search) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`);
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty()) return;
event.preventDefault();
event.returnValue = true;
};
const handlePopState = () => {
const destination = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (!isDirty()) {
currentHref = destination;
return;
}
const destinationUrl = new URL(destination, window.location.origin);
const currentUrl = new URL(currentHref, window.location.origin);
if (destinationUrl.pathname === currentUrl.pathname && destinationUrl.search === currentUrl.search) {
currentHref = destination;
return;
}
window.history.pushState(window.history.state, "", currentHref);
confirmNavigation(destination);
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const dirtyRef = useRef(isDirty);
const confirmationOpenRef = useRef(false);
const confirmNavigationRef = useRef<(href: string) => void>(() => undefined);
dirtyRef.current = isDirty;
confirmNavigationRef.current = (href) => {
if (confirmationOpenRef.current) return;
confirmationOpenRef.current = true;
openConfirmModal(
{
title: tConfirmLeave("title"),
children: tConfirmLeave("message"),
confirmProps: { children: tCommon("action.discard") },
onConfirm: () => router.push(href),
},
{
onClose: () => {
confirmationOpenRef.current = false;
},
},
);
};
useEffect(
() =>
registerUnsavedChangesGuard({
isDirty: () => dirtyRef.current,
confirmNavigation: (href) => confirmNavigationRef.current(href),
}),
[],
);
}