feat: indexer manager widget (#1057)
* fix(deps): update tanstack-query monorepo to ^5.53.2 (#1055) Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com> <br/> <div align="center"> <img src="https://homarr.dev/img/logo.png" height="80" alt="" /> <h3>Homarr</h3> </div> **Thank you for your contribution. Please ensure that your pull request meets the following pull request:** - [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``) - [ ] Pull request targets ``dev`` branch - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation) * fix: requested changes * fix: requested changes * feat: add cron job * fix: review changes * fix: add missing oldmarr import mappings --------- Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
|
|||||||
import { appRouter } from "./app";
|
import { appRouter } from "./app";
|
||||||
import { calendarRouter } from "./calendar";
|
import { calendarRouter } from "./calendar";
|
||||||
import { dnsHoleRouter } from "./dns-hole";
|
import { dnsHoleRouter } from "./dns-hole";
|
||||||
|
import { indexerManagerRouter } from "./indexer-manager";
|
||||||
import { mediaRequestsRouter } from "./media-requests";
|
import { mediaRequestsRouter } from "./media-requests";
|
||||||
import { mediaServerRouter } from "./media-server";
|
import { mediaServerRouter } from "./media-server";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
@@ -19,4 +20,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
calendar: calendarRouter,
|
calendar: calendarRouter,
|
||||||
mediaRequests: mediaRequestsRouter,
|
mediaRequests: mediaRequestsRouter,
|
||||||
rssFeed: rssFeedRouter,
|
rssFeed: rssFeedRouter,
|
||||||
|
indexerManager: indexerManagerRouter,
|
||||||
});
|
});
|
||||||
|
|||||||
75
packages/api/src/router/widgets/indexer-manager.ts
Normal file
75
packages/api/src/router/widgets/indexer-manager.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
|
import { integrationCreatorByKind } from "@homarr/integrations";
|
||||||
|
import type { Indexer } from "@homarr/integrations/types";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||||
|
|
||||||
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
export const indexerManagerRouter = createTRPCRouter({
|
||||||
|
getIndexersStatus: publicProcedure
|
||||||
|
.unstable_concat(createManyIntegrationMiddleware("query", "prowlarr"))
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
ctx.integrations.map(async (integration) => {
|
||||||
|
const client = integrationCreatorByKind(integration.kind, integration);
|
||||||
|
const indexers = await client.getIndexersAsync().catch((err) => {
|
||||||
|
logger.error("indexer-manager router - ", err);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Failed to fetch indexers for ${integration.name} (${integration.id})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
integrationId: integration.id,
|
||||||
|
indexers,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}),
|
||||||
|
|
||||||
|
subscribeIndexersStatus: publicProcedure
|
||||||
|
.unstable_concat(createManyIntegrationMiddleware("query", "prowlarr"))
|
||||||
|
.subscription(({ ctx }) => {
|
||||||
|
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
|
||||||
|
const unsubscribes: (() => void)[] = [];
|
||||||
|
for (const integration of ctx.integrations) {
|
||||||
|
const channel = createItemAndIntegrationChannel<Indexer[]>("indexerManager", integration.id);
|
||||||
|
const unsubscribe = channel.subscribe((indexers) => {
|
||||||
|
emit.next({
|
||||||
|
integrationId: integration.id,
|
||||||
|
indexers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
unsubscribes.push(unsubscribe);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
unsubscribes.forEach((unsubscribe) => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
testAllIndexers: publicProcedure
|
||||||
|
.unstable_concat(createManyIntegrationMiddleware("interact", "prowlarr"))
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
await Promise.all(
|
||||||
|
ctx.integrations.map(async (integration) => {
|
||||||
|
const client = integrationCreatorByKind(integration.kind, integration);
|
||||||
|
await client.testAllAsync().catch((err) => {
|
||||||
|
logger.error("indexer-manager router - ", err);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Failed to test all indexers for ${integration.name} (${integration.id})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { analyticsJob } from "./jobs/analytics";
|
import { analyticsJob } from "./jobs/analytics";
|
||||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||||
|
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||||
import { mediaRequestsJob } from "./jobs/integrations/media-requests";
|
import { mediaRequestsJob } from "./jobs/integrations/media-requests";
|
||||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||||
@@ -18,6 +19,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
mediaOrganizer: mediaOrganizerJob,
|
mediaOrganizer: mediaOrganizerJob,
|
||||||
mediaRequests: mediaRequestsJob,
|
mediaRequests: mediaRequestsJob,
|
||||||
rssFeeds: rssFeedsJob,
|
rssFeeds: rssFeedsJob,
|
||||||
|
indexerManager: indexerManagerJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||||
|
|||||||
42
packages/cron-jobs/src/jobs/integrations/indexer-manager.ts
Normal file
42
packages/cron-jobs/src/jobs/integrations/indexer-manager.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { decryptSecret } from "@homarr/common";
|
||||||
|
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||||
|
import { db, eq } from "@homarr/db";
|
||||||
|
import { items } from "@homarr/db/schema/sqlite";
|
||||||
|
import { ProwlarrIntegration } from "@homarr/integrations";
|
||||||
|
|
||||||
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
|
export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => {
|
||||||
|
const itemsForIntegration = await db.query.items.findMany({
|
||||||
|
where: eq(items.kind, "indexerManager"),
|
||||||
|
with: {
|
||||||
|
integrations: {
|
||||||
|
with: {
|
||||||
|
integration: {
|
||||||
|
with: {
|
||||||
|
secrets: {
|
||||||
|
columns: {
|
||||||
|
kind: true,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const itemForIntegration of itemsForIntegration) {
|
||||||
|
for (const integration of itemForIntegration.integrations) {
|
||||||
|
const prowlarr = new ProwlarrIntegration({
|
||||||
|
...integration.integration,
|
||||||
|
decryptedSecrets: integration.integration.secrets.map((secret) => ({
|
||||||
|
...secret,
|
||||||
|
value: decryptSecret(secret.value),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await prowlarr.getIndexersAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -14,5 +14,6 @@ export const widgetKinds = [
|
|||||||
"mediaRequests-requestList",
|
"mediaRequests-requestList",
|
||||||
"mediaRequests-requestStats",
|
"mediaRequests-requestStats",
|
||||||
"rssFeed",
|
"rssFeed",
|
||||||
|
"indexerManager",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
|||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
|
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class ProwlarrIntegration extends Integration {
|
|||||||
name: indexer.name,
|
name: indexer.name,
|
||||||
url: indexer.indexerUrls[0] ?? "",
|
url: indexer.indexerUrls[0] ?? "",
|
||||||
enabled: indexer.enable,
|
enabled: indexer.enable,
|
||||||
status: inactiveIndexerIds.has(indexer.id),
|
status: !inactiveIndexerIds.has(indexer.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return indexers;
|
return indexers;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
|
||||||
export * from "./calendar-types";
|
export * from "./calendar-types";
|
||||||
|
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||||
|
export * from "./interfaces/indexer-manager/indexer";
|
||||||
export * from "./interfaces/media-requests/media-request";
|
export * from "./interfaces/media-requests/media-request";
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const widgetKindMapping = {
|
|||||||
"smartHome-executeAutomation": "smart-home/trigger-automation",
|
"smartHome-executeAutomation": "smart-home/trigger-automation",
|
||||||
"mediaRequests-requestList": "media-requests-list",
|
"mediaRequests-requestList": "media-requests-list",
|
||||||
"mediaRequests-requestStats": "media-requests-stats",
|
"mediaRequests-requestStats": "media-requests-stats",
|
||||||
|
indexerManager: "indexer-manager",
|
||||||
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
||||||
// Use null for widgets that did not exist in oldmarr
|
// Use null for widgets that did not exist in oldmarr
|
||||||
// TODO: revert assignment so that only old widgets are needed in the object,
|
// TODO: revert assignment so that only old widgets are needed in the object,
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ const optionMapping: OptionMapping = {
|
|||||||
displayName: (oldOptions) => oldOptions.displayName,
|
displayName: (oldOptions) => oldOptions.displayName,
|
||||||
},
|
},
|
||||||
mediaServer: {},
|
mediaServer: {},
|
||||||
|
indexerManager: {
|
||||||
|
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
||||||
|
},
|
||||||
app: null,
|
app: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1065,6 +1065,20 @@ export default {
|
|||||||
unknown: "Unknown",
|
unknown: "Unknown",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
indexerManager: {
|
||||||
|
name: "Indexer manager status",
|
||||||
|
description: "Status of your indexers",
|
||||||
|
option: {
|
||||||
|
openIndexerSiteInNewTab: {
|
||||||
|
label: "Open indexer site in new tab",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: "Indexer manager",
|
||||||
|
testAll: "Test all",
|
||||||
|
error: {
|
||||||
|
internalServerError: "Failed to fetch indexers status",
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
location: {
|
location: {
|
||||||
query: "City / Postal code",
|
query: "City / Postal code",
|
||||||
@@ -1738,6 +1752,9 @@ export default {
|
|||||||
rssFeeds: {
|
rssFeeds: {
|
||||||
label: "RSS feeds",
|
label: "RSS feeds",
|
||||||
},
|
},
|
||||||
|
indexerManager: {
|
||||||
|
label: "Indexer Manager",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as dnsHoleControls from "./dns-hole/controls";
|
|||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
|
import * as indexerManager from "./indexer-manager";
|
||||||
import * as mediaRequestsList from "./media-requests/list";
|
import * as mediaRequestsList from "./media-requests/list";
|
||||||
import * as mediaRequestsStats from "./media-requests/stats";
|
import * as mediaRequestsStats from "./media-requests/stats";
|
||||||
import * as mediaServer from "./media-server";
|
import * as mediaServer from "./media-server";
|
||||||
@@ -47,6 +48,7 @@ export const widgetImports = {
|
|||||||
"mediaRequests-requestList": mediaRequestsList,
|
"mediaRequests-requestList": mediaRequestsList,
|
||||||
"mediaRequests-requestStats": mediaRequestsStats,
|
"mediaRequests-requestStats": mediaRequestsStats,
|
||||||
rssFeed,
|
rssFeed,
|
||||||
|
indexerManager,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
85
packages/widgets/src/indexer-manager/component.tsx
Normal file
85
packages/widgets/src/indexer-manager/component.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core";
|
||||||
|
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { Indexer } from "@homarr/integrations/types";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { NoIntegrationSelectedError } from "../errors";
|
||||||
|
|
||||||
|
export default function IndexerManagerWidget({
|
||||||
|
options,
|
||||||
|
integrationIds,
|
||||||
|
serverData,
|
||||||
|
}: WidgetComponentProps<"indexerManager">) {
|
||||||
|
if (integrationIds.length === 0) {
|
||||||
|
throw new NoIntegrationSelectedError();
|
||||||
|
}
|
||||||
|
const t = useI18n();
|
||||||
|
const [indexersData, setIndexersData] = useState<{ integrationId: string; indexers: Indexer[] }[]>(
|
||||||
|
serverData?.initialData ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
|
||||||
|
|
||||||
|
clientApi.widget.indexerManager.subscribeIndexersStatus.useSubscription(
|
||||||
|
{ integrationIds },
|
||||||
|
{
|
||||||
|
onData(newData) {
|
||||||
|
setIndexersData((prevData) => {
|
||||||
|
return prevData.map((item) =>
|
||||||
|
item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex h="100%" direction="column">
|
||||||
|
<Text size="6.5cqmin" mt="1.5cqmin" pl="20cqmin">
|
||||||
|
<IconReportSearch size="7cqmin" /> {t("widget.indexerManager.title")}
|
||||||
|
</Text>
|
||||||
|
<Card m="2.5cqmin" p="2.5cqmin" radius="md" withBorder>
|
||||||
|
<ScrollArea h="100%">
|
||||||
|
{indexersData.map(({ integrationId, indexers }) => (
|
||||||
|
<Container key={integrationId}>
|
||||||
|
{indexers.map((indexer) => (
|
||||||
|
<Group key={indexer.id} justify="space-between">
|
||||||
|
<Anchor href={indexer.url} target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{indexer.name}
|
||||||
|
</Text>
|
||||||
|
</Anchor>
|
||||||
|
{indexer.status === false || indexer.enabled === false ? (
|
||||||
|
<IconCircleX color="#d9534f" />
|
||||||
|
) : (
|
||||||
|
<IconCircleCheck color="#2ecc71" />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</Card>
|
||||||
|
<Button
|
||||||
|
m="2.5cqmin"
|
||||||
|
p="2.5cqmin"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconTestPipe size={20} />}
|
||||||
|
loading={isPending}
|
||||||
|
loaderProps={{ type: "dots" }}
|
||||||
|
onClick={() => {
|
||||||
|
testAll({ integrationIds });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("widget.indexerManager.testAll")}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
packages/widgets/src/indexer-manager/index.ts
Normal file
22
packages/widgets/src/indexer-manager/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { IconReportSearch, IconServerOff } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("indexerManager", {
|
||||||
|
icon: IconReportSearch,
|
||||||
|
options: optionsBuilder.from((factory) => ({
|
||||||
|
openIndexerSiteInNewTab: factory.switch({
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
supportedIntegrations: ["prowlarr"],
|
||||||
|
errors: {
|
||||||
|
INTERNAL_SERVER_ERROR: {
|
||||||
|
icon: IconServerOff,
|
||||||
|
message: (t) => t("widget.indexerManager.error.internalServerError"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.withServerData(() => import("./serverData"))
|
||||||
|
.withDynamicImport(() => import("./component"));
|
||||||
27
packages/widgets/src/indexer-manager/serverData.ts
Normal file
27
packages/widgets/src/indexer-manager/serverData.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
|
import type { WidgetProps } from "../definition";
|
||||||
|
|
||||||
|
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"indexerManager">) {
|
||||||
|
if (integrationIds.length === 0) {
|
||||||
|
return {
|
||||||
|
initialData: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentIndexers = await api.widget.indexerManager.getIndexersStatus({
|
||||||
|
integrationIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialData: currentIndexers,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
initialData: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user