diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index c2a8efc60..1f933e9ca 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -11,6 +11,7 @@ import { mediaTranscodingRouter } from "./media-transcoding"; import { minecraftRouter } from "./minecraft"; import { notebookRouter } from "./notebook"; import { optionsRouter } from "./options"; +import { releasesRouter } from "./releases"; import { rssFeedRouter } from "./rssFeed"; import { smartHomeRouter } from "./smart-home"; import { weatherRouter } from "./weather"; @@ -31,4 +32,5 @@ export const widgetRouter = createTRPCRouter({ mediaTranscoding: mediaTranscodingRouter, minecraft: minecraftRouter, options: optionsRouter, + releases: releasesRouter, }); diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts new file mode 100644 index 000000000..93c4e9f40 --- /dev/null +++ b/packages/api/src/router/widgets/releases.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +import { releasesRequestHandler } from "@homarr/request-handler/releases"; + +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const releasesRouter = createTRPCRouter({ + getLatest: publicProcedure + .input( + z.object({ + releases: z.array( + z.object({ + providerName: z.string(), + identifier: z.string(), + versionRegex: z.string().optional(), + }), + ), + }), + ) + .query(async ({ input }) => { + const result = await Promise.all( + input.releases.map(async (release) => { + const innerHandler = releasesRequestHandler.handler({ + providerName: release.providerName, + identifier: release.identifier, + versionRegex: release.versionRegex, + }); + return await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); + }), + ); + + return result; + }), +}); diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 415319318..0cdf7d872 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -4,7 +4,7 @@ import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -export const rssFeedRouter = createTRPCRouter({ +export const rssFeedRouter = createTRPCRouter({ getFeeds: publicProcedure .input( z.object({ diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index a829b9ae8..705bf84f7 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -20,5 +20,6 @@ export const widgetKinds = [ "bookmarks", "indexerManager", "healthMonitoring", + "releases", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index d3abda661..52879e6ac 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -33,9 +33,10 @@ interface IconPickerProps { error?: string | null; onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; + withAsterisk?: boolean; } -export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur }: IconPickerProps) => { +export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur, withAsterisk = true }: IconPickerProps) => { const [value, setValue] = useUncontrolled({ value: propsValue, onChange, @@ -145,7 +146,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur setSearch(value || ""); }} rightSectionPointerEvents="none" - withAsterisk + withAsterisk={withAsterisk} error={error} label={tCommon("iconPicker.label")} placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })} diff --git a/packages/request-handler/src/releases.ts b/packages/request-handler/src/releases.ts new file mode 100644 index 000000000..c3db9501f --- /dev/null +++ b/packages/request-handler/src/releases.ts @@ -0,0 +1,146 @@ +import dayjs from "dayjs"; +import { z } from "zod"; + +import { fetchWithTimeout } from "@homarr/common"; + +import { Providers } from "../../widgets/src/releases/release-providers"; +import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; + +const dockerResponseSchema = z + .object({ + results: z.array( + z + .object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }) + .transform((tag) => ({ + identifier: "", + tag: tag.name, + lastUpdated: tag.last_updated, + })), + ), + }) + .transform((resp) => resp.results); + +const githubResponseSchema = z.array( + z + .object({ tag_name: z.string(), published_at: z.string().transform((value) => new Date(value)) }) + .transform((tag) => ({ + identifier: "", + tag: tag.tag_name, + lastUpdated: tag.published_at, + })), +); + +const gitlabResponseSchema = z.array( + z.object({ name: z.string(), released_at: z.string().transform((value) => new Date(value)) }).transform((tag) => ({ + identifier: "", + tag: tag.name, + lastUpdated: tag.released_at, + })), +); + +const npmResponseSchema = z + .object({ + time: z.record(z.string().transform((value) => new Date(value))).transform((version) => + Object.entries(version).map(([key, value]) => ({ + identifier: "", + tag: key, + lastUpdated: value, + })), + ), + }) + .transform((resp) => resp.time); + +const codebergResponseSchema = z.array( + z + .object({ tag_name: z.string(), published_at: z.string().transform((value) => new Date(value)) }) + .transform((tag) => ({ + identifier: "", + tag: tag.tag_name, + lastUpdated: tag.published_at, + })), +); + +const _responseSchema = z.object({ identifier: z.string(), tag: z.string(), lastUpdated: z.date() }); + +function getDockerUrl(identifier: string): string { + if (identifier.indexOf("/") > 0) { + const [owner, name] = identifier.split("/"); + if (!owner || !name) { + return ""; + } + return `https://hub.docker.com/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}/tags?page_size=50`; + } else { + return `https://hub.docker.com/v2/repositories/library/${encodeURIComponent(identifier)}/tags?page_size=50`; + } +} + +function getGithubUrl(identifier: string): string { + return `https://api.github.com/repos/${encodeURIComponent(identifier)}/releases`; +} + +function getGitlabUrl(identifier: string): string { + return `https://gitlab.com/api/v4/projects/${encodeURIComponent(identifier)}/releases`; +} + +function getNpmUrl(identifier: string): string { + return `https://registry.npmjs.org/${encodeURIComponent(identifier)}`; +} + +function getCodebergUrl(identifier: string): string { + const [owner, name] = identifier.split("/"); + if (!owner || !name) { + return ""; + } + return `https://codeberg.org/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`; +} + +export const releasesRequestHandler = createCachedWidgetRequestHandler({ + queryKey: "releasesApiResult", + widgetKind: "releases", + async requestAsync(input: { providerName: string; identifier: string; versionRegex: string | undefined }) { + let url = ""; + let responseSchema; + switch (input.providerName) { + case Providers.Docker.name: + url = getDockerUrl(input.identifier); + responseSchema = dockerResponseSchema; + break; + case Providers.Github.name: + url = getGithubUrl(input.identifier); + responseSchema = githubResponseSchema; + break; + case Providers.Gitlab.name: + url = getGitlabUrl(input.identifier); + responseSchema = gitlabResponseSchema; + break; + case Providers.Npm.name: + url = getNpmUrl(input.identifier); + responseSchema = npmResponseSchema; + break; + case Providers.Codeberg.name: + url = getCodebergUrl(input.identifier); + responseSchema = codebergResponseSchema; + break; + } + + if (url === "" || responseSchema === undefined) return undefined; + + const response = await fetchWithTimeout(url); + const result = responseSchema.safeParse(await response.json()); + + if (!result.success) return undefined; + + const latest: ReleaseResponse = result.data + .filter((result) => (input.versionRegex ? new RegExp(input.versionRegex).test(result.tag) : false)) + .reduce( + (latest, result) => { + return result.lastUpdated > latest.lastUpdated ? { ...result, identifier: input.identifier } : latest; + }, + { identifier: "", tag: "", lastUpdated: new Date(0) }, + ); + return latest; + }, + cacheDuration: dayjs.duration(5, "minutes"), +}); + +export type ReleaseResponse = z.infer; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 72ed28ddc..8b3a5feb8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1927,6 +1927,47 @@ "label": "Amount posts limit" } } + }, + "releases": { + "name": "Releases", + "description": "Displays a list of the current version of the given repositories with the given version regex.", + "option": { + "releases": { + "label": "Releases" + }, + "addRelease": { + "label": "Add release" + }, + "provider": { + "label": "Provider" + }, + "identifier": { + "label": "Identifier", + "placeholder": "Name or Owner/Name" + }, + "versionRegex": { + "label": "Version Regex" + }, + "sortBy": { + "label": "Sort By" + }, + "showOnlyNewReleases": { + "label": "Show Only New Releases" + }, + "edit": { + "label": "Edit" + } + }, + "editForm": { + "title": "Edit Release", + "cancel": { + "label": "Cancel" + }, + "confirm": { + "label": "Confirm" + } + }, + "not-found": "Not Found" } }, "widgetPreview": { diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index d675e5c1c..1d87df11f 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -9,6 +9,7 @@ import { WidgetSliderInput } from "./widget-slider-input"; import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input"; import { WidgetSwitchInput } from "./widget-switch-input"; import { WidgetTextInput } from "./widget-text-input"; +import { WidgetMultiReleasesInput } from "./widget-multiReleases-input"; const mapping = { text: WidgetTextInput, @@ -21,6 +22,7 @@ const mapping = { switch: WidgetSwitchInput, app: WidgetAppInput, sortableItemList: WidgetSortedItemListInput, + multiReleases: WidgetMultiReleasesInput } satisfies Record; export const getInputForType = (type: TType) => { diff --git a/packages/widgets/src/_inputs/widget-multiReleases-input.tsx b/packages/widgets/src/_inputs/widget-multiReleases-input.tsx new file mode 100644 index 000000000..bd62c4c5a --- /dev/null +++ b/packages/widgets/src/_inputs/widget-multiReleases-input.tsx @@ -0,0 +1,245 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; +import { ActionIcon, Button, Divider, Fieldset, Grid, Group, Select, Stack, Text, TextInput } from "@mantine/core"; +import type { FormErrors } from "@mantine/form"; +import { IconEdit, IconTrash } from "@tabler/icons-react"; + +import { createModal, useModalAction } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; +import { MaskedOrNormalImage } from "@homarr/ui"; + +import { IconPicker } from "../../../forms-collection/src"; +import { Release } from "../releases/release"; +import { Providers } from "../releases/release-providers"; +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +interface FormValidation { + hasErrors: boolean; + errors: FormErrors; +} + +export const WidgetMultiReleasesInput = ({ property, kind }: CommonWidgetInputProps<"multiReleases">) => { + const t = useWidgetInputTranslation(kind, property); + const tReleases = useScopedI18n("widget.releases"); + const form = useFormContext(); + const releases = form.values.options[property] as Release[]; + const { openModal } = useModalAction(releaseEditModal); + + const onReleaseSave = useCallback( + (release: Release, index: number): FormValidation => { + form.setFieldValue(`options.${property}.${index}.provider`, release.provider); + form.setFieldValue(`options.${property}.${index}.identifier`, release.identifier); + form.setFieldValue(`options.${property}.${index}.versionRegex`, release.versionRegex); + form.setFieldValue(`options.${property}.${index}.iconUrl`, release.iconUrl); + + return form.validate(); + }, + [form, property], + ); + + const providers = useMemo(() => { + return Object.values(Providers).map((provider) => provider.name); + }, []); + + const addNewItem = () => { + const item = new Release(Providers.Docker, ""); + + form.setValues((previous) => { + const previousValues = previous.options?.[property] as Release[]; + return { + ...previous, + options: { + ...previous.options, + [property]: [...previousValues, item], + }, + }; + }); + }; + + const onReleaseRemove = (index: number) => { + form.setValues((previous) => { + const previousValues = previous.options?.[property] as Release[]; + return { + ...previous, + options: { + ...previous.options, + [property]: previousValues.filter((_, i) => i !== index), + }, + }; + }); + }; + + return ( +
+ + + + + {releases.map((release, index) => { + return ( + + + + + + + + {release.provider.name} + + + + {release.identifier} + + + + {release.versionRegex} + + + + + + + onReleaseRemove(index)}> + + + + + + + ); + })} + +
+ ); +}; + +interface ReleaseEditProps { + fieldPath: string; + release: Release; + onReleaseSave: (release: Release) => FormValidation; + providers: string[]; +} + +const releaseEditModal = createModal(({ innerProps, actions }) => { + const tReleases = useScopedI18n("widget.releases"); + const [loading, setLoading] = useState(false); + const [tempRelease, setTempRelease] = useState(innerProps.release); + const [formErrors, setFormErrors] = useState({}); + + const handleConfirm = useCallback(() => { + setLoading(true); + + const validation = innerProps.onReleaseSave(tempRelease); + setFormErrors(validation.errors); + if (!validation.hasErrors) { + actions.closeModal(); + } + + setLoading(false); + }, [innerProps, tempRelease, actions]); + + const handleChange = useCallback( + (changedValue: Partial) => { + setTempRelease((prev) => ({ ...prev, ...changedValue })); + const validation = innerProps.onReleaseSave({ ...tempRelease, ...changedValue }); + setFormErrors(validation.errors); + }, + [innerProps, tempRelease], + ); + + return ( + + + +