fix(releases-widget): error display, decouple database repository object from responses and batch widget queries (#2891)

Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
Co-authored-by: Andre Silva <asilva01@acuitysso.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Andre Silva
2025-05-09 12:24:37 +01:00
committed by GitHub
parent d6180ebc73
commit 347c6d1519
14 changed files with 414 additions and 191 deletions

View File

@@ -63,7 +63,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const item = {
providerKey: "DockerHub",
identifier: "",
} as ReleasesRepository;
};
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
@@ -98,7 +98,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
};
});
};
return (
<Fieldset legend={t("label")}>
<Stack gap="5">

View File

@@ -10,6 +10,7 @@ import {
IconGitFork,
IconProgressCheck,
IconStar,
IconTriangleFilled,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import { useFormatter, useNow } from "next-intl";
@@ -17,118 +18,116 @@ import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { isDateWithin, splitToChunksWithNItems } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
import { Providers } from "./releases-providers";
import type { ReleasesRepository } from "./releases-repository";
import type { ReleasesRepositoryResponse } from "./releases-repository";
function isDateWithin(date: Date, relativeDate: string): boolean {
const amount = parseInt(relativeDate.slice(0, -1), 10);
const unit = relativeDate.slice(-1);
const startTime = new Date().getTime();
const endTime = new Date(date).getTime();
const diffTime = Math.abs(endTime - startTime);
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
switch (unit) {
case "h":
return diffHours < amount;
case "d":
return diffHours / 24 < amount;
case "w":
return diffHours / (24 * 7) < amount;
case "m":
return diffHours / (24 * 30) < amount;
case "y":
return diffHours / (24 * 365) < amount;
default:
throw new Error("Invalid unit");
}
}
const formatRelativeDate = (value: string): string => {
const isMonths = /\d+m/g.test(value);
const isOtherUnits = /\d+[HDWY]/g.test(value);
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
const board = useRequiredBoard();
const [expandedRepository, setExpandedRepository] = useState("");
const [expandedRepository, setExpandedRepository] = useState({ providerKey: "", identifier: "" });
const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]);
const relativeDateOptions = useMemo(
() => ({
newReleaseWithin: formatRelativeDate(options.newReleaseWithin),
staleReleaseWithin: formatRelativeDate(options.staleReleaseWithin),
}),
[options.newReleaseWithin, options.staleReleaseWithin],
);
const [results] = clientApi.widget.releases.getLatest.useSuspenseQuery(
{
repositories: options.repositories.map((repository) => ({
providerKey: repository.providerKey,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
})),
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
const batchedRepositories = useMemo(() => splitToChunksWithNItems(options.repositories, 5), [options.repositories]);
const [results] = clientApi.useSuspenseQueries((t) =>
batchedRepositories.flatMap((chunk) =>
t.widget.releases.getLatest({
repositories: chunk.map((repository) => ({
providerKey: repository.providerKey,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
})),
}),
),
);
const repositories = useMemo(() => {
return results
.flat()
.map(({ data }) => {
if (data === undefined) return undefined;
const repository = options.repositories.find(
(repository: ReleasesRepository) =>
repository.providerKey === data.providerKey && repository.identifier === data.identifier,
(repository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier,
);
if (repository === undefined) return undefined;
return {
...repository,
...data,
iconUrl: repository.iconUrl,
isNewRelease:
options.newReleaseWithin !== "" ? isDateWithin(data.latestReleaseAt, options.newReleaseWithin) : false,
relativeDateOptions.newReleaseWithin !== "" && data.latestReleaseAt
? isDateWithin(data.latestReleaseAt, relativeDateOptions.newReleaseWithin)
: false,
isStaleRelease:
options.staleReleaseWithin !== "" ? !isDateWithin(data.latestReleaseAt, options.staleReleaseWithin) : false,
relativeDateOptions.staleReleaseWithin !== "" && data.latestReleaseAt
? !isDateWithin(data.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
: false,
};
})
.filter(
(repository) =>
repository !== undefined &&
(!options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease),
(repository.error !== undefined ||
!options.showOnlyHighlighted ||
repository.isNewRelease ||
repository.isStaleRelease),
)
.sort((repoA, repoB) => {
if (repoA?.latestReleaseAt === undefined) return 1;
if (repoB?.latestReleaseAt === undefined) return -1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepository[];
}) as ReleasesRepositoryResponse[];
}, [
results,
options.repositories,
options.showOnlyHighlighted,
options.newReleaseWithin,
options.staleReleaseWithin,
relativeDateOptions.newReleaseWithin,
relativeDateOptions.staleReleaseWithin,
]);
const toggleExpandedRepository = useCallback(
(identifier: string) => {
setExpandedRepository(expandedRepository === identifier ? "" : identifier);
(repository: ReleasesRepositoryResponse) => {
if (
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier
) {
setExpandedRepository({ providerKey: "", identifier: "" });
} else {
setExpandedRepository({ providerKey: repository.providerKey, identifier: repository.identifier });
}
},
[expandedRepository],
);
return (
<Stack gap={0}>
{repositories.map((repository: ReleasesRepository) => {
const isActive = expandedRepository === repository.identifier;
{repositories.map((repository: ReleasesRepositoryResponse) => {
const isActive =
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier;
const hasError = repository.error !== undefined;
return (
<Stack
@@ -141,7 +140,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[classes.active ?? ""]: isActive,
})}
p="xs"
onClick={() => toggleExpandedRepository(repository.identifier)}
onClick={() => toggleExpandedRepository(repository)}
>
<MaskedOrNormalImage
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
@@ -155,9 +154,14 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
<Group gap={5} justify="space-between" style={{ flex: 1 }}>
<Text size="xs">{repository.identifier}</Text>
<Tooltip label={repository.latestRelease ?? t("not-found")}>
<Text size="xs" fw={700} truncate="end" style={{ flexShrink: 1 }}>
{repository.latestRelease ?? t("not-found")}
<Tooltip
withArrow
arrowSize={5}
label={repository.latestRelease}
events={{ hover: repository.latestRelease !== undefined, focus: false, touch: false }}
>
<Text size="xs" fw={700} truncate="end" c={hasError ? "red" : "text"} style={{ flexShrink: 1 }}>
{hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
</Text>
</Tooltip>
</Group>
@@ -168,20 +172,25 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
c={repository.isNewRelease ? "primaryColor" : repository.isStaleRelease ? "secondaryColor" : "dimmed"}
>
{repository.latestReleaseAt &&
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
})}
</Text>
{(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
/>
{!hasError ? (
(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
/>
)
) : (
<IconTriangleFilled size={10} color={"var(--mantine-color-red-filled)"} />
)}
</Group>
</Group>
@@ -198,8 +207,8 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
}
interface DetailsDisplayProps {
repository: ReleasesRepository;
toggleExpandedRepository: (identifier: string) => void;
repository: ReleasesRepositoryResponse;
toggleExpandedRepository: (repository: ReleasesRepositoryResponse) => void;
}
const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplayProps) => {
@@ -208,15 +217,15 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
return (
<>
<Divider onClick={() => toggleExpandedRepository(repository.identifier)} />
<Divider onClick={() => toggleExpandedRepository(repository)} />
<Group
className={classes.releasesRepositoryDetails}
justify="space-between"
p={5}
onClick={() => toggleExpandedRepository(repository.identifier)}
onClick={() => toggleExpandedRepository(repository)}
>
<Group>
<Tooltip label={t("pre-release")}>
<Tooltip label={t("pre-release")} withArrow arrowSize={5}>
<IconProgressCheck
size={13}
color={
@@ -225,14 +234,14 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
/>
</Tooltip>
<Tooltip label={t("archived")}>
<Tooltip label={t("archived")} withArrow arrowSize={5}>
<IconArchive
size={13}
color={repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
</Tooltip>
<Tooltip label={t("forked")}>
<Tooltip label={t("forked")} withArrow arrowSize={5}>
<IconGitFork
size={13}
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
@@ -240,7 +249,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
</Tooltip>
</Group>
<Group>
<Tooltip label={t("starsCount")}>
<Tooltip label={t("starsCount")} withArrow arrowSize={5}>
<Group gap={5}>
<IconStar
size={12}
@@ -257,7 +266,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
</Group>
</Tooltip>
<Tooltip label={t("forksCount")}>
<Tooltip label={t("forksCount")} withArrow arrowSize={5}>
<Group gap={5}>
<IconGitFork
size={12}
@@ -274,7 +283,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
</Group>
</Tooltip>
<Tooltip label={t("issuesCount")}>
<Tooltip label={t("issuesCount")} withArrow arrowSize={5}>
<Group gap={5}>
<IconCircleDot
size={12}
@@ -297,7 +306,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
};
interface ExtendedDisplayProps {
repository: ReleasesRepository;
repository: ReleasesRepositoryResponse;
hasIconColor: boolean;
}
@@ -337,17 +346,32 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
</Text>
)}
</Group>
<Divider my={10} mx="30%" />
<Button
variant="light"
component="a"
href={repository.releaseUrl ?? repository.projectUrl}
target="_blank"
rel="noreferrer"
>
<IconExternalLink />
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
</Button>
{(repository.releaseUrl ?? repository.projectUrl) && (
<>
<Divider my={10} mx="30%" />
<Button
variant="light"
component="a"
href={repository.releaseUrl ?? repository.projectUrl}
target="_blank"
rel="noreferrer"
>
<IconExternalLink />
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
</Button>
</>
)}
{repository.error && (
<>
<Divider my={10} mx="30%" />
<Title order={4} ta="center">
{t("error.label")}
</Title>
<Text size="xs" ff="monospace" c="red" style={{ whiteSpace: "pre-wrap" }}>
{repository.error.code ? t(`error.options.${repository.error.code}` as never) : repository.error.message}
</Text>
</>
)}
{repository.releaseDescription && (
<>
<Divider my={10} mx="30%" />

View File

@@ -4,6 +4,11 @@ import { z } from "zod";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const relativeDateSchema = z
.string()
.regex(/^\d+[hdwmyHDWMY]$/)
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
createOptions() {
@@ -11,18 +16,12 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
newReleaseWithin: factory.text({
defaultValue: "1w",
withDescription: true,
validate: z
.string()
.regex(/^\d+[hdwmy]$/)
.or(z.literal("")),
validate: relativeDateSchema,
}),
staleReleaseWithin: factory.text({
defaultValue: "6m",
defaultValue: "6M",
withDescription: true,
validate: z
.string()
.regex(/^\d+[hdwmy]$/)
.or(z.literal("")),
validate: relativeDateSchema,
}),
showOnlyHighlighted: factory.switch({
withDescription: true,

View File

@@ -9,7 +9,9 @@ export interface ReleasesRepository {
identifier: string;
versionFilter?: ReleasesVersionFilter;
iconUrl?: string;
}
export interface ReleasesRepositoryResponse extends ReleasesRepository {
latestRelease?: string;
latestReleaseAt?: Date;
isNewRelease: boolean;
@@ -27,4 +29,6 @@ export interface ReleasesRepository {
starsCount?: number;
forksCount?: number;
openIssues?: number;
error?: { code?: string; message?: string };
}