feat: polish release v2 experience (#6800)
This commit is contained in:
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),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user