diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 000000000..492d56ecc --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,97 @@ +# Changes + +## Goal + +Remove widget-only services from Homarr's global Integration system. + +The affected services are release providers, Search.ch, and ArchiveTeam Warrior. They are only used by specific widgets, so keeping them as global integrations added unnecessary setup, UI, database rows, docs pages, and integration package code. + +## Why + +These services do not need shared integration state. + +- Release providers are selected per Releases widget repository. +- Search.ch is only used by the Timetable widget. +- ArchiveTeam Warrior is only used by the ArchiveTeam Warrior widget. + +Moving them into widget options makes configuration simpler and removes dead-weight integration code from `packages/integrations`. + +## What Changed + +### Release Providers + +- Added release provider definitions in `packages/definitions/src/release-provider.ts`. +- Removed release providers from `IntegrationKind`. +- Moved release-fetching code out of `packages/integrations` and into `packages/request-handler/src/release-providers.ts`. +- Updated the Releases widget to store provider configuration directly in widget options: + - `provider` + - `identifier` + - `versionFilter` + - `providerUrl` +- Updated the Releases widget editor to use a provider select instead of an integration select. +- Updated Docker import mapping so `ghcr.io` maps to `gitHubContainerRegistry`. +- GitHub Container Registry now uses the public OCI registry API and accepts full image references such as `ghcr.io/homarr-labs/homarr:latest`. + +### Timetable / Search.ch + +- Removed Search.ch as an integration. +- Added a Timetable widget `baseUrl` option, defaulting to `https://search.ch`. +- Moved Search.ch request/parsing logic into `packages/request-handler/src/timetable.ts`. +- Updated the Timetable API to receive `baseUrl` directly from widget options. + +### ArchiveTeam Warrior + +- Removed ArchiveTeam Warrior as an integration. +- Added an ArchiveTeam Warrior widget `url` option, defaulting to `http://localhost:8001`. +- Moved ArchiveTeam Warrior status/WebSocket handling into `packages/request-handler/src/archive-team-warrior.ts`. +- Updated the widget API and component to use the widget `url` option. + +### Integration Cleanup + +- Removed creators, exports, docs references, and implementation files for the deleted integrations. +- Removed unused dependencies from `packages/integrations/package.json`. +- Added required request-handler dependencies for the moved WebSocket logic. + +### Database Migration + +- Added `packages/db/migrations/custom/0003_remove_widget_only_integrations.ts`. +- The migration moves old integration-linked widget config into widget options, then deletes the old widget-only integration rows. +- Updated the earlier release widget migration to use the new `provider` field instead of `providerIntegrationId`. + +### Docs + +- Updated Releases widget docs to describe providers instead of integrations. +- Updated Timetable docs to remove Search.ch integration setup. +- Removed obsolete integration docs pages for the deleted integration kinds. +- Updated docs sitemap and integration docs mappings. + +### Translations + +- Added English labels for the new widget options: + - `widget.archiveTeamWarrior.option.url.label` + - `widget.timetable.option.baseUrl.label` + - `widget.releases.option.repositories.providerUrl.label` + +## How It Works Now + +Release provider configuration lives on each Releases widget repository entry. The request handler resolves provider metadata and fetches releases directly from the provider-specific endpoint. GHCR uses anonymous OCI registry access for public packages. + +Timetable and ArchiveTeam Warrior now pass their widget URL options directly to their API routers and request handlers. No global integration lookup is needed. + +## Deliberate Non-Goals + +- No provider secret/token support was added. +- No new secret storage model was introduced. +- No compatibility layer was kept in `packages/integrations`. + +Widget options are client-visible, so storing provider credentials there would be wrong. If authenticated provider requests are needed later, they need a separate secret-storage design. + +## Verification So Far + +- Focused typechecks passed for affected packages. +- Full `pnpm turbo typecheck` passed. +- Full `pnpm lint` passed with existing warnings. +- Focused release provider normalization tests passed. +- Widget translation test passed. +- Live GHCR smoke checks passed for `ghcr.io/muchobien/pocketbase:latest` and `ghcr.io/homarr-labs/homarr:latest`. +- `pnpm test packages/integrations/test/pi-hole.spec.ts` passed earlier, but failed on rebased reruns because Pi-hole reported `domainsBeingBlocked` as `-2`/`0` instead of `> 1`. diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 5f923cec8..e4fe8e725 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -28,6 +28,7 @@ Read our [glossary](/docs/getting-started/glossary) if you are new to Homarr. Congratulations on installing Homarr. Now it's time to get yourself familiar with the concepts of Homarr and how to use it. ## Onboarding + The onboard will show the first time you start Homarr. This process will help you with your first steps and adding some initial data, such as the administrator account. @@ -45,6 +46,7 @@ You can either import a previous dashboard using the ZIP archive or start from s You may refresh anytime and continue from a different device - the onboarding is saved immediately on the server for your convenience. ### Starting from scratch + Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form. If credentials provider was selected, you'll be prompted to create an admin user for Homarr. This user cannot be deleted and has full control over Homarr and all of it's boards. @@ -52,43 +54,60 @@ You must choose an username and password (both can later be changed). We enforce strict password requirements, when you enter your password, to protect your instance if you accidentially expose it to the Internet. -{"Screenshot +{"Screenshot If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group. -This group will be used to synchronize users from your provider to Homarr. +This group will be used to synchronize users from your provider to Homarr. When a user is logged in through the provider the groups of him that match groups with any in Homarr will be added to the user. The name of the group has to match exactly the name of the group in the provider. -{"Screenshot +{"Screenshot Next, you can configure some basic settings for Homarr. We recommend to leave all settings at default for most users. Here you can also disable analytics. Please note that our analytics are completly anonymous and is fully compliant with GDPR and PECR. -{"Screenshot +{"Screenshot ### Setting up integrations After configuring server settings, the onboarding guides you through setting up your integrations. #### Docker service discovery + If Docker is available (socket mounted), Homarr automatically detects running containers and matches them to known integration types. Matching integrations are pre-selected in the integration list, saving you from manually picking each one. #### URL templating + You can define a base URL template to auto-fill integration URLs in bulk. Two modes are supported: + - **Subdomain mode** - e.g. `{app}.example.com` where `{app}` is replaced with the integration name - **Port mode** - e.g. `192.168.1.1:{port}` where `{port}` is replaced with the integration's default port #### API key helper links + For integrations that require API keys (such as Sonarr, Radarr, SABnzbd, or Seerr), a helper link opens the relevant settings page in the target application so you can quickly copy your key. #### Creating apps from Docker containers + You can optionally auto-create bookmark apps for all discovered Docker containers, making them immediately available on your board. :::note -Some niche and registry integrations (GitHub, npm, Docker Hub, GitLab, Codeberg, LinuxServer.io, GHCR, Quay, Search.ch) are hidden from the onboarding selection grid but remain available on the manage integrations page. +Release providers and Search.ch are configured directly in their widgets instead of the integrations page. ::: @@ -97,30 +116,41 @@ The onboarding can no longer be started again. From here you can click on one of the provided links to continue your journey. We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board). -{"Screenshot +{"Screenshot ### Importing a ZIP from version before 1.0.0 + This is recommended when you used Homarr before and want to re-import your data. It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0). -Export from old Homarr +Export from old Homarr **How to export and import data** -1. Navigate to your old Homarr, open your management pages, open the tools submenu and open ``Migrate to 1.0``. + +1. Navigate to your old Homarr, open your management pages, open the tools submenu and open `Migrate to 1.0`. 2. Check the contents that you want to export. We recommend you to export all data. 3. Click on the export data button 4. Save the ZIP file, that is being downloaded, on your device. If you selected "integrations" or "users", you also get an encryption / decryption key. -Copy it and do not loose it! -5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose ``Import from Homarr before 1.0`` + Copy it and do not loose it! +5. Go to your new >1.0.0 Homarr instance and start the onboarding. Choose `Import from Homarr before 1.0` 6. Drag and drop your previously downloaded ZIP archive into the file upload:
7. Next, choose the data that you want to import and how to import it. Since screen sizes were removed in 1.0, we create a new board for every screen size by default. We recommend you to only keep the best layout and adjust it later in 1.0.0 - but you can also import all of them and pick later.
8. If you previously exported integrations or users, you will be asked to decrypt the data safely using your obtained encryption token. Paste into the modal and confirm your action.
9. Next, you can [follow the same setup as when starting from scratch](#starting-from-scratch). After you completed the onboarding, all of your data will be migrated and available. ## Creating your first board + In Homarr, you organize your apps and widgets on boards. Boards can be organized using the interactive drag and drop. -Let's create your first board by navigating here: ``Manage`` > ``Boards`` > ``New board``: +Let's create your first board by navigating here: `Manage` > `Boards` > `New board`: ![](./img/manage-create-board-button.png) A board must always have a name and can either be public or private. @@ -133,12 +163,13 @@ Congratulations - you've just created your first board. You can see boards, that ![](./img/manage-view-board.png) ## Add your first app to Homarr + To add links to your dashboard, you must first add them as an "app". Navigate to this page to add an app: ![](./img/manage-navigate-apps.png) -Next, click on the ``New app`` button. You'll need to provide a name and an icon to create an app. +Next, click on the `New app` button. You'll need to provide a name and an icon to create an app. The name must be at least one character long but you can provide any image URL for the icon. Homarr ships with over 11K icons - so you can just search by entering a search text instead. @@ -149,8 +180,9 @@ After you created the app, you'll see in on the apps page: ![](./img/manage-created-app.png) ## Arrange and organize your board + Let's add your freshly created app, from the previous step, to the board. -To do this, edit your previously created board by clicking on the big ``Open board`` button: +To do this, edit your previously created board by clicking on the big `Open board` button: ![](./img/manage-view-board.png) @@ -165,23 +197,38 @@ Here you have the following options in the header: When entering the edit mode, you'll get some additional buttons to work with: -{"Screenshot +{"Screenshot - **Add item**: Click this button to add new elements to your page. - **Exit edit mode**: Click this btuton to save your changes and exit edit mode. To continue, click the option "New Item": -{"Screenshot +{"Screenshot Next, you can choose what item you'd like to add. We will discuss the other options in the following sections - for now choose "App" to continue. -{"Screenshot +{"Screenshot As soon as you chose the app, the modal will close and you'll get the following item on your board: -Screenshot of the No app element on the board +Screenshot of the No app element on the board This is expected, because you haven't told Homarr what app it should show in this item. To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu. @@ -228,17 +275,18 @@ Be sure to save your changes by exiting edit mode! ![](./img/exit-edit-mode.png) ## Adding your first integration + Now it's time to integrate some basic data into your board. To do this, we first need to create an integration. An integration is a connection between Homarr and a third party application - usually something that you run and deploy yourself. -Navigate to ``Manage`` > ``Integrations`` (for example via your user menu). +Navigate to `Manage` > `Integrations` (for example via your user menu). As an example, we are going to use the public demo instance of **Dash.** - a popular application to monitor CPU / hardware usage. Choose the **Dash.** integration from the list of available integrations: ![](./img/choose-integration.gif) -Next, choose a name (or leave it at default), enter ``https://dash.mauz.dev/`` as the URL in the form and click on ``Test connection and create``. +Next, choose a name (or leave it at default), enter `https://dash.mauz.dev/` as the URL in the form and click on `Test connection and create`. Pressing the save button will perform a test request to the integration, to confirm that the networking setup is working as expected. Sometimes invalid configurations, firewalls and other mistakes can lead to connectivity problems - [follow this guide here](/docs/management/integrations/#troubleshooting-connection) if you experience issues. @@ -254,13 +302,14 @@ the target application or your router. ::: ## Embed integration data using widgets + Let's add your integration, that you have configured in the section above, as a widget to your board. First, navigate back to your board that you want to add it to. Next, enter edit mode again and add another item to your board: ![](./img/manage-board-header-add-item-menu.png) -Instead of choosing an app, let's choose ``System Health Monitoring`` this time. +Instead of choosing an app, let's choose `System Health Monitoring` this time. Click on "Add to board": ![](./img/manage-board-header-choose-system-health.png) @@ -290,10 +339,12 @@ As with every change, don't forget to save your changes using the exit edit mode ![](./img/exit-edit-mode.png) ## Grouping your items + As the last step, let's organize your freshly created items using two different options. You can choose either or both of them on your boards. ### Using dynamic sections + Dynamic sections are a simple, resizable visual group to group items. ![](./img/create-dynamic-section.png) @@ -303,6 +354,7 @@ After adding a dynamic section, you can simply drag and drop items into the sect ![](./img/dynamic-section.gif) ### Using categories + Categories are more advanced and complicated than dynamic sections. To create a category, open the add item modal and choose "new category": @@ -329,22 +381,27 @@ You cannot reduce their horizontal width. After completing the onboarding, Homarr provides interactive guided tours to help you discover key features. ### Management tour + When you first visit the management area (`/manage`), Homarr walks you through the main pages: boards, apps, integrations, and users (admin only). Each page highlights the list view and the create action so you know where to find and add things. ### Board tour + When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu. ### Tour controls + - Press **Enter** (or click **Next**) to advance through tour steps - Tours auto-start on first visit and do not repeat once completed - Tours are skipped on mobile screens (below `sm` breakpoint) - You can **reset tours** from the user settings page under **Manage > Users > [Your user] > General** ## Next steps + You're done with the basic setup 🔥. Now it's time to organize and customize even more. We highly recommend you to experiment with different options, styles and configurations. See these curated pages for your next steps: + - Create new apps using the [apps management](/docs/management/apps/) - Customize your board or add more boards using the [boards management](/docs/management/boards/) - Integrate software that you use using the [integrations management](/docs/management/integrations/) diff --git a/apps/docs/docs/integrations/codeberg/index.mdx b/apps/docs/docs/integrations/codeberg/index.mdx deleted file mode 100644 index 98ca314c2..000000000 --- a/apps/docs/docs/integrations/codeberg/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: 'Codeberg' -description: "Codeberg is a non-profit, community-driven platform for hosting and collaborating on software projects." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { codebergIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/codeberg/index.ts b/apps/docs/docs/integrations/codeberg/index.ts deleted file mode 100644 index f2a43ad48..000000000 --- a/apps/docs/docs/integrations/codeberg/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const codebergIntegration: IntegrationDefinition = { - name: "Codeberg", - description: - "Codeberg is a non-profit, community-driven platform for hosting and collaborating on software projects.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/codeberg.svg", - path: "../../integrations/codeberg", -}; diff --git a/apps/docs/docs/integrations/docker-hub/index.mdx b/apps/docs/docs/integrations/docker-hub/index.mdx deleted file mode 100644 index a49d03047..000000000 --- a/apps/docs/docs/integrations/docker-hub/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: 'Docker Hub' -description: "Docker Hub is a cloud-based registry service for sharing and managing Docker images." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { dockerHubIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/docker-hub/index.ts b/apps/docs/docs/integrations/docker-hub/index.ts deleted file mode 100644 index 1a49feeae..000000000 --- a/apps/docs/docs/integrations/docker-hub/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const dockerHubIntegration: IntegrationDefinition = { - name: "Docker Hub", - description: "Docker Hub is a cloud-based registry service for sharing and managing Docker images.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/docker.svg", - path: "../../integrations/docker-hub", -}; diff --git a/apps/docs/docs/integrations/github-containerregistry/index.mdx b/apps/docs/docs/integrations/github-containerregistry/index.mdx deleted file mode 100644 index f4cb5b310..000000000 --- a/apps/docs/docs/integrations/github-containerregistry/index.mdx +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: 'GitHub Container Registry' -description: "GitHub Container Registry is a platform for hosting and managing packages." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { gitHubContainerRegistryIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/github-containerregistry/index.ts b/apps/docs/docs/integrations/github-containerregistry/index.ts deleted file mode 100644 index b88f426d3..000000000 --- a/apps/docs/docs/integrations/github-containerregistry/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const gitHubContainerRegistryIntegration: IntegrationDefinition = { - name: "GitHub Container Registry", - description: "GitHub Container Registry is a platform for hosting and managing packages.", - iconUrl: { - light: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg", - dark: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg", - }, - path: "../../integrations/github-packages", -}; diff --git a/apps/docs/docs/integrations/github/index.mdx b/apps/docs/docs/integrations/github/index.mdx deleted file mode 100644 index 39c9777b3..000000000 --- a/apps/docs/docs/integrations/github/index.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: 'GitHub' -description: "GitHub is a web-based platform for version control and collaboration." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { githubIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/github/index.ts b/apps/docs/docs/integrations/github/index.ts deleted file mode 100644 index 7bb9b6b5d..000000000 --- a/apps/docs/docs/integrations/github/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const githubIntegration: IntegrationDefinition = { - name: "GitHub", - description: "GitHub is a web-based platform for version control and collaboration.", - iconUrl: { - light: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github.svg", - dark: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg", - }, - path: "../../integrations/github", -}; diff --git a/apps/docs/docs/integrations/gitlab/index.mdx b/apps/docs/docs/integrations/gitlab/index.mdx deleted file mode 100644 index edc77e1cd..000000000 --- a/apps/docs/docs/integrations/gitlab/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: 'GitLab' -description: "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { gitlabIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/gitlab/index.ts b/apps/docs/docs/integrations/gitlab/index.ts deleted file mode 100644 index c252cd6ff..000000000 --- a/apps/docs/docs/integrations/gitlab/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const gitlabIntegration: IntegrationDefinition = { - name: "GitLab", - description: "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitlab.svg", - path: "../../integrations/gitlab", -}; diff --git a/apps/docs/docs/integrations/linux-server-io/index.mdx b/apps/docs/docs/integrations/linux-server-io/index.mdx deleted file mode 100644 index 426dc7f68..000000000 --- a/apps/docs/docs/integrations/linux-server-io/index.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: 'LinuxServer.io' -description: "LinuxServer.io is a platform for managing and deploying containerized applications." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { linuxServerIoIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/linux-server-io/index.ts b/apps/docs/docs/integrations/linux-server-io/index.ts deleted file mode 100644 index 82bc0fac6..000000000 --- a/apps/docs/docs/integrations/linux-server-io/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const linuxServerIoIntegration: IntegrationDefinition = { - name: "LinuxServer.io", - description: "LinuxServer.io is a platform for managing and deploying containerized applications.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg", - path: "../../integrations/linux-server-io", -}; diff --git a/apps/docs/docs/integrations/npm/index.mdx b/apps/docs/docs/integrations/npm/index.mdx deleted file mode 100644 index c4d21135e..000000000 --- a/apps/docs/docs/integrations/npm/index.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: 'NPM' -description: "NPM is a package manager for the JavaScript programming language, primarily used for managing Node.js packages." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { npmIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/npm/index.ts b/apps/docs/docs/integrations/npm/index.ts deleted file mode 100644 index 34fbafb5c..000000000 --- a/apps/docs/docs/integrations/npm/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const npmIntegration: IntegrationDefinition = { - name: "NPM", - description: - "NPM is a package manager for the JavaScript programming language, primarily used for managing Node.js packages.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/npm.svg", - path: "../../integrations/npm", -}; diff --git a/apps/docs/docs/integrations/quay/index.mdx b/apps/docs/docs/integrations/quay/index.mdx deleted file mode 100644 index 9033f81cf..000000000 --- a/apps/docs/docs/integrations/quay/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: 'Quay' -description: "Quay is a container registry for storing and managing container images." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { quayIntegration } from '.'; -import { releasesWidget } from '@site/docs/widgets/releases'; - - - - -### Widgets & Capabilities - - -### Adding the integration - - -### Secrets - \ No newline at end of file diff --git a/apps/docs/docs/integrations/quay/index.ts b/apps/docs/docs/integrations/quay/index.ts deleted file mode 100644 index 315732bff..000000000 --- a/apps/docs/docs/integrations/quay/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; - -export const quayIntegration: IntegrationDefinition = { - name: "Quay", - description: "Quay is a container registry for storing and managing container images.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png", - path: "../../integrations/quay", -}; diff --git a/apps/docs/docs/integrations/search-ch/index.mdx b/apps/docs/docs/integrations/search-ch/index.mdx deleted file mode 100644 index 6286d5cca..000000000 --- a/apps/docs/docs/integrations/search-ch/index.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: 'Search.ch' -description: "Search.ch supports searching for timetables in Switzerland." -hide_title: true ---- - -import { IntegrationHeader } from '@site/src/components/integrations/header'; -import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; -import { AddingIntegration } from '@site/src/components/integrations/adding'; -import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; -import { IconDownload } from "@tabler/icons-react"; -import { searchChIntegration } from '.'; -import Alert from '@theme/Admonition'; -import { timetableWidget } from '@site/docs/widgets/timetable'; - - - - -### Widgets & Capabilities - - -### Adding the integration - \ No newline at end of file diff --git a/apps/docs/docs/integrations/search-ch/index.ts b/apps/docs/docs/integrations/search-ch/index.ts deleted file mode 100644 index 6c5819d67..000000000 --- a/apps/docs/docs/integrations/search-ch/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IntegrationDefinition } from "@site/src/types"; -export const searchChIntegration: IntegrationDefinition = { - name: "Search.ch", - description: "Search.ch supports searching for timetables in Switzerland.", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png", - path: "../../integrations/search-ch", -}; diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.ts b/apps/docs/docs/widgets/immich-album-carousel/index.ts index 327314c3d..6f74e9c23 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.ts +++ b/apps/docs/docs/widgets/immich-album-carousel/index.ts @@ -9,9 +9,8 @@ export const immichAlbumCarouselWidget: WidgetDefinition = { configuration: { items: [ { - name: "Album ID", - description: - "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'", + name: "Album", + description: "Select the Immich album to show in the slideshow.", values: { type: "string" }, defaultValue: "", }, @@ -27,6 +26,12 @@ export const immichAlbumCarouselWidget: WidgetDefinition = { values: { type: "boolean" }, defaultValue: "yes", }, + { + name: "Randomize photos", + description: "Show photos in a random order instead of always starting with the same album order.", + values: { type: "boolean" }, + defaultValue: "no", + }, ], }, }; diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index 7808756f0..faf386a13 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -1,69 +1,44 @@ --- -title: 'Releases' -description: 'Displays a list of the current version of the given repositories with the given version regex.' +title: "Releases" +description: "Displays a list of the current version of the given repositories with the given version regex." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { releasesWidget } from '.'; -import { githubIntegration } from '@site/docs/integrations/github'; -import { gitlabIntegration } from '@site/docs/integrations/gitlab'; -import { dockerHubIntegration } from '@site/docs/integrations/docker-hub'; -import { npmIntegration } from '@site/docs/integrations/npm'; -import { codebergIntegration } from '@site/docs/integrations/codeberg'; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { releasesWidget } from "."; - + -The releases widget enables you to track a list of repositories and see their respective latest release. -It also supports importing releases from your local docker containers. +The releases widget enables you to track a list of repositories and see their respective latest release. +It also supports importing releases from your local docker containers. ### Screenshots -import collapsibleCard from './img/collapsible.png'; -import fullCard from './img/full.png'; -import noDetailsCard from './img/no-details.png'; -import { quayIntegration } from '@site/docs/integrations/quay'; -import { gitHubContainerRegistryIntegration } from '@site/docs/integrations/github-containerregistry'; -import { linuxServerIoIntegration } from '@site/docs/integrations/linux-server-io'; +import collapsibleCard from "./img/collapsible.png"; +import fullCard from "./img/full.png"; +import noDetailsCard from "./img/no-details.png"; -
+
releases full releases collapsible releases no details
-### Supported Integrations +### Supported Providers - +GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and LinuxServer.io are configured per repository in the widget. + +For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`. +Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted. ### Adding the widget ### Configuration + :::note @@ -74,4 +49,3 @@ Setting the Prefix to `v`, precision to `2` and the Suffix to `-alpha`, will mat Setting the Prefix to `v`, precision to `3`, will match `v5.2.0` but not `v1.0.2-alpha` or `Beta v5.2.1`. ::: - diff --git a/apps/docs/docs/widgets/releases/index.ts b/apps/docs/docs/widgets/releases/index.ts index dbe3c1271..90fff3491 100644 --- a/apps/docs/docs/widgets/releases/index.ts +++ b/apps/docs/docs/widgets/releases/index.ts @@ -54,16 +54,22 @@ export const releasesWidget: WidgetDefinition = { items: [ { name: "Provider", - description: "Integration to fetch the repository from.", - values: "Select from the available integrations.", + description: "Provider to fetch the repository or package releases from.", + values: "Select from the available providers.", defaultValue: "-", }, { name: "Identifier", - description: 'The identifier of the repository, e.g. "homarr-labs/homarr".', - values: "Any valid repository identifier.", + description: 'The identifier of the repository or package, e.g. "homarr-labs/homarr".', + values: 'Any valid provider identifier. GHCR also accepts full image refs like "ghcr.io/owner/package:tag".', defaultValue: "-", }, + { + name: "Provider URL", + description: "Base URL for the provider.", + values: "Any valid URL.", + defaultValue: "Provider default", + }, { name: "Name", description: "Display name of the repository.", diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index fe9a1b11c..e9bdfd326 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -1,44 +1,40 @@ --- -title: 'Timetable' -description: 'Displays departure times for a station.' +title: "Timetable" +description: "Displays departure times for a station." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { timetableWidget } from '.'; -import { searchChIntegration } from '@site/docs/integrations/search-ch'; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { timetableWidget } from "."; - - + The Timetable widget provides real-time departure times for a selected station. ### Screenshots -import trainDepartures from './img/train-departures.png'; -import streetDepartures from './img/street-departures.png'; +import trainDepartures from "./img/train-departures.png"; +import streetDepartures from "./img/street-departures.png";
- timetable-widget-train-departures - timetable-widget-bus-and-tram-departures + timetable-widget-train-departures + timetable-widget-bus-and-tram-departures
### Adding the widget -### Supported Integrations - - - ### Configuration + diff --git a/apps/docs/docs/widgets/timetable/index.ts b/apps/docs/docs/widgets/timetable/index.ts index 6f36349ee..1cefb6bcd 100644 --- a/apps/docs/docs/widgets/timetable/index.ts +++ b/apps/docs/docs/widgets/timetable/index.ts @@ -8,6 +8,12 @@ export const timetableWidget: WidgetDefinition = { path: "../../widgets/timetable", configuration: { items: [ + { + name: "Search.ch URL", + description: "Base URL for the Search.ch timetable API.", + values: "Any valid URL.", + defaultValue: "https://search.ch", + }, { name: "Station", description: "Select the station", diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 2fc7228c8..7451af1e7 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -48,10 +48,8 @@ export const categoryTranslationKeys: Record = { search: "integration.category.search", mediaTranscoding: "integration.category.mediaTranscoding", networkController: "integration.category.networkController", - releasesProvider: "integration.category.releasesProvider", notifications: "integration.category.notifications", firewall: "integration.category.firewall", - timetable: "integration.category.timetable", photoService: "integration.category.photoService", notes: "integration.category.notes", mediaMonitoring: "integration.category.mediaMonitoring", diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts index f2519c533..725c9393a 100644 --- a/packages/api/src/router/test/integration/integration-test-connection.spec.ts +++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts @@ -271,77 +271,4 @@ describe("testConnectionAsync should run test connection of integration", () => }); }); - test("with input of existing github app", async () => { - // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); - const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); - factorySpy.mockReturnValue( - Promise.resolve({ - testConnectionAsync: async () => await Promise.resolve({ success: true }), - } as homarrIntegrations.PiHoleIntegrationV6), - ); - optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]); - - const integration = { - id: "new", - name: "GitHub", - url: "https://api.github.com", - kind: "github" as const, - secrets: [ - { - kind: "githubAppId" as const, - value: "345", - }, - { - kind: "githubInstallationId" as const, - value: "456", - }, - { - kind: "privateKey" as const, - value: null, - }, - ], - }; - - const dbSecrets = [ - { - kind: "githubAppId" as const, - value: "123.encrypted" as const, - }, - { - kind: "githubInstallationId" as const, - value: "234.encrypted" as const, - }, - { - kind: "privateKey" as const, - value: "privateKey.encrypted" as const, - }, - ]; - - // Act - await testConnectionAsync(integration, dbSecrets); - - // Assert - expect(factorySpy).toHaveBeenCalledWith({ - id: "new", - name: "GitHub", - url: "https://api.github.com", - kind: "github" as const, - decryptedSecrets: [ - expect.objectContaining({ - kind: "githubAppId", - value: "345", - }), - expect.objectContaining({ - kind: "githubInstallationId", - value: "456", - }), - expect.objectContaining({ - kind: "privateKey", - value: "privateKey", - }), - ], - externalUrl: null, - }); - }); }); diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts index 0824c165a..14dd6b681 100644 --- a/packages/api/src/router/widgets/archive-team-warrior.ts +++ b/packages/api/src/router/widgets/archive-team-warrior.ts @@ -1,39 +1,37 @@ import { observable } from "@trpc/server/observable"; +import { z } from "zod/v4"; -import type { ArchiveTeamWarriorStatus } from "@homarr/integrations"; +import type { ArchiveTeamWarriorStatus } from "@homarr/request-handler/archive-team-warrior"; import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const inputSchema = z.object({ url: z.string().url() }); + export const archiveTeamWarriorRouter = createTRPCRouter({ - getStatus: publicProcedure - .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior")) - .query(async ({ ctx }) => { - const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); - const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + getStatus: publicProcedure.input(inputSchema).query(async ({ input }) => { + const handler = archiveTeamWarriorRequestHandler.handler({ url: input.url }); + const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return { - status: data, - updatedAt: timestamp, - }; - }), + return { + status: data, + updatedAt: timestamp, + }; + }), - subscribeStatus: publicProcedure - .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior")) - .subscription(({ ctx }) => { - return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => { - const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); - const unsubscribe = handler.subscribe((status) => { - emit.next({ - status, - updatedAt: new Date(), - }); + subscribeStatus: publicProcedure.input(inputSchema).subscription(({ input }) => { + return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => { + const handler = archiveTeamWarriorRequestHandler.handler({ url: input.url }); + const unsubscribe = handler.subscribe((status) => { + emit.next({ + status, + updatedAt: new Date(), }); - - return () => { - unsubscribe(); - }; }); - }), + + return () => { + unsubscribe(); + }; + }); + }), }); diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index 3d355b26c..b403b87cb 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -1,10 +1,9 @@ import { escapeForRegEx } from "@tiptap/react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { releaseProviderKinds } from "@homarr/definitions"; import { releasesRequestHandler } from "@homarr/request-handler/releases"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { @@ -25,26 +24,29 @@ const releaseVersionFilterSchema = z.object({ export const releasesRouter = createTRPCRouter({ getLatest: publicProcedure - .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider"))) .input( z.object({ repositories: z.array( z.object({ id: z.string(), + provider: z.enum(releaseProviderKinds), identifier: z.string(), versionFilter: releaseVersionFilterSchema.optional(), + providerUrl: z.string().url().optional(), }), ), }), ) - .query(async ({ ctx, input }) => { + .query(async ({ input }) => { return await Promise.all( input.repositories.map(async (repository) => { const response = await releasesRequestHandler - .handler(ctx.integration, { + .handler({ id: repository.id, + provider: repository.provider, identifier: repository.identifier, versionRegex: formatVersionFilterRegex(repository.versionFilter), + providerUrl: repository.providerUrl, }) .getCachedOrUpdatedDataAsync({ forceUpdate: false, @@ -52,7 +54,7 @@ export const releasesRouter = createTRPCRouter({ return { id: repository.id, - integration: { name: ctx.integration.name, kind: ctx.integration.kind }, + provider: repository.provider, timestamp: response.timestamp, ...response.data, }; diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index 787be4c37..189338a13 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,21 +1,26 @@ import z from "zod"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import { timetableOptionsSchema } from "@homarr/integrations/types"; import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const baseUrlSchema = z.string().url(); + export const timetableRouter = createTRPCRouter({ getTimetable: publicProcedure - .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable"))) - .input(timetableOptionsSchema) - .query(async ({ input, ctx }) => { - const innerHandler = timetableGetTimetableRequestHandler.handler(ctx.integration, { + .input( + z.object({ + baseUrl: baseUrlSchema, + stationId: z.string(), + limit: z.number(), + }), + ) + .query(async ({ input }) => { + const innerHandler = timetableGetTimetableRequestHandler.handler({ + baseUrl: input.baseUrl, stationId: input.stationId, limit: input.limit, }); @@ -25,14 +30,15 @@ export const timetableRouter = createTRPCRouter({ return data; }), searchStations: publicProcedure - .concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("timetable"))) .input( z.object({ + baseUrl: baseUrlSchema, query: z.string(), }), ) - .query(async ({ input, ctx }) => { - const innerHandler = timetableSearchStationsRequestHandler.handler(ctx.integration, { + .query(async ({ input }) => { + const innerHandler = timetableSearchStationsRequestHandler.handler({ + baseUrl: input.baseUrl, query: input.query, }); diff --git a/packages/cron-jobs/src/jobs/integrations/timetable.ts b/packages/cron-jobs/src/jobs/integrations/timetable.ts index b3964dc38..8cda973e3 100644 --- a/packages/cron-jobs/src/jobs/integrations/timetable.ts +++ b/packages/cron-jobs/src/jobs/integrations/timetable.ts @@ -1,20 +1,39 @@ +import SuperJSON from "superjson"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; -import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { db, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema"; import { timetableGetTimetableRequestHandler } from "@homarr/request-handler/timetable"; +import type { WidgetComponentProps } from "../../../../widgets"; import { createCronJob } from "../../lib"; -export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback( - createRequestIntegrationJobHandler(timetableGetTimetableRequestHandler.handler, { - widgetKinds: ["timetable"], - getInput: { - timetable: (options) => - options.station - ? { - stationId: options.station.value, - limit: 10, - } - : [], - }, - }), -); +const logger = createLogger({ module: "timetableJobs" }); + +export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback(async () => { + const timetableItems = await db.query.items.findMany({ + where: eq(items.kind, "timetable"), + }); + + const parsedItems = timetableItems.map((item) => ({ + id: item.id, + options: SuperJSON.parse["options"]>(item.options), + })); + + for (const item of parsedItems) { + if (!item.options.station) continue; + + try { + const innerHandler = timetableGetTimetableRequestHandler.handler({ + baseUrl: item.options.baseUrl, + stationId: item.options.station.value, + limit: 10, + }); + await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true }); + } catch (error) { + logger.error(new ErrorWithMetadata("Failed to update timetable", { id: item.id }, { cause: error })); + } + } +}); diff --git a/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts b/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts index 951831482..8cec43188 100644 --- a/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts +++ b/packages/db/migrations/custom/0000_release_widget_provider_to_options.ts @@ -1,8 +1,7 @@ import SuperJSON from "superjson"; import { createId } from "@homarr/common"; -import type { IntegrationKind } from "@homarr/definitions"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { releaseProviderKinds } from "@homarr/definitions"; import { eq } from "../.."; import type { Database } from "../.."; @@ -13,19 +12,6 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) { where: (items, { eq }) => eq(items.kind, "releases"), }); - const integrationKinds = getIntegrationKindsByCategory("releasesProvider"); - const providerIntegrations = await db.query.integrations.findMany({ - where: (integrations, { inArray }) => inArray(integrations.kind, integrationKinds), - columns: { - id: true, - kind: true, - }, - }); - - const providerIntegrationMap = new Map( - providerIntegrations.map((integration) => [integration.kind, integration.id]), - ); - const updates: { id: string; options: object; @@ -44,7 +30,7 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) { return { id: createId(), - providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null, + provider: releaseProviderKinds.find((kind) => kind === provider), ...otherFields, }; }, @@ -68,5 +54,5 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) { .where(eq(items.id, update.id)); } - console.log(`Migrated release widget providers to integrations count="${updates.length}"`); + console.log(`Migrated release widget providers to options count="${updates.length}"`); } diff --git a/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts b/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts new file mode 100644 index 000000000..030525639 --- /dev/null +++ b/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts @@ -0,0 +1,121 @@ +import SuperJSON from "superjson"; + +import { eq, inArray } from "../.."; +import type { Database } from "../.."; +import { integrationItems, integrations, items } from "../../schema"; + +const removedIntegrationKinds = [ + "github", + "dockerHub", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "searchCh", + "archiveTeamWarrior", +] as const; + +type RemovedIntegrationKind = (typeof removedIntegrationKinds)[number]; + +const releaseProviderKinds = [ + "github", + "dockerHub", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", +] as const; + +const isReleaseProviderKind = (kind: RemovedIntegrationKind): kind is (typeof releaseProviderKinds)[number] => + releaseProviderKinds.some((provider) => provider === kind); + +export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database) { + const oldIntegrations = await db.query.integrations.findMany({ + columns: { id: true, kind: true, url: true }, + }); + const oldIntegrationById = new Map( + oldIntegrations + .filter((integration) => removedIntegrationKinds.includes(integration.kind as RemovedIntegrationKind)) + .map((integration) => [ + integration.id, + { kind: integration.kind as RemovedIntegrationKind, url: integration.url }, + ]), + ); + + if (oldIntegrationById.size === 0) return; + + const oldIntegrationIds = Array.from(oldIntegrationById.keys()); + const oldIntegrationItems = await db.query.integrationItems.findMany(); + const oldIntegrationItemByItemId = new Map( + oldIntegrationItems + .filter((item) => oldIntegrationById.has(item.integrationId)) + .map((item) => [item.itemId, oldIntegrationById.get(item.integrationId)]), + ); + const oldIntegrationItemIdsByItemId = oldIntegrationItems + .filter((item) => oldIntegrationById.has(item.integrationId)) + .reduce>((acc, item) => { + const itemIds = acc.get(item.itemId) ?? []; + itemIds.push(item.integrationId); + acc.set(item.itemId, itemIds); + return acc; + }, new Map()); + + const existingItems = await db.query.items.findMany({ + where: (items, { inArray }) => inArray(items.kind, ["releases", "timetable", "archiveTeamWarrior"]), + }); + + const updatedItems: { id: string; options: object }[] = []; + const unlinkItemIds: string[] = []; + + for (const item of existingItems) { + const options = SuperJSON.parse>(item.options); + const linkedIntegration = oldIntegrationItemByItemId.get(item.id); + + if (item.kind === "releases" && Array.isArray(options.repositories)) { + const repositories = options.repositories.map((repository) => { + if (typeof repository !== "object" || repository === null) return repository; + const { providerIntegrationId, ...rest } = repository as Record; + const provider = + typeof providerIntegrationId === "string" ? oldIntegrationById.get(providerIntegrationId) : undefined; + const existingProviderUrl = + typeof rest.providerUrl === "string" && rest.providerUrl.length > 0 ? rest.providerUrl : undefined; + return { + ...rest, + provider: provider && isReleaseProviderKind(provider.kind) ? provider.kind : rest.provider, + providerUrl: provider?.url ?? existingProviderUrl, + }; + }); + updatedItems.push({ id: item.id, options: { ...options, repositories } }); + if (oldIntegrationItemIdsByItemId.has(item.id)) unlinkItemIds.push(item.id); + } + + if (item.kind === "timetable" && linkedIntegration?.kind === "searchCh") { + updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } }); + unlinkItemIds.push(item.id); + } + + if (item.kind === "archiveTeamWarrior" && linkedIntegration?.kind === "archiveTeamWarrior") { + updatedItems.push({ id: item.id, options: { ...options, url: linkedIntegration.url } }); + unlinkItemIds.push(item.id); + } + } + + for (const update of updatedItems) { + await db + .update(items) + .set({ options: SuperJSON.stringify(update.options) }) + .where(eq(items.id, update.id)); + } + + if (unlinkItemIds.length > 0) { + await db.delete(integrationItems).where(inArray(integrationItems.itemId, unlinkItemIds)); + } + + await db.delete(integrations).where(inArray(integrations.id, oldIntegrationIds)); + + console.log(`Migrated widget-only integrations to widget options count="${updatedItems.length}"`); +} diff --git a/packages/db/migrations/custom/index.ts b/packages/db/migrations/custom/index.ts index a6a002733..8bcc93c32 100644 --- a/packages/db/migrations/custom/index.ts +++ b/packages/db/migrations/custom/index.ts @@ -2,9 +2,11 @@ import type { Database } from "../.."; import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options"; import { migrateOpnsenseCredentialsAsync } from "./0001_opnsense_credentials"; import { migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync } from "./0002_app_widget_show_description_tooltip_to_display_mode"; +import { migrateWidgetOnlyIntegrationsToOptionsAsync } from "./0003_remove_widget_only_integrations"; export const applyCustomMigrationsAsync = async (db: Database) => { await migrateReleaseWidgetProviderToOptionsAsync(db); await migrateOpnsenseCredentialsAsync(db); await migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync(db); + await migrateWidgetOnlyIntegrationsToOptionsAsync(db); }; diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index 9dcba12d4..e7e10809b 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -31,9 +31,6 @@ const integrationAliases: Partial> = audiobookshelf: ["audiobookshelf"], navidrome: ["navidrome"], paperlessNgx: ["paperless-ngx", "paperless"], - searchCh: ["search-ch"], - gitHubContainerRegistry: ["ghcr"], - linuxServerIO: ["linuxserver"], coolify: ["coolify"], truenas: ["truenas"], }; @@ -63,19 +60,7 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null => return null; }; -const notDockerDiscoverable = new Set([ - "dockerHub", - "github", - "gitlab", - "npm", - "codeberg", - "linuxServerIO", - "gitHubContainerRegistry", - "quay", - "ical", - "searchCh", - "mock", -]); +const notDockerDiscoverable = new Set(["ical", "mock"]); interface ContainerMatchInput { image: string; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 4dbb8757a..685a95465 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -168,16 +168,11 @@ export type HomarrDocumentationPath = | "/docs/integrations/aria2" | "/docs/integrations/audiobookshelf" | "/docs/integrations/beszel" - | "/docs/integrations/codeberg" | "/docs/integrations/coolify" | "/docs/integrations/dash-dot" | "/docs/integrations/deluge" - | "/docs/integrations/docker-hub" | "/docs/integrations/docker" | "/docs/integrations/emby" - | "/docs/integrations/github-containerregistry" - | "/docs/integrations/github" - | "/docs/integrations/gitlab" | "/docs/integrations/glances" | "/docs/integrations/gluetun" | "/docs/integrations/gotify" @@ -188,10 +183,8 @@ export type HomarrDocumentationPath = | "/docs/integrations/jellyseerr" | "/docs/integrations/kubernetes" | "/docs/integrations/lidarr" - | "/docs/integrations/linux-server-io" | "/docs/integrations/navidrome" | "/docs/integrations/nextcloud" - | "/docs/integrations/npm" | "/docs/integrations/ntfy" | "/docs/integrations/nzbget" | "/docs/integrations/open-media-vault" @@ -204,11 +197,9 @@ export type HomarrDocumentationPath = | "/docs/integrations/prowlarr" | "/docs/integrations/proxmox" | "/docs/integrations/q-bittorent" - | "/docs/integrations/quay" | "/docs/integrations/radarr" | "/docs/integrations/readarr" | "/docs/integrations/sabnzbd" - | "/docs/integrations/search-ch" | "/docs/integrations/seerr" | "/docs/integrations/slskd" | "/docs/integrations/sonarr" @@ -315,14 +306,6 @@ export type HomarrDocumentationPath = | "/docs/integrations/nextcloud" | "/docs/integrations/unifi-controller" | "/docs/integrations/opnsense" - | "/docs/integrations/github" - | "/docs/integrations/docker-hub" - | "/docs/integrations/gitlab" - | "/docs/integrations/npm" - | "/docs/integrations/codeberg" - | "/docs/integrations/linux-server-io" - | "/docs/integrations/github-containerregistry" - | "/docs/integrations/quay" | "/docs/integrations/ntfy" | "/docs/integrations/gotify" | "/docs/integrations/ical" @@ -330,7 +313,6 @@ export type HomarrDocumentationPath = | "/docs/integrations/truenas" | "/docs/integrations/unraid" | "/docs/integrations/coolify" - | "/docs/integrations/search-ch" | "/docs/integrations/immich" | "/docs/integrations/paperless-ngx" | "/docs/integrations/tracearr" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 90c3cae8b..f7265d36b 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -31,14 +31,6 @@ export const integrationDocSlugs: Record = { nextcloud: "nextcloud", unifiController: "unifi-controller", opnsense: "opnsense", - github: "github", - dockerHub: "docker-hub", - gitlab: "gitlab", - npm: "npm", - codeberg: "codeberg", - linuxServerIO: "linux-server-io", - gitHubContainerRegistry: "github-containerregistry", - quay: "quay", ntfy: "ntfy", gotify: "gotify", ical: "ical", @@ -46,7 +38,6 @@ export const integrationDocSlugs: Record = { truenas: "truenas", unraid: "unraid", coolify: "coolify", - searchCh: "search-ch", immich: "immich", paperlessNgx: "paperless-ngx", tracearr: "tracearr", @@ -58,6 +49,5 @@ export const integrationDocSlugs: Record = { gluetun: "gluetun", peaNut: "peanut", beszel: "beszel", - archiveTeamWarrior: null, mock: null, }; diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index eb81e4d18..94b392ee7 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -17,3 +17,4 @@ export * from "./emptysuperjson"; export * from "./hotkeys"; export * from "./docker-integration-match"; export * from "./integration-url-template"; +export * from "./release-provider"; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index abb4ffdfd..acec307bb 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -283,70 +283,6 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), defaultPort: 443, }, - github: { - name: "Github", - secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg", - category: ["releasesProvider"], - defaultUrl: "https://api.github.com", - documentationUrl: createDocumentationLink("/docs/integrations/github"), - }, - dockerHub: { - name: "Docker Hub", - secretKinds: [[], ["username", "personalAccessToken"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg", - category: ["releasesProvider"], - defaultUrl: "https://hub.docker.com", - documentationUrl: createDocumentationLink("/docs/integrations/docker-hub"), - }, - gitlab: { - name: "Gitlab", - secretKinds: [[], ["personalAccessToken"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg", - category: ["releasesProvider"], - defaultUrl: "https://gitlab.com", - documentationUrl: createDocumentationLink("/docs/integrations/gitlab"), - }, - npm: { - name: "NPM", - secretKinds: [[]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg", - category: ["releasesProvider"], - defaultUrl: "https://registry.npmjs.org", - documentationUrl: createDocumentationLink("/docs/integrations/npm"), - }, - codeberg: { - name: "Codeberg", - secretKinds: [[], ["personalAccessToken"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg", - category: ["releasesProvider"], - defaultUrl: "https://codeberg.org", - documentationUrl: createDocumentationLink("/docs/integrations/codeberg"), - }, - linuxServerIO: { - name: "LinuxServer.io", - secretKinds: [[]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg", - category: ["releasesProvider"], - defaultUrl: "https://api.linuxserver.io", - documentationUrl: createDocumentationLink("/docs/integrations/linux-server-io"), - }, - gitHubContainerRegistry: { - name: "GitHub Container Registry", - secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg", - category: ["releasesProvider"], - defaultUrl: "https://api.github.com", - documentationUrl: createDocumentationLink("/docs/integrations/github"), - }, - quay: { - name: "Quay", - secretKinds: [[], ["personalAccessToken"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png", - category: ["releasesProvider"], - defaultUrl: "https://quay.io", - documentationUrl: createDocumentationLink("/docs/integrations/quay"), - }, ntfy: { name: "ntfy", secretKinds: [["topic"], ["topic", "apiKey"]], @@ -401,13 +337,6 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/coolify"), defaultPort: 8000, }, - searchCh: { - name: "Search.ch", - secretKinds: [[]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png", - category: ["timetable"], - documentationUrl: createDocumentationLink("/docs/integrations/search-ch"), - }, immich: { name: "Immich", secretKinds: [["apiKey"]], @@ -496,14 +425,6 @@ export const integrationDefs = { category: ["vpn"], documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), }, - archiveTeamWarrior: { - name: "ArchiveTeam Warrior", - secretKinds: [[], ["username", "password"]], - iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", - category: ["archiving"], - defaultUrl: "http://localhost:8001", - documentationUrl: null, - }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", @@ -602,7 +523,6 @@ export const integrationCategories = [ "search", "mediaTranscoding", "networkController", - "releasesProvider", "notifications", "firewall", "timetable", @@ -612,7 +532,6 @@ export const integrationCategories = [ "speedtest", "analytics", "vpn", - "archiving", "ups", "documents", "mediaLibrary", diff --git a/packages/definitions/src/release-provider.ts b/packages/definitions/src/release-provider.ts new file mode 100644 index 000000000..800e6d3be --- /dev/null +++ b/packages/definitions/src/release-provider.ts @@ -0,0 +1,77 @@ +import { objectKeys } from "@homarr/common"; + +export const releaseProviderDefs = { + github: { + name: "Github", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg", + defaultUrl: "https://api.github.com", + }, + dockerHub: { + name: "Docker Hub", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg", + defaultUrl: "https://hub.docker.com", + }, + gitlab: { + name: "Gitlab", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg", + defaultUrl: "https://gitlab.com", + }, + npm: { + name: "NPM", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg", + defaultUrl: "https://registry.npmjs.org", + }, + codeberg: { + name: "Codeberg", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg", + defaultUrl: "https://codeberg.org", + }, + linuxServerIO: { + name: "LinuxServer.io", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg", + defaultUrl: "https://api.linuxserver.io", + }, + gitHubContainerRegistry: { + name: "GitHub Container Registry", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg", + defaultUrl: "https://ghcr.io", + }, + quay: { + name: "Quay", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png", + defaultUrl: "https://quay.io", + }, +} as const; + +export const releaseProviderKinds = objectKeys(releaseProviderDefs); +export type ReleaseProviderKind = keyof typeof releaseProviderDefs; + +export const getReleaseProviderName = (provider: ReleaseProviderKind) => releaseProviderDefs[provider].name; +export const getReleaseProviderIconUrl = (provider: ReleaseProviderKind) => releaseProviderDefs[provider].iconUrl; +export const getReleaseProviderDefaultUrl = (provider: ReleaseProviderKind) => releaseProviderDefs[provider].defaultUrl; + +const releaseProviderRegistryPrefixes: Partial> = { + dockerHub: "docker.io/", + gitHubContainerRegistry: "ghcr.io/", + linuxServerIO: "lscr.io/", + quay: "quay.io/", +}; + +const removeContainerImageVersion = (identifier: string) => { + const digestIndex = identifier.indexOf("@"); + const withoutDigest = digestIndex === -1 ? identifier : identifier.slice(0, digestIndex); + const tagIndex = withoutDigest.indexOf(":", withoutDigest.lastIndexOf("/") + 1); + + return tagIndex === -1 ? withoutDigest : withoutDigest.slice(0, tagIndex); +}; + +export const normalizeReleaseProviderIdentifier = (provider: ReleaseProviderKind, identifier: string) => { + const trimmedIdentifier = identifier.trim(); + const registryPrefix = releaseProviderRegistryPrefixes[provider]; + const withoutRegistry = + registryPrefix && trimmedIdentifier.startsWith(registryPrefix) + ? trimmedIdentifier.slice(registryPrefix.length) + : trimmedIdentifier; + + return removeContainerImageVersion(withoutRegistry); +}; diff --git a/packages/definitions/src/test/release-provider.spec.ts b/packages/definitions/src/test/release-provider.spec.ts new file mode 100644 index 000000000..7e7e0b90e --- /dev/null +++ b/packages/definitions/src/test/release-provider.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeReleaseProviderIdentifier } from "../release-provider"; + +describe("normalizeReleaseProviderIdentifier", () => { + it("normalizes full ghcr image references", () => { + expect(normalizeReleaseProviderIdentifier("gitHubContainerRegistry", "ghcr.io/muchobien/pocketbase:latest")).toBe( + "muchobien/pocketbase", + ); + expect(normalizeReleaseProviderIdentifier("gitHubContainerRegistry", "ghcr.io/homarr-labs/homarr:latest")).toBe( + "homarr-labs/homarr", + ); + }); + + it("keeps existing ghcr owner/name identifiers unchanged", () => { + expect(normalizeReleaseProviderIdentifier("gitHubContainerRegistry", "homarr-labs/homarr")).toBe( + "homarr-labs/homarr", + ); + }); + + it("normalizes container image identifiers for other registries", () => { + expect(normalizeReleaseProviderIdentifier("dockerHub", "postgres:17")).toBe("postgres"); + expect(normalizeReleaseProviderIdentifier("dockerHub", "docker.io/library/postgres:17")).toBe("library/postgres"); + expect(normalizeReleaseProviderIdentifier("dockerHub", "library/postgres@sha256:abc123")).toBe("library/postgres"); + expect(normalizeReleaseProviderIdentifier("linuxServerIO", "lscr.io/linuxserver/radarr:latest")).toBe( + "linuxserver/radarr", + ); + expect(normalizeReleaseProviderIdentifier("quay", "quay.io/prometheus/prometheus:v3.0.0")).toBe( + "prometheus/prometheus", + ); + }); +}); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index a560f2979..5d3597294 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -30,7 +30,6 @@ export const widgetIntegrationSupport: Partial([ - "searchCh", - "github", - "dockerHub", - "gitlab", - "npm", - "codeberg", - "linuxServerIO", - "gitHubContainerRegistry", - "quay", - "mock", -]); +export const hiddenFromOnboarding = new Set(["mock"]); export const defaultBookmarkApps = [ { diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 6e0c81039..d36be1852 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -27,7 +27,6 @@ "@ctrl/deluge": "catalog:", "@ctrl/qbittorrent": "catalog:", "@ctrl/transmission": "catalog:", - "@gitbeaker/rest": "catalog:", "@homarr/common": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0", @@ -39,7 +38,6 @@ "@homarr/validation": "workspace:^0.1.0", "@immich/sdk": "catalog:", "@jellyfin/sdk": "catalog:", - "@octokit/auth-app": "catalog:", "ical.js": "catalog:", "maria2": "catalog:", "octokit": "catalog:", diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts deleted file mode 100644 index 5f95b6d34..000000000 --- a/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { Buffer } from "node:buffer"; -import { randomUUID } from "node:crypto"; -import WebSocket from "ws"; -import type { RawData } from "ws"; -import { z } from "zod/v4"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import { - bandwidthMessageSchema, - itemStatusMessageSchema, - pipelineStartItemMessageSchema, - projectRefreshMessageSchema, - seesawFrameSchema, - warriorBroadcastMessageSchema, - warriorProjectSelectedMessageSchema, - warriorStatusMessageSchema, -} from "./archive-team-warrior-schemas"; -import type { SeesawFrame, WarriorItem, WarriorProject } from "./archive-team-warrior-schemas"; -import type { ArchiveTeamWarriorItem, ArchiveTeamWarriorStatus } from "./archive-team-warrior-types"; - -const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500; - -interface WarriorSnapshotState { - status: string; - runnerStatus?: string; - selectedProject: string | null; - broadcastMessage: string | null; - project: ArchiveTeamWarriorStatus["project"]; - bandwidth: ArchiveTeamWarriorStatus["bandwidth"]; - items: Map; -} - -export class ArchiveTeamWarriorIntegration extends Integration { - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await input.fetchAsync(this.url("/index.html"), { - headers: this.createAuthHeaders(), - }); - - if (!response.ok) return TestConnectionError.StatusResult(response); - - return { success: true }; - } - - public async getStatusAsync(): Promise { - const baseUrl = this.url("/").toString().replace(/\/$/, ""); - - await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`, { - headers: this.createAuthHeaders(), - }); - - return await this.readSockJsSnapshotAsync(baseUrl); - } - - private async readSockJsSnapshotAsync(baseUrl: string): Promise { - const snapshot: WarriorSnapshotState = { - status: "Unknown", - selectedProject: null, - broadcastMessage: null, - project: null, - bandwidth: null, - items: new Map(), - }; - - await new Promise((resolve, reject) => { - let isSettled = false; - - const socket = new WebSocket(this.createSockJsWebSocketUrl(baseUrl), { - headers: this.createAuthHeaders(), - }); - - const finish = () => { - if (isSettled) return; - - isSettled = true; - clearTimeout(timeout); - - if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { - socket.close(); - } - - resolve(); - }; - - const fail = () => { - if (isSettled) return; - - isSettled = true; - clearTimeout(timeout); - reject(new Error("Failed to connect to ArchiveTeam Warrior websocket")); - }; - - const timeout = setTimeout(finish, SOCKJS_SNAPSHOT_TIMEOUT_MS); - - socket.on("message", (data: RawData) => { - const frames = this.parseSockJsMessage(this.decodeWebSocketData(data)); - - for (const frame of frames) { - this.applyFrameToSnapshot(frame, snapshot); - } - }); - - socket.on("error", fail); - socket.on("close", finish); - }); - - return this.createStatusFromSnapshot(snapshot); - } - - private applyFrameToSnapshot(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - switch (frame.event_name) { - case "warrior.status": - this.applyWarriorStatusFrame(frame, snapshot); - break; - - case "warrior.project_selected": - this.applyProjectSelectedFrame(frame, snapshot); - break; - - case "warrior.broadcast_message": - this.applyBroadcastMessageFrame(frame, snapshot); - break; - - case "runner.status": - this.applyRunnerStatusFrame(frame, snapshot); - break; - - case "project.refresh": - this.applyProjectRefreshFrame(frame, snapshot); - break; - - case "pipeline.start_item": - this.applyPipelineStartItemFrame(frame, snapshot); - break; - - case "item.complete": - this.applyItemStatusFrame(frame, snapshot, "completed"); - break; - - case "item.fail": - this.applyItemStatusFrame(frame, snapshot, "failed"); - break; - - case "item.cancel": - this.applyItemStatusFrame(frame, snapshot, "canceled"); - break; - - case "bandwidth": - this.applyBandwidthFrame(frame, snapshot); - break; - } - } - - private applyWarriorStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = warriorStatusMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.status = this.normalizeDisplayStatus(message.data.status); - } - - private applyProjectSelectedFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = warriorProjectSelectedMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.selectedProject = message.data.project; - } - - private applyBroadcastMessageFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = warriorBroadcastMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.broadcastMessage = message.data.message; - } - - private applyRunnerStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = warriorStatusMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.runnerStatus = this.normalizeDisplayStatus(message.data.status); - } - - private applyProjectRefreshFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = projectRefreshMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.project = this.mapProject(message.data?.project); - snapshot.runnerStatus = message.data?.status - ? this.normalizeDisplayStatus(message.data.status) - : snapshot.runnerStatus; - - for (const item of message.data?.items ?? []) { - snapshot.items.set(item.id, this.mapItem(item)); - } - } - - private applyPipelineStartItemFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = pipelineStartItemMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.items.set(message.data.item.id, this.mapItem(message.data.item)); - } - - private applyItemStatusFrame( - frame: SeesawFrame, - snapshot: WarriorSnapshotState, - status: ArchiveTeamWarriorItem["status"], - ) { - const message = itemStatusMessageSchema.safeParse(frame.message); - if (!message.success) return; - - this.updateItemStatus(snapshot.items, message.data.item_id, status); - } - - private applyBandwidthFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) { - const message = bandwidthMessageSchema.safeParse(frame.message); - if (!message.success) return; - - snapshot.bandwidth = message.data; - } - - private createStatusFromSnapshot(snapshot: WarriorSnapshotState): ArchiveTeamWarriorStatus { - const itemList = Array.from(snapshot.items.values()); - - return { - status: snapshot.status, - runnerStatus: snapshot.runnerStatus, - project: snapshot.project, - selectedProject: snapshot.selectedProject, - broadcastMessage: snapshot.broadcastMessage, - bandwidth: snapshot.bandwidth, - items: itemList, - counts: { - running: itemList.filter((item) => item.status === "running").length, - completed: itemList.filter((item) => item.status === "completed").length, - failed: itemList.filter((item) => item.status === "failed").length, - canceled: itemList.filter((item) => item.status === "canceled").length, - }, - updatedAt: new Date().toISOString(), - }; - } - - private createAuthHeaders() { - const authorization = this.createBasicAuthHeader(); - - return authorization ? { Authorization: authorization } : undefined; - } - - private createBasicAuthHeader() { - if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) return undefined; - - const username = this.getSecretValue("username"); - const password = this.getSecretValue("password"); - const credentials = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); - - return `Basic ${credentials}`; - } - - private decodeWebSocketData(data: RawData) { - if (typeof data === "string") return data; - if (Buffer.isBuffer(data)) return data.toString("utf8"); - if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); - - return Buffer.from(data).toString("utf8"); - } - - private parseSockJsMessage(raw: string): SeesawFrame[] { - if (raw === "o" || raw === "h" || raw.length === 0) return []; - if (!raw.startsWith("a")) return []; - - const rawPayload = this.parseJsonSafely(raw.slice(1)); - const payload = z.array(z.string()).safeParse(rawPayload); - if (!payload.success) return []; - - return payload.data.flatMap((frame) => { - const rawFrame = this.parseJsonSafely(frame); - const result = seesawFrameSchema.safeParse(rawFrame); - - return result.success ? [result.data] : []; - }); - } - - private parseJsonSafely(value: string) { - try { - return JSON.parse(value) as unknown; - } catch { - return null; - } - } - - private createSockJsWebSocketUrl(baseUrl: string) { - const url = new URL(baseUrl); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.pathname = `/${Date.now()}/${randomUUID()}/websocket`; - return url.toString(); - } - - private mapProject(project?: WarriorProject): ArchiveTeamWarriorStatus["project"] { - if (!project) return null; - - return { - id: project.project_id?.toString(), - title: project.title ?? "Unknown Project", - }; - } - - private mapItem(item: WarriorItem): ArchiveTeamWarriorItem { - return { - id: item.id, - name: this.formatItemName(item.name), - status: this.normalizeItemStatus(item.status), - project: item.project, - startTime: item.start_time, - }; - } - - private normalizeDisplayStatus(status?: string | null) { - if (!status) return "Unknown"; - - switch (status) { - case "RUNNING_PROJECT": - return "Running"; - case "STOPPED": - return "Stopped"; - case "IDLE": - return "Idle"; - default: - return status - .replaceAll("_", " ") - .toLowerCase() - .replace(/\b\w/g, (character) => character.toUpperCase()); - } - } - - private formatItemName(name: string) { - const cleaned = name.replace(/^Item\s+/i, ""); - const parts = cleaned.split("\u0000").filter(Boolean); - - if (parts.length === 0) return "Item"; - - const first = parts[0]; - - if (!first) return "Item"; - - const label = parts.length === 1 ? first : `${first} + ${parts.length - 1} more`; - - return label.length <= 80 ? label : `${label.slice(0, 77)}...`; - } - - private normalizeItemStatus(status?: string | null): ArchiveTeamWarriorItem["status"] { - if (!status) return "running"; - - switch (status.toLowerCase()) { - case "running": - case "running_project": - return "running"; - case "completed": - return "completed"; - case "failed": - case "fail": - return "failed"; - case "canceled": - case "cancel": - return "canceled"; - default: - return "unknown"; - } - } - - private updateItemStatus( - items: Map, - itemId: string, - status: ArchiveTeamWarriorItem["status"], - ) { - const item = items.get(itemId); - if (!item) return; - - items.set(itemId, { - ...item, - status, - }); - } -} diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-schemas.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-schemas.ts deleted file mode 100644 index 3c5f47fab..000000000 --- a/packages/integrations/src/archive-team-warrior/archive-team-warrior-schemas.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from "zod/v4"; - -export const seesawFrameSchema = z.object({ - event_name: z.string(), - message: z.unknown(), -}); - -export const warriorStatusMessageSchema = z.object({ - status: z.string(), -}); - -export const warriorBroadcastMessageSchema = z.object({ - message: z.string().nullable(), -}); - -export const warriorProjectSelectedMessageSchema = z.object({ - project: z.string().nullable(), -}); - -export const warriorProjectSchema = z.object({ - project_id: z.number().optional(), - title: z.string().optional(), - project_html: z.string().optional(), - utc_deadline: z.string().nullable().optional(), -}); - -export const warriorItemSchema = z.object({ - id: z.string(), - name: z.string(), - status: z.string().nullable().optional(), - project: z.string().nullable().optional(), - start_time: z.number().optional(), -}); - -export const projectRefreshMessageSchema = z - .object({ - project: warriorProjectSchema.optional(), - status: z.string().optional(), - items: z.array(warriorItemSchema).optional(), - }) - .nullable(); - -export const itemStatusMessageSchema = z.object({ - item_id: z.string(), -}); - -export const pipelineStartItemMessageSchema = z.object({ - item: warriorItemSchema, -}); - -export const bandwidthMessageSchema = z.object({ - received: z.number().optional(), - sent: z.number().optional(), - receiving: z.number().optional(), - sending: z.number().optional(), - session_id: z.string().optional(), -}); - -export type WarriorProject = z.infer; -export type WarriorItem = z.infer; -export type SeesawFrame = z.infer; diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-types.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-types.ts deleted file mode 100644 index a21df642d..000000000 --- a/packages/integrations/src/archive-team-warrior/archive-team-warrior-types.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface ArchiveTeamWarriorItem { - id: string; - name: string; - status: "running" | "completed" | "failed" | "canceled" | "unknown"; - project?: string | null; - startTime?: number; -} - -export interface ArchiveTeamWarriorBandwidth { - received?: number; - sent?: number; - receiving?: number; - sending?: number; - session_id?: string; -} - -export interface ArchiveTeamWarriorProject { - id?: string; - title: string; -} - -export interface ArchiveTeamWarriorStatus { - status: string; - runnerStatus?: string; - project?: ArchiveTeamWarriorProject | null; - selectedProject?: string | null; - broadcastMessage?: string | null; - bandwidth?: ArchiveTeamWarriorBandwidth | null; - items: ArchiveTeamWarriorItem[]; - counts: { - running: number; - completed: number; - failed: number; - canceled: number; - }; - updatedAt: string; -} diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 13ed36537..4ef291893 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -3,12 +3,9 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { BeszelIntegration } from "../beszel/beszel-integration"; import { AnchorIntegration } from "../anchor/anchor-integration"; -import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration"; import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration"; -import { CodebergIntegration } from "../codeberg/codeberg-integration"; import { CoolifyIntegration } from "../coolify/coolify-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; -import { DockerHubIntegration } from "../docker-hub/docker-hub-integration"; import { Aria2Integration } from "../download-client/aria2/aria2-integration"; import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; @@ -17,9 +14,6 @@ import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integrati import { SlskdIntegration } from "../download-client/slskd/slskd-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { EmbyIntegration } from "../emby/emby-integration"; -import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration"; -import { GithubIntegration } from "../github/github-integration"; -import { GitlabIntegration } from "../gitlab/gitlab-integration"; import { GlancesIntegration } from "../glances/glances-integration"; import { GluetunIntegration } from "../gluetun/gluetun-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; @@ -27,7 +21,6 @@ import { ICalIntegration } from "../ical/ical-integration"; import { ImmichIntegration } from "../immich/immich-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; -import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration"; import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"; import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; @@ -36,7 +29,6 @@ import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { MockIntegration } from "../mock/mock-integration"; import { NavidromeIntegration } from "../navidrome/navidrome-integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; -import { NPMIntegration } from "../npm/npm-integration"; import { GotifyIntegration } from "../gotify/gotify-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; @@ -49,8 +41,6 @@ import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-in import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; -import { QuayIntegration } from "../quay/quay-integration"; -import { SearchChIntegration } from "../search-ch/search-ch-integration"; import { SeerrIntegration } from "../seerr/seerr-integration"; import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; import { TracearrIntegration } from "../tracearr/tracearr-integration"; @@ -114,15 +104,7 @@ export const integrationCreators = { nextcloud: NextcloudIntegration, unifiController: UnifiControllerIntegration, opnsense: OPNsenseIntegration, - github: GithubIntegration, - dockerHub: DockerHubIntegration, - gitlab: GitlabIntegration, - npm: NPMIntegration, - codeberg: CodebergIntegration, - linuxServerIO: LinuxServerIOIntegration, - gitHubContainerRegistry: GitHubContainerRegistryIntegration, ical: ICalIntegration, - quay: QuayIntegration, ntfy: NTFYIntegration, gotify: GotifyIntegration, mock: MockIntegration, @@ -131,7 +113,6 @@ export const integrationCreators = { coolify: CoolifyIntegration, tracearr: TracearrIntegration, glances: GlancesIntegration, - searchCh: SearchChIntegration, immich: ImmichIntegration, paperlessNgx: PaperlessNgxIntegration, speedtestTracker: SpeedtestTrackerIntegration, @@ -139,7 +120,6 @@ export const integrationCreators = { navidrome: NavidromeIntegration, umami: UmamiIntegration, gluetun: GluetunIntegration, - archiveTeamWarrior: ArchiveTeamWarriorIntegration, uptimeKuma: UptimeKumaIntegration, peaNut: PeaNutIntegration, beszel: BeszelIntegration, diff --git a/packages/integrations/src/codeberg/codeberg-integration.ts b/packages/integrations/src/codeberg/codeberg-integration.ts deleted file mode 100644 index 2396a81e0..000000000 --- a/packages/integrations/src/codeberg/codeberg-integration.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { RequestInit, Response } from "undici"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { - DetailsProviderResponse, - ReleaseResponse, -} from "../interfaces/releases-providers/releases-providers-types"; -import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas"; - -const logger = createLogger({ module: "codebergIntegration" }); - -export class CodebergIntegration extends Integration implements ReleasesProviderIntegration { - private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { - if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined); - - return await callback({ - Authorization: `token ${this.getSecretValue("personalAccessToken")}`, - }); - } - - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await this.withHeadersAsync(async (headers) => { - return await input.fetchAsync(this.url("/version"), { - headers, - }); - }); - - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - - return { - success: true, - }; - } - - private parseIdentifier(identifier: string) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", { - identifier, - }); - return null; - } - return { owner, name }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - const parsedIdentifier = this.parseIdentifier(identifier); - if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; - - const { owner, name } = parsedIdentifier; - - const releasesResponse = await this.withHeadersAsync(async (headers) => { - return await fetchWithTrustedCertificatesAsync( - this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`), - { headers }, - ); - }); - if (!releasesResponse.ok) { - return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; - } - - const releasesResponseJson: unknown = await releasesResponse.json(); - const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { - return { - success: false, - error: { - code: "unexpected", - message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, - }, - }; - } - - const formattedReleases = data.map((tag) => ({ - latestRelease: tag.tag_name, - latestReleaseAt: tag.published_at, - releaseUrl: tag.url, - releaseDescription: tag.body, - isPreRelease: tag.prerelease, - })); - - const latestRelease = getLatestRelease(formattedReleases, versionRegex); - if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - - const details = await this.getDetailsAsync(owner, name); - - return { success: true, data: { ...details, ...latestRelease } }; - } - - protected async getDetailsAsync(owner: string, name: string): Promise { - const response = await this.withHeadersAsync(async (headers) => { - return await fetchWithTrustedCertificatesAsync( - this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`), - { - headers, - }, - ); - }); - - if (!response.ok) { - logger.warn("Failed to get details", { - owner, - name, - error: response.statusText, - }); - - return undefined; - } - - const responseJson = await response.json(); - const { data, success, error } = detailsResponseSchema.safeParse(responseJson); - - if (!success) { - logger.warn("Failed to parse details", { - owner, - name, - error, - }); - - return undefined; - } - - return { - projectUrl: data.html_url, - projectDescription: data.description, - isFork: data.fork, - isArchived: data.archived, - createdAt: data.created_at, - starsCount: data.stars_count, - openIssues: data.open_issues_count, - forksCount: data.forks_count, - }; - } -} diff --git a/packages/integrations/src/codeberg/codeberg-schemas.ts b/packages/integrations/src/codeberg/codeberg-schemas.ts deleted file mode 100644 index ff6e62a01..000000000 --- a/packages/integrations/src/codeberg/codeberg-schemas.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "zod/v4"; - -export const releasesResponseSchema = z.array( - z.object({ - tag_name: z.string(), - published_at: z.string().transform((value) => new Date(value)), - url: z.string(), - body: z.string(), - prerelease: z.boolean(), - }), -); - -export const detailsResponseSchema = z.object({ - html_url: z.string(), - description: z.string(), - fork: z.boolean(), - archived: z.boolean(), - created_at: z.string().transform((value) => new Date(value)), - stars_count: z.number(), - open_issues_count: z.number(), - forks_count: z.number(), -}); diff --git a/packages/integrations/src/docker-hub/docker-hub-integration.ts b/packages/integrations/src/docker-hub/docker-hub-integration.ts deleted file mode 100644 index b9e9ea09c..000000000 --- a/packages/integrations/src/docker-hub/docker-hub-integration.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { fetch, RequestInit, Response } from "undici"; - -import { ResponseError } from "@homarr/common/server"; -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import type { SessionStore } from "../base/session-store"; -import { createSessionStore } from "../base/session-store"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { - DetailsProviderResponse, - ReleaseResponse, -} from "../interfaces/releases-providers/releases-providers-types"; -import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas"; - -const logger = createLogger({ module: "dockerHubIntegration" }); - -export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration { - private readonly sessionStore: SessionStore; - - constructor(integration: IntegrationInput) { - super(integration); - this.sessionStore = createSessionStore(integration); - } - - private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { - if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) - return await callback(undefined); - - const storedSession = await this.sessionStore.getAsync(); - - if (storedSession) { - logger.debug("Using stored session for request", { integrationId: this.integration.id }); - const response = await callback({ - Authorization: `Bearer ${storedSession}`, - }); - if (response.status !== 401) { - return response; - } - - logger.debug("Session expired, getting new session", { integrationId: this.integration.id }); - } - - const accessToken = await this.getSessionAsync(); - await this.sessionStore.setAsync(accessToken); - return await callback({ - Authorization: `Bearer ${accessToken}`, - }); - } - - protected async testingAsync(input: IntegrationTestingInput): Promise { - const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken"); - - if (hasAuth) { - logger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id }); - await this.getSessionAsync(input.fetchAsync); - } else { - logger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id }); - const response = await input.fetchAsync(this.url("/v2/repositories/library")); - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - } - - return { - success: true, - }; - } - - private parseIdentifier(identifier: string) { - if (!identifier.includes("/")) return { owner: "", name: identifier }; - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - logger.warn("Invalid identifier format. Expected 'owner/name' or 'name', for identifier", { - identifier, - }); - return null; - } - return { owner, name }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - const parsedIdentifier = this.parseIdentifier(identifier); - if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; - - const { owner, name } = parsedIdentifier; - - const relativeUrl: `/${string}` = owner - ? `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}` - : `/v2/repositories/library/${encodeURIComponent(name)}`; - - for (let page = 0; page <= 5; page++) { - const releasesResponse = await this.withHeadersAsync(async (headers) => { - return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100&page=${page}`), { - headers, - }); - }); - if (!releasesResponse.ok) { - return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; - } - - const releasesResponseJson: unknown = await releasesResponse.json(); - const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson); - if (!releasesResult.success) { - return { - success: false, - error: { - code: "unexpected", - message: releasesResponseJson - ? JSON.stringify(releasesResponseJson, null, 2) - : releasesResult.error.message, - }, - }; - } - - const latestRelease = getLatestRelease(releasesResult.data.results, versionRegex); - if (!latestRelease) continue; - - const details = await this.getDetailsAsync(relativeUrl); - - return { success: true, data: { ...details, ...latestRelease } }; - } - - return { success: false, error: { code: "noMatchingVersion" } }; - } - - private async getDetailsAsync(relativeUrl: `/${string}`): Promise { - const response = await this.withHeadersAsync(async (headers) => { - return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), { - headers, - }); - }); - - if (!response.ok) { - logger.warn("Failed to get details response", { - relativeUrl, - error: response.statusText, - }); - - return undefined; - } - - const responseJson = await response.json(); - const { data, success, error } = detailsResponseSchema.safeParse(responseJson); - - if (!success) { - logger.warn("Failed to parse details response", { - relativeUrl, - error, - }); - - return undefined; - } - - return { - projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`, - projectDescription: data.description, - createdAt: data.date_registered, - starsCount: data.star_count, - }; - } - - private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise { - const response = await fetchAsync(this.url("/v2/auth/token"), { - method: "POST", - body: JSON.stringify({ - identifier: this.getSecretValue("username"), - secret: this.getSecretValue("personalAccessToken"), - }), - }); - - if (!response.ok) throw new ResponseError(response); - - const data = await response.json(); - const result = await accessTokenResponseSchema.parseAsync(data); - - if (!result.access_token) { - throw new ResponseError({ status: 401, url: response.url }); - } - - logger.info("Received session successfully", { integrationId: this.integration.id }); - - return result.access_token; - } -} diff --git a/packages/integrations/src/docker-hub/docker-hub-schemas.ts b/packages/integrations/src/docker-hub/docker-hub-schemas.ts deleted file mode 100644 index 674ae5532..000000000 --- a/packages/integrations/src/docker-hub/docker-hub-schemas.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "zod/v4"; - -export const accessTokenResponseSchema = z.object({ - access_token: z.string(), -}); - -export const releasesResponseSchema = z.object({ - results: z.array( - z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({ - latestRelease: tag.name, - latestReleaseAt: tag.last_updated, - })), - ), -}); - -export const detailsResponseSchema = z.object({ - name: z.string(), - namespace: z.string(), - description: z.string(), - star_count: z.number(), - date_registered: z.string().transform((value) => new Date(value)), -}); diff --git a/packages/integrations/src/github-container-registry/github-container-registry-integration.ts b/packages/integrations/src/github-container-registry/github-container-registry-integration.ts deleted file mode 100644 index 1400551a8..000000000 --- a/packages/integrations/src/github-container-registry/github-container-registry-integration.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { Octokit, RequestError } from "octokit"; -import type { fetch } from "undici"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import { HandleIntegrationErrors } from "../base/errors/decorator"; -import { integrationOctokitHttpErrorHandler } from "../base/errors/http"; -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { - DetailsProviderResponse, - ReleaseProviderResponse, - ReleaseResponse, -} from "../interfaces/releases-providers/releases-providers-types"; - -const logger = createLogger({ module: "githubContainerRegistryIntegration" }); - -@HandleIntegrationErrors([integrationOctokitHttpErrorHandler]) -export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration { - private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration"; - - protected async testingAsync(input: IntegrationTestingInput): Promise { - const api = this.getApi(input.fetchAsync); - - if (this.hasSecretValue("personalAccessToken")) { - await api.rest.users.getAuthenticated(); - } else if (this.hasSecretValue("githubAppId")) { - await api.rest.apps.getInstallation({ - installation_id: Number(this.getSecretValue("githubInstallationId")), - }); - } else { - await api.request("GET /octocat"); - } - - return { - success: true, - }; - } - - private parseIdentifier(identifier: string) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", { identifier }); - return null; - } - return { owner, name }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - const parsedIdentifier = this.parseIdentifier(identifier); - if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; - - const { owner, name } = parsedIdentifier; - const api = this.getApi(); - - try { - const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({ - username: owner, - package_type: "container", - package_name: name, - per_page: 100, - }); - - const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => { - if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc; - - release.metadata.container.tags.forEach((tag) => { - acc.push({ - latestRelease: tag, - latestReleaseAt: new Date(release.updated_at), - releaseUrl: release.html_url, - releaseDescription: release.description ?? undefined, - }); - }); - return acc; - }, []); - - const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); - if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - - const details = await this.getDetailsAsync(api, owner, name); - - return { success: true, data: { ...details, ...latestRelease } }; - } catch (error) { - const errorMessage = error instanceof RequestError ? error.message : String(error); - logger.warn("Failed to get releases", { - owner, - name, - error: errorMessage, - }); - return { success: false, error: { code: "unexpected", message: errorMessage } }; - } - } - - protected async getDetailsAsync( - api: Octokit, - owner: string, - name: string, - ): Promise { - try { - const response = await api.rest.packages.getPackageForUser({ - username: owner, - package_type: "container", - package_name: name, - }); - - return { - projectUrl: response.data.repository?.html_url ?? response.data.html_url, - projectDescription: response.data.repository?.description ?? undefined, - isFork: response.data.repository?.fork, - isArchived: response.data.repository?.archived, - createdAt: new Date(response.data.created_at), - starsCount: response.data.repository?.stargazers_count, - openIssues: response.data.repository?.open_issues_count, - forksCount: response.data.repository?.forks_count, - }; - } catch (error) { - logger.warn("Failed to get details", { - owner, - name, - error: error instanceof RequestError ? error.message : String(error), - }); - return undefined; - } - } - - private getAuthProperties(): Pick { - if (this.hasSecretValue("personalAccessToken")) - return { - auth: this.getSecretValue("personalAccessToken"), - }; - - if (this.hasSecretValue("githubAppId")) - return { - authStrategy: createAppAuth, - auth: { - appId: this.getSecretValue("githubAppId"), - installationId: this.getSecretValue("githubInstallationId"), - privateKey: this.getSecretValue("privateKey"), - } satisfies Parameters[0], - }; - - return {}; - } - - private getApi(customFetch?: typeof fetch) { - return new Octokit({ - baseUrl: this.url("/").origin, - request: { - fetch: customFetch ?? fetchWithTrustedCertificatesAsync, - }, - userAgent: GitHubContainerRegistryIntegration.userAgent, - // Disable throttling for this integration, Octokit will retry by default after a set time, - // thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request. - throttle: { enabled: false }, - ...this.getAuthProperties(), - }); - } -} - -type OctokitOptions = Exclude[0], undefined>; diff --git a/packages/integrations/src/github/github-integration.ts b/packages/integrations/src/github/github-integration.ts deleted file mode 100644 index 01548ecc6..000000000 --- a/packages/integrations/src/github/github-integration.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { Octokit, RequestError as OctokitRequestError } from "octokit"; -import type { fetch } from "undici"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import { HandleIntegrationErrors } from "../base/errors/decorator"; -import { integrationOctokitHttpErrorHandler } from "../base/errors/http"; -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { - DetailsProviderResponse, - ReleaseProviderResponse, - ReleaseResponse, -} from "../interfaces/releases-providers/releases-providers-types"; - -const logger = createLogger({ module: "githubIntegration" }); - -@HandleIntegrationErrors([integrationOctokitHttpErrorHandler]) -export class GithubIntegration extends Integration implements ReleasesProviderIntegration { - private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration"; - - protected async testingAsync(input: IntegrationTestingInput): Promise { - const api = this.getApi(input.fetchAsync); - - if (this.hasSecretValue("personalAccessToken")) { - await api.rest.users.getAuthenticated(); - } else if (this.hasSecretValue("githubAppId")) { - await api.rest.apps.getInstallation({ - installation_id: Number(this.getSecretValue("githubInstallationId")), - }); - } else { - await api.request("GET /octocat"); - } - - return { - success: true, - }; - } - - private parseIdentifier(identifier: string) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", { - identifier, - }); - return null; - } - return { owner, name }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - const parsedIdentifier = this.parseIdentifier(identifier); - if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; - - const { owner, name } = parsedIdentifier; - const api = this.getApi(); - - try { - const releasesResponse = await api.rest.repos.listReleases({ owner, repo: name }); - - if (releasesResponse.data.length === 0) { - logger.warn("No releases found", { - identifier: `${owner}/${name}`, - }); - return { success: false, error: { code: "noMatchingVersion" } }; - } - - const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => { - if (!release.published_at) return acc; - - acc.push({ - latestRelease: release.tag_name, - latestReleaseAt: new Date(release.published_at), - releaseUrl: release.html_url, - releaseDescription: release.body ?? undefined, - isPreRelease: release.prerelease, - }); - return acc; - }, []); - - const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); - if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - - const details = await this.getDetailsAsync(api, owner, name); - - return { success: true, data: { ...details, ...latestRelease } }; - } catch (error) { - const errorMessage = error instanceof OctokitRequestError ? error.message : String(error); - logger.warn("Failed to get releases", { - owner, - name, - error: errorMessage, - }); - return { success: false, error: { code: "unexpected", message: errorMessage } }; - } - } - - protected async getDetailsAsync( - api: Octokit, - owner: string, - name: string, - ): Promise { - try { - const response = await api.rest.repos.get({ - owner, - repo: name, - }); - - return { - projectUrl: response.data.html_url, - projectDescription: response.data.description ?? undefined, - isFork: response.data.fork, - isArchived: response.data.archived, - createdAt: new Date(response.data.created_at), - starsCount: response.data.stargazers_count, - openIssues: response.data.open_issues_count, - forksCount: response.data.forks_count, - }; - } catch (error) { - logger.warn("Failed to get details", { - owner, - name, - error: error instanceof OctokitRequestError ? error.message : String(error), - }); - return undefined; - } - } - - private getAuthProperties(): Pick { - if (this.hasSecretValue("personalAccessToken")) - return { - auth: this.getSecretValue("personalAccessToken"), - }; - - if (this.hasSecretValue("githubAppId")) - return { - authStrategy: createAppAuth, - auth: { - appId: this.getSecretValue("githubAppId"), - installationId: this.getSecretValue("githubInstallationId"), - privateKey: this.getSecretValue("privateKey"), - } satisfies Parameters[0], - }; - - return {}; - } - - private getApi(customFetch?: typeof fetch) { - return new Octokit({ - baseUrl: this.url("/").origin, - request: { - fetch: customFetch ?? fetchWithTrustedCertificatesAsync, - }, - userAgent: GithubIntegration.userAgent, - // Disable throttling for this integration, Octokit will retry by default after a set time, - // thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request. - throttle: { enabled: false }, - ...this.getAuthProperties(), - }); - } -} - -type OctokitOptions = Exclude[0], undefined>; diff --git a/packages/integrations/src/gitlab/gitlab-integration.ts b/packages/integrations/src/gitlab/gitlab-integration.ts deleted file mode 100644 index fd1ebc926..000000000 --- a/packages/integrations/src/gitlab/gitlab-integration.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Gitlab as CoreGitlab } from "@gitbeaker/core"; -import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils"; -import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils"; -import { Gitlab } from "@gitbeaker/rest"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { - DetailsProviderResponse, - ReleaseProviderResponse, - ReleaseResponse, -} from "../interfaces/releases-providers/releases-providers-types"; - -const logger = createLogger({ module: "gitlabIntegration" }); - -export class GitlabIntegration extends Integration implements ReleasesProviderIntegration { - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await input.fetchAsync(this.url("/api/v4/projects"), { - headers: { - ...(this.hasSecretValue("personalAccessToken") - ? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` } - : {}), - }, - }); - - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - - return { - success: true, - }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - const api = this.getApi(); - - try { - const releasesResponse = await api.ProjectReleases.all(identifier, { - perPage: 100, - }); - - if (releasesResponse instanceof Error) { - logger.warn("No releases found", { - identifier, - error: releasesResponse.message, - }); - return { success: false, error: { code: "noReleasesFound" } }; - } - - const releasesProviderResponse = releasesResponse.reduce((acc, release) => { - if (!release.released_at) return acc; - - const releaseDate = new Date(release.released_at); - - acc.push({ - latestRelease: release.name ?? release.tag_name, - latestReleaseAt: releaseDate, - releaseUrl: release._links.self, - releaseDescription: release.description ?? undefined, - isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730) - }); - return acc; - }, []); - - const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); - if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - - const details = await this.getDetailsAsync(api, identifier); - - return { success: true, data: { ...details, ...latestRelease } }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.warn("Failed to get releases", { - identifier, - error: errorMessage, - }); - return { success: false, error: { code: "unexpected", message: errorMessage } }; - } - } - - protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise { - try { - const response = await api.Projects.show(identifier); - - if (response instanceof Error) { - logger.warn("Failed to get details", { - identifier, - error: response.message, - }); - - return undefined; - } - - if (!response.web_url) { - logger.warn("No web URL found", { - identifier, - }); - return undefined; - } - - return { - projectUrl: response.web_url, - projectDescription: response.description ?? undefined, - isFork: response.forked_from_project !== null, - isArchived: response.archived, - createdAt: new Date(response.created_at), - starsCount: response.star_count, - openIssues: response.open_issues_count, - forksCount: response.forks_count, - }; - } catch (error) { - logger.warn("Failed to get details", { - identifier, - error: error instanceof Error ? error.message : String(error), - }); - - return undefined; - } - } - - private getApi() { - return new Gitlab({ - host: this.url("/").origin, - requesterFn: createRequesterFn( - async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions), - async (endpoint: string, options?: Record): Promise => { - if (options === undefined) { - throw new Error("Gitlab library is not configured correctly. Options must be provided."); - } - - const response = await fetchWithTrustedCertificatesAsync( - `${options.prefixUrl as string}${endpoint}`, - options, - ); - const headers = Object.fromEntries(response.headers.entries()); - - return { - status: response.status, - headers, - body: await response.json(), - } as FormattedResponse; - }, - ), - ...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}), - }); - } -} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 66cf4be51..73fc3fbd1 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,6 +1,5 @@ // General integrations export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; -export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { AnchorIntegration } from "./anchor/anchor-integration"; export { Aria2Integration } from "./download-client/aria2/aria2-integration"; @@ -64,7 +63,6 @@ export type { TdarrStatistics, TdarrWorker, } from "./interfaces/media-transcoding/media-transcoding-types"; -export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration"; export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types"; @@ -75,11 +73,6 @@ export type { AnchorNotesListInput, AnchorNoteSummary, } from "./anchor/anchor-types"; -export type { - ArchiveTeamWarriorBandwidth, - ArchiveTeamWarriorItem, - ArchiveTeamWarriorStatus, -} from "./archive-team-warrior/archive-team-warrior-types"; export type { TracearrDashboardData } from "./tracearr/tracearr-types"; export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types"; export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types"; diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts deleted file mode 100644 index e94f555fc..000000000 --- a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ReleaseProviderResponse, ReleaseResponse } from "./releases-providers-types"; - -export const getLatestRelease = ( - releases: ReleaseProviderResponse[], - versionRegex?: string, -): ReleaseProviderResponse | null => { - const validReleases = releases.filter((result) => { - if (result.latestRelease) { - return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true; - } - return true; - }); - - return validReleases.length === 0 - ? null - : validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest)); -}; - -export interface ReleasesProviderIntegration { - getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise; -} diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts deleted file mode 100644 index fb89b0fcb..000000000 --- a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { TranslationObject } from "@homarr/translation"; - -export interface ReleasesRepository extends Record { - id: string; - identifier: string; - versionRegex?: string; -} - -export interface DetailsProviderResponse { - projectUrl?: string; - projectDescription?: string; - isFork?: boolean; - isArchived?: boolean; - createdAt?: Date; - starsCount?: number; - openIssues?: number; - forksCount?: number; -} - -export interface ReleaseProviderResponse { - latestRelease: string; - latestReleaseAt: Date; - releaseUrl?: string; - releaseDescription?: string; - isPreRelease?: boolean; -} - -type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"]; - -export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse; - -export type ReleaseError = { code: ReleasesErrorKeys } | { code: "unexpected"; message: string }; - -export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError }; diff --git a/packages/integrations/src/interfaces/timetable/timetable-integration.ts b/packages/integrations/src/interfaces/timetable/timetable-integration.ts deleted file mode 100644 index 74e9d3c02..000000000 --- a/packages/integrations/src/interfaces/timetable/timetable-integration.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Station, Timetable, TimetableOptions } from "./timetable-types"; - -export interface TimetableIntegration { - searchStationsAsync(query: string): Promise; - getTimetableAsync(options: TimetableOptions): Promise; -} diff --git a/packages/integrations/src/interfaces/timetable/timetable-types.ts b/packages/integrations/src/interfaces/timetable/timetable-types.ts deleted file mode 100644 index 72350b5a3..000000000 --- a/packages/integrations/src/interfaces/timetable/timetable-types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import z from "zod"; - -export interface Station { - id: string; - name: string; -} - -export const timetableOptionsSchema = z.object({ - stationId: z.string(), - limit: z.number().default(10), -}); - -export type TimetableOptions = z.infer; - -export interface TimetableEntry { - timestamp: Date; - delay: number; - line: { - name: string; - color: string | null; - } | null; - location: string; - platform: { - name: string; - hasChanged: boolean; - } | null; -} - -export interface Timetable { - stationId: string; - timestamp: Date; - entries: TimetableEntry[]; -} diff --git a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts b/packages/integrations/src/linuxserverio/linuxserverio-integration.ts deleted file mode 100644 index 3178f2c4b..000000000 --- a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types"; -import { releasesResponseSchema } from "./linuxserverio-schemas"; - -const logger = createLogger({ module: "linuxServerIOIntegration" }); - -export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration { - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await input.fetchAsync(this.url("/health")); - - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - - return { - success: true, - }; - } - - private parseIdentifier(identifier: string) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", { identifier }); - return null; - } - return { owner, name }; - } - - public async getLatestMatchingReleaseAsync(identifier: string): Promise { - const { name } = this.parseIdentifier(identifier) ?? {}; - if (!name) return { success: false, error: { code: "invalidIdentifier" } }; - - const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images")); - if (!releasesResponse.ok) { - return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; - } - - const releasesResponseJson: unknown = await releasesResponse.json(); - const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { - return { success: false, error: { code: "unexpected", message: error.message } }; - } - - const release = data.data.repositories.linuxserver.find((repo) => repo.name === name); - if (!release) { - logger.warn("Repository not found on provider", { - name, - }); - return { success: false, error: { code: "noMatchingVersion" } }; - } - - return { - success: true, - data: { - latestRelease: release.version, - latestReleaseAt: release.version_timestamp, - releaseDescription: release.changelog?.shift()?.desc, - projectUrl: release.github_url, - projectDescription: release.description, - isArchived: release.deprecated, - createdAt: release.initial_date ? new Date(release.initial_date) : undefined, - starsCount: release.stars, - }, - }; - } -} diff --git a/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts b/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts deleted file mode 100644 index 3e3653697..000000000 --- a/packages/integrations/src/linuxserverio/linuxserverio-schemas.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod/v4"; - -export const releasesResponseSchema = z.object({ - data: z.object({ - repositories: z.object({ - linuxserver: z.array( - z.object({ - name: z.string(), - initial_date: z - .string() - .transform((value) => new Date(value)) - .optional(), - github_url: z.string(), - description: z.string(), - version: z.string(), - version_timestamp: z.string().transform((value) => new Date(value)), - stars: z.number(), - deprecated: z.boolean(), - changelog: z - .array( - z.object({ - date: z.string().transform((value) => new Date(value)), - desc: z.string(), - }), - ) - .optional(), - }), - ), - }), - }), -}); diff --git a/packages/integrations/src/npm/npm-integration.ts b/packages/integrations/src/npm/npm-integration.ts deleted file mode 100644 index b0c8c050b..000000000 --- a/packages/integrations/src/npm/npm-integration.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types"; -import { releasesResponseSchema } from "./npm-schemas"; - -export class NPMIntegration extends Integration implements ReleasesProviderIntegration { - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await input.fetchAsync(this.url("/")); - - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - - return { - success: true, - }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - if (!identifier) return { success: false, error: { code: "invalidIdentifier" } }; - - const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url(`/${encodeURIComponent(identifier)}`)); - if (!releasesResponse.ok) { - return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; - } - - const releasesResponseJson: unknown = await releasesResponse.json(); - const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { - return { - success: false, - error: { - code: "unexpected", - message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, - }, - }; - } - - const formattedReleases = data.time.map((tag) => ({ - ...tag, - releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`, - releaseDescription: data.versions[tag.latestRelease]?.description ?? "", - })); - - const latestRelease = getLatestRelease(formattedReleases, versionRegex); - if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - - return { success: true, data: latestRelease }; - } -} diff --git a/packages/integrations/src/npm/npm-schemas.ts b/packages/integrations/src/npm/npm-schemas.ts deleted file mode 100644 index 5d21734e2..000000000 --- a/packages/integrations/src/npm/npm-schemas.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod/v4"; - -export const releasesResponseSchema = z.object({ - time: z - .record( - z.string(), - z.string().transform((value) => new Date(value)), - ) - .transform((version) => - Object.entries(version).map(([key, value]) => ({ - latestRelease: key, - latestReleaseAt: value, - })), - ), - versions: z.record(z.string(), z.object({ description: z.string() })), - name: z.string(), -}); diff --git a/packages/integrations/src/quay/quay-integration.ts b/packages/integrations/src/quay/quay-integration.ts deleted file mode 100644 index 21cbf797e..000000000 --- a/packages/integrations/src/quay/quay-integration.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { RequestInit, Response } from "undici"; - -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { createLogger } from "@homarr/core/infrastructure/logs"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { - ReleaseProviderResponse, - ReleaseResponse, -} from "../interfaces/releases-providers/releases-providers-types"; -import { releasesResponseSchema } from "./quay-schemas"; - -const logger = createLogger({ module: "quayIntegration" }); - -export class QuayIntegration extends Integration implements ReleasesProviderIntegration { - private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise): Promise { - if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined); - - return await callback({ - Authorization: `token ${this.getSecretValue("personalAccessToken")}`, - }); - } - - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await this.withHeadersAsync(async (headers) => { - return await input.fetchAsync(this.url("/api/v1/discovery"), { - headers, - }); - }); - - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - - return { - success: true, - }; - } - - private parseIdentifier(identifier: string) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", { - identifier, - }); - return null; - } - return { owner, name }; - } - - public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { - const parsedIdentifier = this.parseIdentifier(identifier); - if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; - - const { owner, name } = parsedIdentifier; - - const releasesResponse = await this.withHeadersAsync(async (headers) => { - return await fetchWithTrustedCertificatesAsync( - this.url( - `/api/v1/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?includeTags=true&includeStats=true`, - ), - { - headers, - }, - ); - }); - if (!releasesResponse.ok) { - return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; - } - - const releasesResponseJson: unknown = await releasesResponse.json(); - const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { - return { success: false, error: { code: "unexpected", message: error.message } }; - } - - const releasesProviderResponse = Object.entries(data.tags).reduce((acc, [_, tag]) => { - if (!tag.name || !tag.last_modified) return acc; - acc.push({ - latestRelease: tag.name, - latestReleaseAt: new Date(tag.last_modified), - releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`, - }); - return acc; - }, []); - - const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); - if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - - return { success: true, data: { projectDescription: data.description, ...latestRelease } }; - } -} diff --git a/packages/integrations/src/quay/quay-schemas.ts b/packages/integrations/src/quay/quay-schemas.ts deleted file mode 100644 index e24fe2d83..000000000 --- a/packages/integrations/src/quay/quay-schemas.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from "zod/v4"; - -export const releasesResponseSchema = z.object({ - description: z.string().optional(), - tags: z.record( - z.string(), - z.object({ - name: z.string(), - last_modified: z.string(), - }), - ), -}); diff --git a/packages/integrations/src/search-ch/search-ch-integration.ts b/packages/integrations/src/search-ch/search-ch-integration.ts deleted file mode 100644 index cbaf8e80d..000000000 --- a/packages/integrations/src/search-ch/search-ch-integration.ts +++ /dev/null @@ -1,141 +0,0 @@ -import z from "zod"; - -import { ResponseError } from "@homarr/common/server"; -import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; - -import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; -import type { TimetableIntegration } from "../interfaces/timetable/timetable-integration"; -import type { Station, Timetable, TimetableOptions } from "../interfaces/timetable/timetable-types"; - -const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; - -export class SearchChIntegration extends Integration implements TimetableIntegration { - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await input.fetchAsync(this.url("/timetable/api/completion.json", { term: "Zürich" })); - - if (!response.ok) { - return TestConnectionError.StatusResult(response); - } - - return { - success: true, - }; - } - - /** - * Searches for stations matching the given query. - * @param query The search query for station names. - * @returns A list of stations matching the search query. - * @docs https://search.ch/fahrplan/api/help - */ - public async searchStationsAsync(query: string): Promise { - const response = await fetchWithTrustedCertificatesAsync( - this.url("/timetable/api/completion.json", { - term: query, - show_ids: 1, - nofavorites: 1, - }), - ); - - if (!response.ok) { - throw new ResponseError(response); - } - - const data = await searchSchema.parseAsync(await response.json()); - - return ( - data - // Remove completions like addresses, we only want stations. - .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) - .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) - .filter((item) => item !== null) - ); - } - - /** - * Gets the departure timetable for a given station. - * @param options Timetable options including station ID and limit. - * @returns timetable for the given station. - * @docs https://search.ch/fahrplan/api/help - */ - public async getTimetableAsync(options: TimetableOptions): Promise { - const now = new Date(); - // The following format is used [01.01.2000, 23:59:00]. The API allows usage of DD.MM.YYYY format and HH:mm format - const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string]; - - const response = await fetchWithTrustedCertificatesAsync( - this.url("/timetable/api/stationboard.json", { - stop: options.stationId, - limit: options.limit, - show_delays: 1, - show_tracks: 1, - date, - time: time.substring(0, 5), // Only keep the HH:mm part of the time, as that's what the API expects - }), - ); - - if (!response.ok) { - throw new ResponseError(response); - } - - const data = await timetableSchema.parseAsync(await response.json()); - - return { - stationId: options.stationId, - timestamp: now, - entries: data.connections.map((connection) => { - // Color is in format A35~F10~ for foreground and background color. Some only have ~~ and therefore no color - const color = connection.color.split("~")[0]; - - return { - timestamp: connection.time, - line: connection.line - ? { - name: connection.line, - color: color && color.length >= 1 ? `#${color}` : null, - } - : null, - location: connection.terminal.name, - delay: connection.dep_delay, - platform: connection.track - ? { - // If the track name contains "!", it indicates a change in the track - // so we remove the "!" from the name and set hasChanged to true - name: connection.track.replace("!", ""), - hasChanged: connection.track.includes("!"), - } - : null, - }; - }), - }; - } -} - -const timetableSchema = z.object({ - connections: z.array( - z.object({ - time: z.string().transform((str) => new Date(str)), - line: z.string().optional(), - color: z.string(), - terminal: z.object({ - name: z.string(), - }), - track: z.string().optional(), - dep_delay: z - .string() - .optional() - .transform((str) => (str ? parseInt(str) : 0)), - }), - ), -}); - -const searchSchema = z.array( - z.object({ - id: z.string().optional(), - label: z.string(), - iconclass: z.string(), // This is in format sl-icon-type-{type} - }), -); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index afeaf1db2..273b5b2d2 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -15,7 +15,6 @@ export * from "./opnsense/opnsense-types"; export * from "./interfaces/media-releases"; export * from "./coolify/coolify-types"; export * from "./anchor/anchor-types"; -export * from "./interfaces/timetable/timetable-types"; export * from "./tracearr/tracearr-types"; export * from "./speedtest-tracker/speedtest-tracker-types"; export * from "./uptime-kuma/uptime-kuma-types"; diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json index 29609d87d..e98e0753c 100644 --- a/packages/request-handler/package.json +++ b/packages/request-handler/package.json @@ -33,10 +33,12 @@ "octokit": "catalog:", "superjson": "catalog:", "undici": "catalog:", + "ws": "catalog:", "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", + "@types/ws": "catalog:", "typescript": "catalog:" } } diff --git a/packages/request-handler/src/archive-team-warrior.ts b/packages/request-handler/src/archive-team-warrior.ts index 4038ec9dc..23a2118c5 100644 --- a/packages/request-handler/src/archive-team-warrior.ts +++ b/packages/request-handler/src/archive-team-warrior.ts @@ -1,20 +1,331 @@ +import { randomUUID } from "node:crypto"; +import { Buffer } from "node:buffer"; import dayjs from "dayjs"; +import WebSocket from "ws"; +import type { RawData } from "ws"; +import { z } from "zod/v4"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; -import type { ArchiveTeamWarriorStatus } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createWidgetOptionsChannel } from "@homarr/redis"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createCachedRequestHandler } from "./lib/cached-request-handler"; -export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler< - ArchiveTeamWarriorStatus, - IntegrationKindByCategory<"archiving">, - Record ->({ - async requestAsync(integration) { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getStatusAsync(); - }, - cacheDuration: dayjs.duration(5, "minute"), - queryKey: "archiveTeamWarriorStatus", +const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500; + +export interface ArchiveTeamWarriorItem { + id: string; + name: string; + status: "running" | "completed" | "failed" | "canceled" | "unknown"; + project?: string | null; + startTime?: number; +} + +export interface ArchiveTeamWarriorBandwidth { + received?: number; + sent?: number; + receiving?: number; + sending?: number; + session_id?: string; +} + +export interface ArchiveTeamWarriorProject { + id?: string; + title: string; +} + +export interface ArchiveTeamWarriorStatus { + status: string; + runnerStatus?: string; + project?: ArchiveTeamWarriorProject | null; + selectedProject: string | null; + broadcastMessage: string | null; + bandwidth?: ArchiveTeamWarriorBandwidth | null; + items: ArchiveTeamWarriorItem[]; + counts: { running: number; completed: number; failed: number; canceled: number }; + updatedAt: string; +} + +interface WarriorSnapshotState { + status: string; + runnerStatus?: string; + selectedProject: string | null; + broadcastMessage: string | null; + project: ArchiveTeamWarriorStatus["project"]; + bandwidth: ArchiveTeamWarriorStatus["bandwidth"]; + items: Map; +} + +export const archiveTeamWarriorRequestHandler = { + handler: (itemOptions: { url: string }) => + createCachedRequestHandler({ + async requestAsync(input) { + return await getStatusAsync(input.url); + }, + cacheDuration: dayjs.duration(5, "minute"), + queryKey: "archiveTeamWarriorStatus", + createRedisChannel(input, handlerOptions) { + return createWidgetOptionsChannel( + "archiveTeamWarrior", + handlerOptions.queryKey, + input, + ); + }, + }).handler(itemOptions), +}; + +const getStatusAsync = async (url: string): Promise => { + const baseUrl = url.replace(/\/$/, ""); + await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`); + return await readSockJsSnapshotAsync(baseUrl); +}; + +const readSockJsSnapshotAsync = async (baseUrl: string): Promise => { + const snapshot: WarriorSnapshotState = { + status: "Unknown", + selectedProject: null, + broadcastMessage: null, + project: null, + bandwidth: null, + items: new Map(), + }; + + await new Promise((resolve, reject) => { + let isSettled = false; + const socket = new WebSocket(createSockJsWebSocketUrl(baseUrl)); + + const finish = () => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeout); + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close(); + resolve(); + }; + + const fail = () => { + if (isSettled) return; + isSettled = true; + clearTimeout(timeout); + reject(new Error("Failed to connect to ArchiveTeam Warrior websocket")); + }; + + const timeout = setTimeout(finish, SOCKJS_SNAPSHOT_TIMEOUT_MS); + socket.on("message", (data: RawData) => { + for (const frame of parseSockJsMessage(decodeWebSocketData(data))) { + applyFrameToSnapshot(frame, snapshot); + } + }); + socket.on("error", fail); + socket.on("close", finish); + }); + + return createStatusFromSnapshot(snapshot); +}; + +const applyFrameToSnapshot = (frame: SeesawFrame, snapshot: WarriorSnapshotState) => { + switch (frame.event_name) { + case "warrior.status": + case "runner.status": { + const message = warriorStatusMessageSchema.safeParse(frame.message); + if (!message.success) return; + if (frame.event_name === "warrior.status") snapshot.status = normalizeDisplayStatus(message.data.status); + else snapshot.runnerStatus = normalizeDisplayStatus(message.data.status); + return; + } + case "warrior.project_selected": { + const message = warriorProjectSelectedMessageSchema.safeParse(frame.message); + if (message.success) snapshot.selectedProject = message.data.project; + return; + } + case "warrior.broadcast_message": { + const message = warriorBroadcastMessageSchema.safeParse(frame.message); + if (message.success) snapshot.broadcastMessage = message.data.message; + return; + } + case "project.refresh": { + const message = projectRefreshMessageSchema.safeParse(frame.message); + if (!message.success) return; + snapshot.project = mapProject(message.data?.project); + snapshot.runnerStatus = message.data?.status ? normalizeDisplayStatus(message.data.status) : snapshot.runnerStatus; + for (const item of message.data?.items ?? []) snapshot.items.set(item.id, mapItem(item)); + return; + } + case "pipeline.start_item": { + const message = pipelineStartItemMessageSchema.safeParse(frame.message); + if (message.success) snapshot.items.set(message.data.item.id, mapItem(message.data.item)); + return; + } + case "item.complete": + updateItemStatus(frame, snapshot, "completed"); + return; + case "item.fail": + updateItemStatus(frame, snapshot, "failed"); + return; + case "item.cancel": + updateItemStatus(frame, snapshot, "canceled"); + return; + case "bandwidth": { + const message = bandwidthMessageSchema.safeParse(frame.message); + if (message.success) snapshot.bandwidth = message.data; + } + } +}; + +const updateItemStatus = ( + frame: SeesawFrame, + snapshot: WarriorSnapshotState, + status: ArchiveTeamWarriorItem["status"], +) => { + const message = itemStatusMessageSchema.safeParse(frame.message); + if (!message.success) return; + const item = snapshot.items.get(message.data.item_id); + if (item) snapshot.items.set(message.data.item_id, { ...item, status }); +}; + +const createStatusFromSnapshot = (snapshot: WarriorSnapshotState): ArchiveTeamWarriorStatus => { + const itemList = Array.from(snapshot.items.values()); + return { + status: snapshot.status, + runnerStatus: snapshot.runnerStatus, + project: snapshot.project, + selectedProject: snapshot.selectedProject, + broadcastMessage: snapshot.broadcastMessage, + bandwidth: snapshot.bandwidth, + items: itemList, + counts: { + running: itemList.filter((item) => item.status === "running").length, + completed: itemList.filter((item) => item.status === "completed").length, + failed: itemList.filter((item) => item.status === "failed").length, + canceled: itemList.filter((item) => item.status === "canceled").length, + }, + updatedAt: new Date().toISOString(), + }; +}; + +const decodeWebSocketData = (data: RawData) => { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return Buffer.from(data).toString("utf8"); +}; + +const parseSockJsMessage = (raw: string): SeesawFrame[] => { + if (raw === "o" || raw === "h" || raw.length === 0) return []; + if (!raw.startsWith("a")) return []; + const payload = z.array(z.string()).safeParse(parseJsonSafely(raw.slice(1))); + if (!payload.success) return []; + return payload.data.flatMap((frame) => { + const result = seesawFrameSchema.safeParse(parseJsonSafely(frame)); + return result.success ? [result.data] : []; + }); +}; + +const parseJsonSafely = (value: string) => { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +}; + +const createSockJsWebSocketUrl = (baseUrl: string) => { + const url = new URL(baseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = `/${Date.now()}/${randomUUID()}/websocket`; + return url.toString(); +}; + +const mapProject = (project?: WarriorProject): ArchiveTeamWarriorStatus["project"] => { + if (!project) return null; + return { id: project.project_id?.toString(), title: project.title ?? "Unknown Project" }; +}; + +const mapItem = (item: WarriorItem): ArchiveTeamWarriorItem => ({ + id: item.id, + name: formatItemName(item.name), + status: normalizeItemStatus(item.status), + project: item.project, + startTime: item.start_time, }); + +const normalizeDisplayStatus = (status?: string | null) => { + if (!status) return "Unknown"; + switch (status) { + case "RUNNING_PROJECT": + return "Running"; + case "STOPPED": + return "Stopped"; + case "IDLE": + return "Idle"; + default: + return status + .replaceAll("_", " ") + .toLowerCase() + .replace(/\b\w/g, (character) => character.toUpperCase()); + } +}; + +const formatItemName = (name: string) => { + const parts = name.replace(/^Item\s+/i, "").split("\u0000").filter(Boolean); + const first = parts[0]; + if (!first) return "Item"; + const label = parts.length === 1 ? first : `${first} + ${parts.length - 1} more`; + return label.length <= 80 ? label : `${label.slice(0, 77)}...`; +}; + +const normalizeItemStatus = (status?: string | null): ArchiveTeamWarriorItem["status"] => { + switch (status?.toLowerCase()) { + case "running": + case "running_project": + return "running"; + case "completed": + return "completed"; + case "failed": + case "fail": + return "failed"; + case "canceled": + case "cancel": + return "canceled"; + case undefined: + return "running"; + default: + return "unknown"; + } +}; + +const seesawFrameSchema = z.object({ event_name: z.string(), message: z.unknown() }); +const warriorStatusMessageSchema = z.object({ status: z.string() }); +const warriorBroadcastMessageSchema = z.object({ message: z.string().nullable() }); +const warriorProjectSelectedMessageSchema = z.object({ project: z.string().nullable() }); +const warriorProjectSchema = z.object({ + project_id: z.number().optional(), + title: z.string().optional(), + project_html: z.string().optional(), + utc_deadline: z.string().nullable().optional(), +}); +const warriorItemSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.string().nullable().optional(), + project: z.string().nullable().optional(), + start_time: z.number().optional(), +}); +const projectRefreshMessageSchema = z + .object({ + project: warriorProjectSchema.optional(), + status: z.string().optional(), + items: z.array(warriorItemSchema).optional(), + }) + .nullable(); +const itemStatusMessageSchema = z.object({ item_id: z.string() }); +const pipelineStartItemMessageSchema = z.object({ item: warriorItemSchema }); +const bandwidthMessageSchema = z.object({ + received: z.number().optional(), + sent: z.number().optional(), + receiving: z.number().optional(), + sending: z.number().optional(), + session_id: z.string().optional(), +}); + +type WarriorProject = z.infer; +type WarriorItem = z.infer; +type SeesawFrame = z.infer; diff --git a/packages/request-handler/src/release-providers.ts b/packages/request-handler/src/release-providers.ts new file mode 100644 index 000000000..ebca83318 --- /dev/null +++ b/packages/request-handler/src/release-providers.ts @@ -0,0 +1,647 @@ +import { Octokit, RequestError as OctokitRequestError } from "octokit"; +import { z } from "zod/v4"; + +import type { ReleaseProviderKind } from "@homarr/definitions"; +import { getReleaseProviderDefaultUrl, normalizeReleaseProviderIdentifier } from "@homarr/definitions"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +const logger = createLogger({ module: "releaseProviders" }); + +export interface ReleasesRepositoryRequest extends Record { + id: string; + provider: ReleaseProviderKind; + identifier: string; + versionRegex?: string; + providerUrl?: string; +} + +export interface DetailsProviderResponse { + projectUrl?: string; + projectDescription?: string; + isFork?: boolean; + isArchived?: boolean; + createdAt?: Date; + starsCount?: number; + openIssues?: number; + forksCount?: number; +} + +export interface ReleaseProviderResponse { + latestRelease: string; + latestReleaseAt: Date; + releaseUrl?: string; + releaseDescription?: string; + isPreRelease?: boolean; +} + +export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse; +export type ReleaseError = + | { code: "invalidIdentifier" | "noMatchingVersion" | "noReleasesFound" } + | { code: "unexpected"; message: string }; +export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError }; + +export const getLatestRelease = ( + releases: ReleaseProviderResponse[], + versionRegex?: string, +): ReleaseProviderResponse | null => { + const validReleases = releases.filter((result) => { + if (result.latestRelease) return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true; + return true; + }); + + return validReleases.length === 0 + ? null + : validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest)); +}; + +export const getLatestMatchingReleaseAsync = async (input: ReleasesRepositoryRequest): Promise => { + const baseUrl = removeTrailingSlash(input.providerUrl ?? getReleaseProviderDefaultUrl(input.provider)); + const identifier = normalizeReleaseProviderIdentifier(input.provider, input.identifier); + + switch (input.provider) { + case "github": + return await getGithubReleaseAsync(baseUrl, identifier, input.versionRegex); + case "gitHubContainerRegistry": + return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex); + case "dockerHub": + return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex); + case "gitlab": + return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex); + case "npm": + return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex); + case "codeberg": + return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex); + case "linuxServerIO": + return await getLinuxServerIOReleaseAsync(baseUrl, identifier); + case "quay": + return await getQuayReleaseAsync(baseUrl, identifier, input.versionRegex); + } +}; + +const parseOwnerName = (identifier: string) => { + const [owner, name] = identifier.split("/"); + if (!owner || !name) return null; + return { owner, name }; +}; + +const parseOwnerPackageName = (identifier: string) => { + const [owner, ...nameParts] = identifier.split("/"); + const name = nameParts.join("/"); + if (!owner || !name) return null; + return { owner, name }; +}; + +const removeTrailingSlash = (url: string) => url.replace(/\/$/, ""); + +const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`); + +const getGithubApi = (baseUrl: string, userAgent: string) => + new Octokit({ + baseUrl, + request: { fetch: fetchWithTrustedCertificatesAsync }, + throttle: { enabled: false }, + userAgent, + }); + +const getGithubReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + const parsed = parseOwnerName(identifier); + if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + + const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider"); + try { + const releasesResponse = await api.rest.repos.listReleases({ owner: parsed.owner, repo: parsed.name }); + if (releasesResponse.data.length === 0) return { success: false, error: { code: "noMatchingVersion" } }; + + const releases = releasesResponse.data.reduce((acc, release) => { + if (!release.published_at) return acc; + acc.push({ + latestRelease: release.tag_name, + latestReleaseAt: new Date(release.published_at), + releaseUrl: release.html_url, + releaseDescription: release.body ?? undefined, + isPreRelease: release.prerelease, + }); + return acc; + }, []); + const latestRelease = getLatestRelease(releases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + const details = await getGithubDetailsAsync(api, parsed.owner, parsed.name); + return { success: true, data: { ...details, ...latestRelease } }; + } catch (error) { + const message = error instanceof OctokitRequestError ? error.message : String(error); + logger.warn("Failed to get GitHub releases", { identifier, error: message }); + return { success: false, error: { code: "unexpected", message } }; + } +}; + +const getGithubDetailsAsync = async (api: Octokit, owner: string, name: string): Promise => { + try { + const response = await api.rest.repos.get({ owner, repo: name }); + return { + projectUrl: response.data.html_url, + projectDescription: response.data.description ?? undefined, + isFork: response.data.fork, + isArchived: response.data.archived, + createdAt: new Date(response.data.created_at), + starsCount: response.data.stargazers_count, + openIssues: response.data.open_issues_count, + forksCount: response.data.forks_count, + }; + } catch (error) { + logger.warn("Failed to get GitHub details", { owner, name, error: String(error) }); + return {}; + } +}; + +const getGitHubContainerRegistryReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + const parsed = parseOwnerPackageName(identifier); + if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + + try { + const repositoryName = `${parsed.owner}/${parsed.name}`; + const registryUrl = new URL(baseUrl); + const token = await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName); + const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, token); + const matchingTags = versionRegex ? tags.filter((tag) => new RegExp(versionRegex).test(tag)) : tags; + const tagsToCheck = versionRegex + ? matchingTags.slice(-100) + : matchingTags.includes("latest") + ? ["latest"] + : matchingTags.slice(-100); + if (tagsToCheck.length === 0) return { success: false, error: { code: "noMatchingVersion" } }; + + const releases = await Promise.all( + tagsToCheck.map(async (tag) => ({ + latestRelease: tag, + latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(registryUrl, repositoryName, tag, token), + releaseUrl: `https://github.com/${repositoryName}/pkgs/container/${encodeURIComponent(parsed.name)}`, + })), + ); + const latestRelease = getLatestRelease(releases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + return { + success: true, + data: { + projectUrl: `https://github.com/${repositoryName}`, + ...latestRelease, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn("Failed to get GitHub container registry releases", { identifier, error: message }); + return { success: false, error: { code: "unexpected", message } }; + } +}; + +const gitHubContainerRegistryTokenSchema = z.object({ token: z.string() }); +const gitHubContainerRegistryTagsSchema = z.object({ tags: z.array(z.string()).optional() }); +const gitHubContainerRegistryManifestSchema = z.object({ + config: z.object({ digest: z.string() }).optional(), + manifests: z.array(z.object({ digest: z.string() })).optional(), +}); +const gitHubContainerRegistryConfigSchema = z.object({ + created: z.string().transform((value) => new Date(value)), +}); + +const getGitHubContainerRegistryTokenAsync = async (registryUrl: URL, repositoryName: string) => { + const response = await fetchWithTrustedCertificatesAsync( + buildUrl( + registryUrl.origin, + `/token?service=${registryUrl.host}&scope=${encodeURIComponent(`repository:${repositoryName}:pull`)}` as `/${string}`, + ), + ); + if (!response.ok) throw new Error(response.statusText); + + const result = gitHubContainerRegistryTokenSchema.safeParse(await response.json()); + if (!result.success) throw new Error(result.error.message); + + return result.data.token; +}; + +const getGitHubContainerRegistryTagsAsync = async (registryUrl: URL, repositoryName: string, token: string) => { + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(registryUrl.origin, `/v2/${repositoryName}/tags/list`), + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!response.ok) throw new Error(response.statusText); + + const result = gitHubContainerRegistryTagsSchema.safeParse(await response.json()); + if (!result.success) throw new Error(result.error.message); + + return result.data.tags ?? []; +}; + +const getGitHubContainerRegistryTagCreatedAtAsync = async ( + registryUrl: URL, + repositoryName: string, + tag: string, + token: string, +) => { + const manifest = await getGitHubContainerRegistryManifestAsync(registryUrl, repositoryName, tag, token); + const imageManifest = manifest.config + ? manifest + : await getGitHubContainerRegistryManifestAsync( + registryUrl, + repositoryName, + manifest.manifests?.[0]?.digest ?? "", + token, + ); + const configDigest = imageManifest.config?.digest; + if (!configDigest) throw new Error("Missing image config digest"); + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(registryUrl.origin, `/v2/${repositoryName}/blobs/${configDigest}`), + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!response.ok) throw new Error(response.statusText); + + const result = gitHubContainerRegistryConfigSchema.safeParse(await response.json()); + if (!result.success) throw new Error(result.error.message); + + return result.data.created; +}; + +const getGitHubContainerRegistryManifestAsync = async ( + registryUrl: URL, + repositoryName: string, + reference: string, + token: string, +) => { + if (!reference) throw new Error("Missing image manifest reference"); + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(registryUrl.origin, `/v2/${repositoryName}/manifests/${reference}`), + { + headers: { + Authorization: `Bearer ${token}`, + Accept: [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ].join(", "), + }, + }, + ); + if (!response.ok) throw new Error(response.statusText); + + const result = gitHubContainerRegistryManifestSchema.safeParse(await response.json()); + if (!result.success) throw new Error(result.error.message); + + return result.data; +}; + +const dockerHubReleasesSchema = z.object({ + results: z.array( + z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({ + latestRelease: tag.name, + latestReleaseAt: tag.last_updated, + })), + ), +}); + +const dockerHubDetailsSchema = z.object({ + name: z.string(), + namespace: z.string(), + description: z.string(), + star_count: z.number(), + date_registered: z.string().transform((value) => new Date(value)), +}); + +const getDockerHubReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + const parsed = identifier.includes("/") ? parseOwnerName(identifier) : { owner: "", name: identifier }; + if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + + const relativeUrl = parsed.owner + ? `/v2/namespaces/${encodeURIComponent(parsed.owner)}/repositories/${encodeURIComponent(parsed.name)}` + : `/v2/repositories/library/${encodeURIComponent(parsed.name)}`; + + for (let page = 0; page <= 5; page++) { + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `${relativeUrl}/tags?page_size=100&page=${page}` as `/${string}`), + ); + if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; + + const json: unknown = await response.json(); + const result = dockerHubReleasesSchema.safeParse(json); + if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } }; + + const latestRelease = getLatestRelease(result.data.results, versionRegex); + if (!latestRelease) continue; + + const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`); + return { success: true, data: { ...details, ...latestRelease } }; + } + + return { success: false, error: { code: "noMatchingVersion" } }; +}; + +const getDockerHubDetailsAsync = async ( + baseUrl: string, + relativeUrl: `/${string}`, +): Promise => { + const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`)); + if (!response.ok) return {}; + + const result = dockerHubDetailsSchema.safeParse(await response.json()); + if (!result.success) return {}; + + return { + projectUrl: `https://hub.docker.com/r/${result.data.namespace}/${result.data.name}`, + projectDescription: result.data.description, + createdAt: result.data.date_registered, + starsCount: result.data.star_count, + }; +}; + +const gitlabReleasesSchema = z.array( + z.object({ + name: z.string().nullable().optional(), + tag_name: z.string(), + released_at: z.string().nullable().optional(), + description: z.string().nullable().optional(), + _links: z.object({ self: z.string().optional() }).optional(), + }), +); + +const gitlabDetailsSchema = z.object({ + web_url: z.string().optional(), + description: z.string().nullable().optional(), + forked_from_project: z.unknown().nullable().optional(), + archived: z.boolean(), + created_at: z.string(), + star_count: z.number(), + open_issues_count: z.number().optional(), + forks_count: z.number(), +}); + +const getGitlabReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + const encodedIdentifier = encodeURIComponent(identifier); + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}/releases?per_page=100`), + ); + if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; + + const result = gitlabReleasesSchema.safeParse(await response.json()); + if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } }; + if (result.data.length === 0) return { success: false, error: { code: "noReleasesFound" } }; + + const releases = result.data.reduce((acc, release) => { + if (!release.released_at) return acc; + const releaseDate = new Date(release.released_at); + acc.push({ + latestRelease: release.name ?? release.tag_name, + latestReleaseAt: releaseDate, + releaseUrl: release._links?.self, + releaseDescription: release.description ?? undefined, + isPreRelease: releaseDate > new Date(), + }); + return acc; + }, []); + const latestRelease = getLatestRelease(releases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier); + return { success: true, data: { ...details, ...latestRelease } }; +}; + +const getGitlabDetailsAsync = async (baseUrl: string, encodedIdentifier: string): Promise => { + const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`)); + if (!response.ok) return {}; + + const result = gitlabDetailsSchema.safeParse(await response.json()); + if (!result.success || !result.data.web_url) return {}; + + return { + projectUrl: result.data.web_url, + projectDescription: result.data.description ?? undefined, + isFork: result.data.forked_from_project !== null, + isArchived: result.data.archived, + createdAt: new Date(result.data.created_at), + starsCount: result.data.star_count, + openIssues: result.data.open_issues_count, + forksCount: result.data.forks_count, + }; +}; + +const npmReleasesSchema = z.object({ + time: z + .record( + z.string(), + z.string().transform((value) => new Date(value)), + ) + .transform((version) => + Object.entries(version).map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })), + ), + versions: z.record(z.string(), z.object({ description: z.string() })), + name: z.string(), +}); + +const getNpmReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + if (!identifier) return { success: false, error: { code: "invalidIdentifier" } }; + + const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`)); + if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; + + const result = npmReleasesSchema.safeParse(await response.json()); + if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } }; + + const releases = result.data.time.map((tag) => ({ + ...tag, + releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(result.data.name)}/v/${encodeURIComponent(tag.latestRelease)}`, + releaseDescription: result.data.versions[tag.latestRelease]?.description ?? "", + })); + const latestRelease = getLatestRelease(releases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + return { success: true, data: latestRelease }; +}; + +const codebergReleasesSchema = z.array( + z.object({ + tag_name: z.string(), + published_at: z.string().transform((value) => new Date(value)), + url: z.string(), + body: z.string(), + prerelease: z.boolean(), + }), +); + +const codebergDetailsSchema = z.object({ + html_url: z.string(), + description: z.string(), + fork: z.boolean(), + archived: z.boolean(), + created_at: z.string().transform((value) => new Date(value)), + stars_count: z.number(), + open_issues_count: z.number(), + forks_count: z.number(), +}); + +const getCodebergReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + const parsed = parseOwnerName(identifier); + if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/releases`), + ); + if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; + + const result = codebergReleasesSchema.safeParse(await response.json()); + if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } }; + + const releases = result.data.map((tag) => ({ + latestRelease: tag.tag_name, + latestReleaseAt: tag.published_at, + releaseUrl: tag.url, + releaseDescription: tag.body, + isPreRelease: tag.prerelease, + })); + const latestRelease = getLatestRelease(releases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name); + return { success: true, data: { ...details, ...latestRelease } }; +}; + +const getCodebergDetailsAsync = async ( + baseUrl: string, + owner: string, + name: string, +): Promise => { + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`), + ); + if (!response.ok) return {}; + + const result = codebergDetailsSchema.safeParse(await response.json()); + if (!result.success) return {}; + + return { + projectUrl: result.data.html_url, + projectDescription: result.data.description, + isFork: result.data.fork, + isArchived: result.data.archived, + createdAt: result.data.created_at, + starsCount: result.data.stars_count, + openIssues: result.data.open_issues_count, + forksCount: result.data.forks_count, + }; +}; + +const linuxServerIOReleasesSchema = z.object({ + data: z.object({ + repositories: z.object({ + linuxserver: z.array( + z.object({ + name: z.string(), + initial_date: z + .string() + .transform((value) => new Date(value)) + .optional(), + github_url: z.string(), + description: z.string(), + version: z.string(), + version_timestamp: z.string().transform((value) => new Date(value)), + stars: z.number(), + deprecated: z.boolean(), + changelog: z.array(z.object({ date: z.string(), desc: z.string() })).optional(), + }), + ), + }), + }), +}); + +const getLinuxServerIOReleaseAsync = async (baseUrl: string, identifier: string): Promise => { + const parsed = parseOwnerName(identifier); + if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + + const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, "/api/v1/images")); + if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; + + const result = linuxServerIOReleasesSchema.safeParse(await response.json()); + if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } }; + + const release = result.data.data.repositories.linuxserver.find((repo) => repo.name === parsed.name); + if (!release) return { success: false, error: { code: "noMatchingVersion" } }; + + return { + success: true, + data: { + latestRelease: release.version, + latestReleaseAt: release.version_timestamp, + releaseDescription: release.changelog?.shift()?.desc, + projectUrl: release.github_url, + projectDescription: release.description, + isArchived: release.deprecated, + createdAt: release.initial_date, + starsCount: release.stars, + }, + }; +}; + +const quayReleasesSchema = z.object({ + description: z.string().optional(), + tags: z.record(z.string(), z.object({ name: z.string(), last_modified: z.string() })), +}); + +const getQuayReleaseAsync = async ( + baseUrl: string, + identifier: string, + versionRegex?: string, +): Promise => { + const parsed = parseOwnerName(identifier); + if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl( + baseUrl, + `/api/v1/repository/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}?includeTags=true&includeStats=true`, + ), + ); + if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; + + const result = quayReleasesSchema.safeParse(await response.json()); + if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } }; + + const releases = Object.values(result.data.tags).reduce((acc, tag) => { + if (!tag.name || !tag.last_modified) return acc; + acc.push({ + latestRelease: tag.name, + latestReleaseAt: new Date(tag.last_modified), + releaseUrl: `https://quay.io/repository/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/tag/${encodeURIComponent(tag.name)}`, + }); + return acc; + }, []); + const latestRelease = getLatestRelease(releases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + return { success: true, data: { projectDescription: result.data.description, ...latestRelease } }; +}; diff --git a/packages/request-handler/src/releases.ts b/packages/request-handler/src/releases.ts index 7bfd29348..683c292fb 100644 --- a/packages/request-handler/src/releases.ts +++ b/packages/request-handler/src/releases.ts @@ -1,20 +1,19 @@ import dayjs from "dayjs"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; -import type { ReleaseResponse, ReleasesRepository } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createWidgetOptionsChannel } from "@homarr/redis"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createCachedRequestHandler } from "./lib/cached-request-handler"; +import type { ReleaseResponse, ReleasesRepositoryRequest } from "./release-providers"; +import { getLatestMatchingReleaseAsync } from "./release-providers"; -export const releasesRequestHandler = createCachedIntegrationRequestHandler< - ReleaseResponse, - IntegrationKindByCategory<"releasesProvider">, - ReleasesRepository ->({ - requestAsync: async (integration, input) => { - const instance = await createIntegrationAsync(integration); - return instance.getLatestMatchingReleaseAsync(input.identifier, input.versionRegex); - }, - cacheDuration: dayjs.duration(5, "minutes"), - queryKey: "repositoriesReleases", -}); +export const releasesRequestHandler = { + handler: (itemOptions: ReleasesRepositoryRequest) => + createCachedRequestHandler({ + requestAsync: async (input) => await getLatestMatchingReleaseAsync(input), + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "repositoriesReleases", + createRedisChannel(input, handlerOptions) { + return createWidgetOptionsChannel("releases", handlerOptions.queryKey, input); + }, + }).handler(itemOptions), +}; diff --git a/packages/request-handler/src/test/release-providers.spec.ts b/packages/request-handler/src/test/release-providers.spec.ts new file mode 100644 index 000000000..1c34489d8 --- /dev/null +++ b/packages/request-handler/src/test/release-providers.spec.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { Response } from "undici"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { getLatestMatchingReleaseAsync } from "../release-providers"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ + warn: vi.fn(), + }), +})); + +const mockedFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => { + beforeEach(() => { + mockedFetch.mockReset(); + }); + + test("normalizes full image refs and returns latest from anonymous OCI registry metadata", async () => { + mockGhcrResponses({ + repositoryName: "homarr-labs/homarr", + tags: ["v1.0.0", "latest"], + createdAtByTag: { + latest: "2026-06-19T20:30:59.853Z", + }, + }); + + const result = await getLatestMatchingReleaseAsync({ + id: "homarr", + provider: "gitHubContainerRegistry", + identifier: "ghcr.io/homarr-labs/homarr:latest", + }); + + expect(result).toEqual({ + success: true, + data: { + projectUrl: "https://github.com/homarr-labs/homarr", + latestRelease: "latest", + latestReleaseAt: new Date("2026-06-19T20:30:59.853Z"), + releaseUrl: "https://github.com/homarr-labs/homarr/pkgs/container/homarr", + }, + }); + expectUrl(0, "https://ghcr.io/token"); + expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("service")).toBe("ghcr.io"); + expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("scope")).toBe( + "repository:homarr-labs/homarr:pull", + ); + }); + + test("uses custom registry base URL for GHCR-compatible registries", async () => { + mockGhcrResponses({ + registryOrigin: "https://registry.example.com", + repositoryName: "homarr-labs/homarr", + tags: ["latest"], + createdAtByTag: { + latest: "2026-06-19T20:30:59.853Z", + }, + }); + + await getLatestMatchingReleaseAsync({ + id: "homarr", + provider: "gitHubContainerRegistry", + identifier: "homarr-labs/homarr", + providerUrl: "https://registry.example.com/", + }); + + expectUrl(0, "https://registry.example.com/token"); + expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("service")).toBe( + "registry.example.com", + ); + expectUrl(1, "https://registry.example.com/v2/homarr-labs/homarr/tags/list"); + }); + + test("chooses the newest matching version tag", async () => { + mockGhcrResponses({ + repositoryName: "muchobien/pocketbase", + tags: ["0.22.0", "0.23.0", "latest"], + createdAtByTag: { + "0.22.0": "2026-01-01T00:00:00.000Z", + "0.23.0": "2026-02-01T00:00:00.000Z", + }, + }); + + const result = await getLatestMatchingReleaseAsync({ + id: "pocketbase", + provider: "gitHubContainerRegistry", + identifier: "ghcr.io/muchobien/pocketbase:latest", + versionRegex: "^0\\.[0-9]+\\.[0-9]+$", + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.latestRelease).toBe("0.23.0"); + expect(result.data.latestReleaseAt).toEqual(new Date("2026-02-01T00:00:00.000Z")); + }); + + test("returns invalidIdentifier for missing package name", async () => { + const result = await getLatestMatchingReleaseAsync({ + id: "invalid", + provider: "gitHubContainerRegistry", + identifier: "ghcr.io/homarr-labs", + }); + + expect(result).toEqual({ success: false, error: { code: "invalidIdentifier" } }); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test("returns unexpected with provider error message", async () => { + mockedFetch.mockResolvedValueOnce(createJsonResponse({ errors: [] }, { status: 500, statusText: "Registry down" })); + + const result = await getLatestMatchingReleaseAsync({ + id: "homarr", + provider: "gitHubContainerRegistry", + identifier: "homarr-labs/homarr", + }); + + expect(result).toEqual({ success: false, error: { code: "unexpected", message: "Registry down" } }); + }); +}); + +interface MockGhcrResponsesInput { + registryOrigin?: string; + repositoryName: string; + tags: string[]; + createdAtByTag: Record; +} + +const mockGhcrResponses = ({ + registryOrigin = "https://ghcr.io", + repositoryName, + tags, + createdAtByTag, +}: MockGhcrResponsesInput) => { + mockedFetch.mockImplementation(async (input) => { + const url = new URL(input.toString()); + const path = url.pathname; + + if (url.origin !== registryOrigin) { + return createJsonResponse({ error: "unexpected origin" }, { status: 404, statusText: "Not Found" }); + } + + if (path === "/token") return createJsonResponse({ token: "token" }); + if (path === `/v2/${repositoryName}/tags/list`) return createJsonResponse({ name: repositoryName, tags }); + + const manifestMatch = new RegExp(`^/v2/${escapeRegExp(repositoryName)}/manifests/(.+)$`).exec(path); + if (manifestMatch) { + const reference = decodeURIComponent(manifestMatch[1] ?? ""); + if (reference.startsWith("sha256:")) { + return createJsonResponse({ config: { digest: `sha256:config-${reference.slice("sha256:".length)}` } }); + } + return createJsonResponse({ + manifests: [{ digest: `sha256:${reference}` }], + }); + } + + const blobMatch = new RegExp(`^/v2/${escapeRegExp(repositoryName)}/blobs/sha256:config-(.+)$`).exec(path); + if (blobMatch) { + const tag = decodeURIComponent(blobMatch[1] ?? ""); + return createJsonResponse({ created: createdAtByTag[tag] ?? "2026-01-01T00:00:00.000Z" }); + } + + return createJsonResponse({ error: "not found" }, { status: 404, statusText: "Not Found" }); + }); +}; + +interface JsonResponseInit { + status?: number; + statusText?: string; +} + +const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) => + new Response(JSON.stringify(body), { + status: init.status ?? 200, + statusText: init.statusText, + headers: { + "content-type": "application/json", + }, + }); + +const expectUrl = (callIndex: number, expectedUrl: string) => { + expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl); +}; + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index 0946ac67d..fa33e4000 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -1,36 +1,153 @@ import dayjs from "dayjs"; +import z from "zod/v4"; -import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; -import type { Station, Timetable } from "@homarr/integrations/types"; +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createWidgetOptionsChannel } from "@homarr/redis"; -import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; +import { createCachedRequestHandler } from "./lib/cached-request-handler"; -export const timetableSearchStationsRequestHandler = createCachedIntegrationRequestHandler< - Station[], - IntegrationKindByCategory<"timetable">, - { query: string } ->({ - async requestAsync(integration, input) { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.searchStationsAsync(input.query); - }, - queryKey: "timetableSearchStations", - cacheDuration: dayjs.duration(1, "day"), +export interface Station { + id: string; + name: string; +} + +export interface TimetableEntry { + timestamp: Date; + delay: number; + line: { + name: string; + color: string | null; + } | null; + location: string; + platform: { + name: string; + hasChanged: boolean; + } | null; +} + +export interface Timetable { + stationId: string; + timestamp: Date; + entries: TimetableEntry[]; +} + +const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; + +export const timetableSearchStationsRequestHandler = { + handler: (itemOptions: { baseUrl: string; query: string }) => + createCachedRequestHandler({ + async requestAsync(input) { + return await searchStationsAsync(input.baseUrl, input.query); + }, + queryKey: "timetableSearchStations", + cacheDuration: dayjs.duration(1, "day"), + createRedisChannel(input, handlerOptions) { + return createWidgetOptionsChannel("timetable", handlerOptions.queryKey, input); + }, + }).handler(itemOptions), +}; + +export const timetableGetTimetableRequestHandler = { + handler: (itemOptions: { baseUrl: string; stationId: string; limit: number }) => + createCachedRequestHandler({ + async requestAsync(input) { + return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit }); + }, + queryKey: "timetableGetTimetable", + cacheDuration: dayjs.duration(1, "minute"), + createRedisChannel(input, handlerOptions) { + return createWidgetOptionsChannel("timetable", handlerOptions.queryKey, input); + }, + }).handler(itemOptions), +}; + +const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => { + const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`); + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value.toString()); + } + return url; +}; + +const searchStationsAsync = async (baseUrl: string, query: string): Promise => { + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }), + ); + if (!response.ok) throw new ResponseError(response); + + const data = await searchSchema.parseAsync(await response.json()); + return data + .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) + .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) + .filter((item) => item !== null); +}; + +const getTimetableAsync = async ( + baseUrl: string, + options: { stationId: string; limit: number }, +): Promise => { + const now = new Date(); + const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string]; + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, "/timetable/api/stationboard.json", { + stop: options.stationId, + limit: options.limit, + show_delays: 1, + show_tracks: 1, + date, + time: time.substring(0, 5), + }), + ); + if (!response.ok) throw new ResponseError(response); + + const data = await timetableSchema.parseAsync(await response.json()); + return { + stationId: options.stationId, + timestamp: now, + entries: data.connections.map((connection) => { + const color = connection.color.split("~")[0]; + return { + timestamp: connection.time, + line: connection.line + ? { + name: connection.line, + color: color && color.length >= 1 ? `#${color}` : null, + } + : null, + location: connection.terminal.name, + delay: connection.dep_delay, + platform: connection.track + ? { + name: connection.track.replace("!", ""), + hasChanged: connection.track.includes("!"), + } + : null, + }; + }), + }; +}; + +const timetableSchema = z.object({ + connections: z.array( + z.object({ + time: z.string().transform((str) => new Date(str)), + line: z.string().optional(), + color: z.string(), + terminal: z.object({ name: z.string() }), + track: z.string().optional(), + dep_delay: z + .string() + .optional() + .transform((str) => (str ? parseInt(str) : 0)), + }), + ), }); -export const timetableGetTimetableRequestHandler = createCachedIntegrationRequestHandler< - Timetable, - IntegrationKindByCategory<"timetable">, - { stationId: string; limit: number } ->({ - async requestAsync(integration, input) { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getTimetableAsync({ - stationId: input.stationId, - limit: input.limit, - }); - }, - queryKey: "timetableGetTimetable", - cacheDuration: dayjs.duration(1, "minute"), -}); +const searchSchema = z.array( + z.object({ + id: z.string().optional(), + label: z.string(), + iconclass: z.string(), + }), +); diff --git a/packages/translation/src/lang/ca.json b/packages/translation/src/lang/ca.json index 18d07473d..84b24a515 100644 --- a/packages/translation/src/lang/ca.json +++ b/packages/translation/src/lang/ca.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/cn.json b/packages/translation/src/lang/cn.json index 04f93c6b9..5e0f92e22 100644 --- a/packages/translation/src/lang/cn.json +++ b/packages/translation/src/lang/cn.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "没有提供者", "tooltip": "无法解析该提供者,请在导入镜像后手动设置" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "没有找到匹配的版本", "noReleasesFound": "没有发现发布信息", "noProviderSeleceted": "没有选择提供者", - "noProviderResponse": "提供者没有响应" + "noProviderResponse": "提供者没有响应", + "noProviderSelected": "没有选择提供者" } } }, diff --git a/packages/translation/src/lang/cr.json b/packages/translation/src/lang/cr.json index 9341366dc..f4fd6ef8d 100644 --- a/packages/translation/src/lang/cr.json +++ b/packages/translation/src/lang/cr.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "crwdns3508:0crwdne3508:0", "tooltip": "crwdns3510:0crwdne3510:0" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "crwdns3514:0crwdne3514:0", "noReleasesFound": "crwdns3516:0crwdne3516:0", "noProviderSeleceted": "crwdns3518:0crwdne3518:0", - "noProviderResponse": "crwdns3520:0crwdne3520:0" + "noProviderResponse": "crwdns3520:0crwdne3520:0", + "noProviderSelected": "crwdns3518:0crwdne3518:0" } } }, diff --git a/packages/translation/src/lang/cs.json b/packages/translation/src/lang/cs.json index 89b8949bb..690e78e49 100644 --- a/packages/translation/src/lang/cs.json +++ b/packages/translation/src/lang/cs.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/da.json b/packages/translation/src/lang/da.json index d22ef9c61..7890a1553 100644 --- a/packages/translation/src/lang/da.json +++ b/packages/translation/src/lang/da.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Ingen Udbyder", "tooltip": "Udbyderen kunne ikke fortolkes, angiv venligst det manuelt efter import af billederne" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Ingen matchende version fundet", "noReleasesFound": "Ingen udgivelser fundet", "noProviderSeleceted": "Ingen udbyder valgt", - "noProviderResponse": "Intet svar fra udbyder" + "noProviderResponse": "Intet svar fra udbyder", + "noProviderSelected": "Ingen udbyder valgt" } } }, diff --git a/packages/translation/src/lang/de-CH.json b/packages/translation/src/lang/de-CH.json index bbe34e1c0..6596321b6 100644 --- a/packages/translation/src/lang/de-CH.json +++ b/packages/translation/src/lang/de-CH.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json index 09d4a4ce2..b84c76904 100644 --- a/packages/translation/src/lang/de.json +++ b/packages/translation/src/lang/de.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Keine Anbieter", "tooltip": "Der Anbieter konnte nicht festgestellt werden, bitte manuell nach dem Import der Bilder festlegen" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Keine passende Version gefunden", "noReleasesFound": "Keine Veröffentlichungen gefunden", "noProviderSeleceted": "Kein Anbieter ausgewählt", - "noProviderResponse": "Keine Antwort vom Anbieter" + "noProviderResponse": "Keine Antwort vom Anbieter", + "noProviderSelected": "Kein Anbieter ausgewählt" } } }, diff --git a/packages/translation/src/lang/el.json b/packages/translation/src/lang/el.json index 168501668..0bf3a37d1 100644 --- a/packages/translation/src/lang/el.json +++ b/packages/translation/src/lang/el.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index ff08eecf7..ce591e42b 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 190a13733..d58f1310a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1755,6 +1755,9 @@ "name": "ArchiveTeam Warrior", "description": "Monitor ArchiveTeam Warrior status, projects, and active archival jobs.", "option": { + "url": { + "label": "Warrior URL" + }, "showBroadcastMessage": { "label": "Show broadcast messages" } @@ -1900,6 +1903,9 @@ "title": "Departures {station}", "noStation": "No station selected", "option": { + "baseUrl": { + "label": "Search.ch URL" + }, "station": { "label": "Station" } @@ -2770,8 +2776,8 @@ "name": "Immich Album", "option": { "albumId": { - "label": "Album ID", - "description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" + "label": "Album", + "description": "Select the Immich album to show in the slideshow." }, "rotationIntervalSeconds": { "label": "Interval in seconds", @@ -2780,6 +2786,10 @@ "showPhotoInfo": { "label": "Show photo info", "description": "Display date of the picture and the current index at the bottom of the slideshow" + }, + "randomizePhotos": { + "label": "Randomize photos", + "description": "Show photos in a random order instead of always starting with the same album order." } }, "description": "Slideshow of pictures in your Immich album", @@ -3144,6 +3154,9 @@ "provider": { "label": "Provider" }, + "providerUrl": { + "label": "Provider URL" + }, "identifier": { "label": "Identifier", "placeholder": "Name or Owner/Name" @@ -3213,8 +3226,10 @@ "invalidIdentifier": "Invalid identifier", "noMatchingVersion": "No matching version found", "noReleasesFound": "No releases found", + "noProviderSelected": "No provider selected", "noProviderSeleceted": "No provider selected", - "noProviderResponse": "No response from provider" + "noProviderResponse": "No response from provider", + "unexpected": "Unexpected provider error" } } }, diff --git a/packages/translation/src/lang/es.json b/packages/translation/src/lang/es.json index 7858cc262..ef4440e30 100644 --- a/packages/translation/src/lang/es.json +++ b/packages/translation/src/lang/es.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Sin Proveedor", "tooltip": "El proveedor no pudo ser analizado, por favor configurarlo manualmente después de importar las imágenes" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "No se encontró una versión correspondiente", "noReleasesFound": "No se encontraron publicaciones", "noProviderSeleceted": "No se ha seleccionado ningún proveedor", - "noProviderResponse": "No hay respuesta del proveedor" + "noProviderResponse": "No hay respuesta del proveedor", + "noProviderSelected": "No se ha seleccionado ningún proveedor" } } }, diff --git a/packages/translation/src/lang/et.json b/packages/translation/src/lang/et.json index 606d4833f..06991bd0c 100644 --- a/packages/translation/src/lang/et.json +++ b/packages/translation/src/lang/et.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/fi.json b/packages/translation/src/lang/fi.json index 713c729b1..36e68e9fd 100644 --- a/packages/translation/src/lang/fi.json +++ b/packages/translation/src/lang/fi.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "Toimittajaa ei ole valittu", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "Toimittajaa ei ole valittu" } } }, diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json index 25b85fe71..ddc5978aa 100644 --- a/packages/translation/src/lang/fr.json +++ b/packages/translation/src/lang/fr.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Aucun fournisseur", "tooltip": "Le fournisseur n'a pas pu être analysé, veuillez le définir manuellement après l'importation des images" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Aucune version correspondante trouvée", "noReleasesFound": "Aucune publication trouvée", "noProviderSeleceted": "Aucun fournisseur sélectionné", - "noProviderResponse": "Aucune réponse du fournisseur" + "noProviderResponse": "Aucune réponse du fournisseur", + "noProviderSelected": "Aucun fournisseur sélectionné" } } }, diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json index 5ecba1a8c..fac9c0f7f 100644 --- a/packages/translation/src/lang/he.json +++ b/packages/translation/src/lang/he.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "ללא ספק", "tooltip": "לא ניתן היה לנתח את הספק, אנא הגדר אותו ידנית לאחר ייבוא ​​התמונות" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "לא נמצאה גרסה תואמת", "noReleasesFound": "לא נמצאו שיחרורים", "noProviderSeleceted": "לא נבחר ספק", - "noProviderResponse": "אין תשובה מהספק" + "noProviderResponse": "אין תשובה מהספק", + "noProviderSelected": "לא נבחר ספק" } } }, diff --git a/packages/translation/src/lang/hr.json b/packages/translation/src/lang/hr.json index 1f7d5013b..f5783a384 100644 --- a/packages/translation/src/lang/hr.json +++ b/packages/translation/src/lang/hr.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/hu.json b/packages/translation/src/lang/hu.json index 1c8d473d7..cf147749d 100644 --- a/packages/translation/src/lang/hu.json +++ b/packages/translation/src/lang/hu.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Nincs Szolgáltató", "tooltip": "A szolgáltató adatai nem értelmezhetők; kérjük, az importálás után állítsa be azokat manuálisan" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/it.json b/packages/translation/src/lang/it.json index e170a2565..8dd34bc36 100644 --- a/packages/translation/src/lang/it.json +++ b/packages/translation/src/lang/it.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Nessun Provider", "tooltip": "Impossibile analizzare il provider, impostarlo manualmente dopo aver importato le immagini" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Nessuna versione corrispondente trovata", "noReleasesFound": "Nessun rilascio trovato", "noProviderSeleceted": "Nessun provider selezionato", - "noProviderResponse": "Nessuna risposta dal provider" + "noProviderResponse": "Nessuna risposta dal provider", + "noProviderSelected": "Nessun provider selezionato" } } }, diff --git a/packages/translation/src/lang/ja.json b/packages/translation/src/lang/ja.json index 1be05e8c8..2c0b5943a 100644 --- a/packages/translation/src/lang/ja.json +++ b/packages/translation/src/lang/ja.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "プロバイダーがありません", "tooltip": "プロバイダーを解析できませんでした。画像をインポートしたあとで、手動で設定してください" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "一致するバージョンが見つかりません", "noReleasesFound": "リリースが見つかりませんでした", "noProviderSeleceted": "プロバイダーが選択されていません", - "noProviderResponse": "プロバイダーからの応答がありません" + "noProviderResponse": "プロバイダーからの応答がありません", + "noProviderSelected": "プロバイダーが選択されていません" } } }, diff --git a/packages/translation/src/lang/ko.json b/packages/translation/src/lang/ko.json index 8ddf946f3..39d5dde10 100644 --- a/packages/translation/src/lang/ko.json +++ b/packages/translation/src/lang/ko.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/lt.json b/packages/translation/src/lang/lt.json index afec241f1..b9140ed7b 100644 --- a/packages/translation/src/lang/lt.json +++ b/packages/translation/src/lang/lt.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/lv.json b/packages/translation/src/lang/lv.json index e5d262048..136d36326 100644 --- a/packages/translation/src/lang/lv.json +++ b/packages/translation/src/lang/lv.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/nl.json b/packages/translation/src/lang/nl.json index 66a8f48d1..3a403e373 100644 --- a/packages/translation/src/lang/nl.json +++ b/packages/translation/src/lang/nl.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Geen aanbieder", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Geen overeenkomende versie gevonden", "noReleasesFound": "Geen uitgaves gevonden", "noProviderSeleceted": "Geen aanbieder geselecteerd", - "noProviderResponse": "Geen reactie van aanbieder" + "noProviderResponse": "Geen reactie van aanbieder", + "noProviderSelected": "Geen aanbieder geselecteerd" } } }, diff --git a/packages/translation/src/lang/no.json b/packages/translation/src/lang/no.json index 00309d122..cb244b347 100644 --- a/packages/translation/src/lang/no.json +++ b/packages/translation/src/lang/no.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Ingen leverandør", "tooltip": "Leverandøren kunne ikke tolkes, sett den manuelt etter import av bildene" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Ingen samsvarende versjon funnet", "noReleasesFound": "Ingen utgivelser funnet", "noProviderSeleceted": "Ingen leverandør valgt", - "noProviderResponse": "Ingen respons fra leverandør" + "noProviderResponse": "Ingen respons fra leverandør", + "noProviderSelected": "Ingen leverandør valgt" } } }, diff --git a/packages/translation/src/lang/pl.json b/packages/translation/src/lang/pl.json index 90aa8c3eb..505135400 100644 --- a/packages/translation/src/lang/pl.json +++ b/packages/translation/src/lang/pl.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Brak dostawcy", "tooltip": "Nie udało się rozpoznać dostawcy. Ustaw go ręcznie po zaimportowaniu obrazów." + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Nie znaleziono pasującej wersji", "noReleasesFound": "Nie znaleziono wydań!", "noProviderSeleceted": "Nie wybrano dostawcy", - "noProviderResponse": "Brak odpowiedzi od dostawcy" + "noProviderResponse": "Brak odpowiedzi od dostawcy", + "noProviderSelected": "Nie wybrano dostawcy" } } }, diff --git a/packages/translation/src/lang/pt-br.json b/packages/translation/src/lang/pt-br.json index 2c096f760..fda6321fe 100644 --- a/packages/translation/src/lang/pt-br.json +++ b/packages/translation/src/lang/pt-br.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Nenhum provedor", "tooltip": "O provedor não pôde ser analisado, defina-o manualmente após importar as imagens" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Nenhuma versão correspondente encontrada", "noReleasesFound": "Nenhum lançamento encontrado", "noProviderSeleceted": "Nenhum provedor selecionado", - "noProviderResponse": "Nenhuma resposta do provedor" + "noProviderResponse": "Nenhuma resposta do provedor", + "noProviderSelected": "Nenhum provedor selecionado" } } }, diff --git a/packages/translation/src/lang/pt.json b/packages/translation/src/lang/pt.json index 48bf163a9..aaa479f56 100644 --- a/packages/translation/src/lang/pt.json +++ b/packages/translation/src/lang/pt.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/ro.json b/packages/translation/src/lang/ro.json index 32081e109..230748ff3 100644 --- a/packages/translation/src/lang/ro.json +++ b/packages/translation/src/lang/ro.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/ru.json b/packages/translation/src/lang/ru.json index edca67abd..56782b717 100644 --- a/packages/translation/src/lang/ru.json +++ b/packages/translation/src/lang/ru.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Нет провайдера", "tooltip": "Не удалось определить провайдер, укажите его вручную после импорта образов" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Подходящая версия не найдена", "noReleasesFound": "Релизы не найдены", "noProviderSeleceted": "Провайдер не выбран", - "noProviderResponse": "Нет ответа от провайдера" + "noProviderResponse": "Нет ответа от провайдера", + "noProviderSelected": "Провайдер не выбран" } } }, diff --git a/packages/translation/src/lang/sk.json b/packages/translation/src/lang/sk.json index 3e44a4be6..65bf13b93 100644 --- a/packages/translation/src/lang/sk.json +++ b/packages/translation/src/lang/sk.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Nenašla sa žiadna zodpovedajúca verzia", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/sl.json b/packages/translation/src/lang/sl.json index eec31b510..a1f5035f9 100644 --- a/packages/translation/src/lang/sl.json +++ b/packages/translation/src/lang/sl.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/sv.json b/packages/translation/src/lang/sv.json index 1747bc399..49fd61158 100644 --- a/packages/translation/src/lang/sv.json +++ b/packages/translation/src/lang/sv.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/tr.json b/packages/translation/src/lang/tr.json index a3acc6650..5917b6a6e 100644 --- a/packages/translation/src/lang/tr.json +++ b/packages/translation/src/lang/tr.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "Sağlayıcı Bulunamadı", "tooltip": "Sağlayıcı çözümlenemedi, lütfen görselleri içe aktardıktan sonra elle ayarlayın" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "Eşleşen sürüm bulunamadı", "noReleasesFound": "Hiçbir sürüm bulunamadı", "noProviderSeleceted": "Hiçbir sağlayıcı seçilmedi", - "noProviderResponse": "Sağlayıcıdan yanıt alınamadı" + "noProviderResponse": "Sağlayıcıdan yanıt alınamadı", + "noProviderSelected": "Hiçbir sağlayıcı seçilmedi" } } }, diff --git a/packages/translation/src/lang/uk.json b/packages/translation/src/lang/uk.json index a6305e4e1..27e9bd0ae 100644 --- a/packages/translation/src/lang/uk.json +++ b/packages/translation/src/lang/uk.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/vi.json b/packages/translation/src/lang/vi.json index dd8e8b519..d09cd5504 100644 --- a/packages/translation/src/lang/vi.json +++ b/packages/translation/src/lang/vi.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/translation/src/lang/zh.json b/packages/translation/src/lang/zh.json index 99def3d69..30ce2dfe3 100644 --- a/packages/translation/src/lang/zh.json +++ b/packages/translation/src/lang/zh.json @@ -3180,6 +3180,9 @@ "noProvider": { "label": "", "tooltip": "" + }, + "providerUrl": { + "label": "" } } }, @@ -3203,7 +3206,8 @@ "noMatchingVersion": "找不到匹配的版本", "noReleasesFound": "", "noProviderSeleceted": "", - "noProviderResponse": "" + "noProviderResponse": "", + "noProviderSelected": "" } } }, diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx index de8ef5021..3b5b636aa 100644 --- a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx @@ -24,7 +24,11 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg const inputProps = form.getInputProps(`options.${property}`); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); - const { isPending, options: selectOptions } = options.useOptions(debouncedSearch, form.values.integrationIds); + const { isPending, options: selectOptions } = options.useOptions( + debouncedSearch, + form.values.integrationIds, + form.values.options, + ); const currentOption = inputProps.value as DynamicSelectOption | null; const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void; diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index f7f026c3c..de3658b98 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -38,16 +38,20 @@ import { escapeForRegEx } from "@tiptap/react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId } from "@homarr/common"; -import { getIconUrl } from "@homarr/definitions"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { ReleaseProviderKind } from "@homarr/definitions"; +import { + getReleaseProviderDefaultUrl, + getReleaseProviderIconUrl, + getReleaseProviderName, + normalizeReleaseProviderIdentifier, + releaseProviderKinds, +} from "@homarr/definitions"; import { findBestIconMatch, IconPicker } from "@homarr/forms-collection"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedImage } from "@homarr/ui"; import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository"; -import { WidgetIntegrationSelect } from "../widget-integration-select"; -import type { IntegrationSelectOption } from "../widget-integration-select"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; @@ -57,10 +61,6 @@ interface FormValidation { errors: FormErrors; } -interface Integration extends IntegrationSelectOption { - iconUrl: string; -} - export const WidgetMultiReleasesRepositoriesInput = ({ property, kind, @@ -78,38 +78,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({ const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; - const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery( - { - category: "releasesProvider", - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ); - const integrations = useMemo( - () => - integrationsApi.data?.reduce>((acc, integration) => { - acc[integration.id] = { - id: integration.id, - name: integration.name, - url: integration.url, - kind: integration.kind, - iconUrl: getIconUrl(integration.kind), - }; - return acc; - }, {}) ?? {}, - [integrationsApi], + const providerOptions = useMemo( + () => releaseProviderKinds.map((provider) => ({ value: provider, label: getReleaseProviderName(provider) })), + [], ); const onRepositorySave = useCallback( (repository: ReleasesRepository, index: number): FormValidation => { - form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId); + form.setFieldValue(`options.${property}.${index}.provider`, repository.provider); form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier); form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl); + form.setFieldValue(`options.${property}.${index}.providerUrl`, repository.providerUrl); const formValidation = form.validate(); const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => { @@ -152,7 +133,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositoryCancel: () => onRepositoryRemove(index), versionFilterPrecisionOptions, - integrations, + providerOptions, }); }; @@ -183,7 +164,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({ onClick={() => openImportModal({ repositories, - integrations, versionFilterPrecisionOptions, onConfirm: (selectedRepositories) => { if (!selectedRepositories.length) return; @@ -210,14 +190,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({ {repositories.map((repository, index) => { - const integration = repository.providerIntegrationId - ? integrations[repository.providerIntegrationId] - : undefined; + const providerName = repository.provider ? getReleaseProviderName(repository.provider) : ""; return ( - {integration?.name ?? ""} + {providerName} @@ -242,7 +222,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({ repository, onRepositorySave: (saved) => onRepositorySave(saved, index), versionFilterPrecisionOptions, - integrations, + providerOptions, }) } variant="light" @@ -294,7 +274,7 @@ interface RepositoryEditProps { onRepositorySave: (repository: ReleasesRepository) => FormValidation; onRepositoryCancel?: () => void; versionFilterPrecisionOptions: string[]; - integrations: Record; + providerOptions: { value: ReleaseProviderKind; label: string }[]; } const RepositoryEditModal = createModal(({ innerProps, actions }) => { @@ -302,11 +282,6 @@ const RepositoryEditModal = createModal(({ innerProps, acti const [loading, setLoading] = useState(false); const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [formErrors, setFormErrors] = useState({}); - const integrationSelectOptions: IntegrationSelectOption[] = useMemo( - () => Object.values(innerProps.integrations), - [innerProps.integrations], - ); - // Allows user to not select an icon by removing the url from the input, // will only try and get an icon if the name or identifier changes const [autoSetIcon, setAutoSetIcon] = useState(false); @@ -361,15 +336,14 @@ const RepositoryEditModal = createModal(({ innerProps, acti
- { - handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined }); + handleChange({ provider: value ? (value as ReleaseProviderKind) : undefined }); }} />
@@ -397,6 +371,16 @@ const RepositoryEditModal = createModal(({ innerProps, acti />
+ { + handleChange({ providerUrl: event.currentTarget.value || undefined }); + }} + error={formErrors[`${innerProps.fieldPath}.providerUrl`]} + /> + void; @@ -522,12 +505,12 @@ interface ImportRepositorySelectProps { const ImportRepositorySelect = ({ repository, checked, - integration, versionFilterPrecisionOptions, disabled = false, onImageSelectionChanged = undefined, }: ImportRepositorySelectProps) => { const tRepository = useScopedI18n("widget.releases.option.repositories"); + const provider = repository.provider; return ( @@ -570,12 +553,12 @@ const ImportRepositorySelect = ({ )} - + - {integration ? ( + {provider ? ( - {integration?.name ?? tRepository("noProvider.label")} + {provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")} @@ -596,7 +579,6 @@ const ImportRepositorySelect = ({ interface RepositoryImportProps { repositories: ReleasesRepository[]; - integrations: Record; versionFilterPrecisionOptions: string[]; onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void; isAdmin: boolean; @@ -625,28 +607,25 @@ const RepositoryImportModal = createModal(({ innerProps, if (!identifier) return acc; - const providerKind = containerImageToProviderKind[source] ?? "dockerHub"; - const integrationId = Object.values(innerProps.integrations).find( - (integration) => integration.kind === providerKind, - )?.id; + const provider = containerImageToProviderKind[source] ?? "dockerHub"; + const normalizedIdentifier = normalizeReleaseProviderIdentifier(provider, identifier); - if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier)) - return acc; + if (acc.some((item) => item.provider === provider && item.identifier === normalizedIdentifier)) return acc; acc.push({ id: createId(), - providerIntegrationId: integrationId, - identifier, + provider, + identifier: normalizedIdentifier, iconUrl: container.iconUrl ?? undefined, - name: formatIdentifierName(identifier), + name: formatIdentifierName(normalizedIdentifier), versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined, alreadyImported: innerProps.repositories.some( - (item) => item.providerIntegrationId === integrationId && item.identifier === identifier, + (item) => item.provider === provider && item.identifier === normalizedIdentifier, ), }); return acc; }, []) ?? [], - [docker.data, innerProps.repositories, innerProps.integrations], + [docker.data, innerProps.repositories], ); const handleConfirm = useCallback(() => { @@ -722,27 +701,20 @@ const RepositoryImportModal = createModal(({ innerProps, {importRepositories .filter((repository) => !repository.alreadyImported) - .map((repository) => { - const integration = repository.providerIntegrationId - ? innerProps.integrations[repository.providerIntegrationId] - : undefined; - - return ( - - isSelected - ? setSelectedImages([...selectedImages, repository]) - : setSelectedImages(selectedImages.filter((img) => img !== repository)) - } - /> - ); - })} + .map((repository) => ( + + isSelected + ? setSelectedImages([...selectedImages, repository]) + : setSelectedImages(selectedImages.filter((img) => img !== repository)) + } + /> + ))}
)} @@ -756,22 +728,15 @@ const RepositoryImportModal = createModal(({ innerProps, {importRepositories .filter((repository) => repository.alreadyImported) - .map((repository) => { - const integration = repository.providerIntegrationId - ? innerProps.integrations[repository.providerIntegrationId] - : undefined; - - return ( - - ); - })} + .map((repository) => ( + + ))} )} @@ -798,8 +763,8 @@ const RepositoryImportModal = createModal(({ innerProps, size: "xl", }); -const containerImageToProviderKind: Record = { - "ghcr.io": "github", +const containerImageToProviderKind: Record = { + "ghcr.io": "gitHubContainerRegistry", "docker.io": "dockerHub", "lscr.io": "linuxServerIO", "quay.io": "quay", diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index dacac283d..14a401175 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -4,41 +4,33 @@ import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/co import { clientApi } from "@homarr/api/client"; import { humanFileSize } from "@homarr/common"; -import { getIconUrl } from "@homarr/definitions"; import type { WidgetComponentProps } from "../definition"; -export default function ArchiveTeamWarriorWidget({ - integrationIds, - options, -}: WidgetComponentProps<"archiveTeamWarrior">) { - const integrationId = integrationIds[0]; +const archiveTeamWarriorIconUrl = "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png"; - if (!integrationId) { - return null; - } - - return ; +export default function ArchiveTeamWarriorWidget({ options }: WidgetComponentProps<"archiveTeamWarrior">) { + return ; } const ArchiveTeamWarriorWidgetContent = ({ - integrationId, + url, options, }: { - integrationId: string; + url: string; options: WidgetComponentProps<"archiveTeamWarrior">["options"]; }) => { const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ - integrationId, + url, }); const utils = clientApi.useUtils(); clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription( - { integrationId }, + { url }, { onData: (next) => { - utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next); + utils.widget.archiveTeamWarrior.getStatus.setData({ url }, next); }, }, ); @@ -50,7 +42,7 @@ const ArchiveTeamWarriorWidgetContent = ({ - + {projectName} diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index 5e0998ec2..1368a6836 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -1,4 +1,5 @@ import { IconArchive } from "@tabler/icons-react"; +import { z } from "zod/v4"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,10 +8,13 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe icon: IconArchive, createOptions() { return optionsBuilder.from((factory) => ({ + url: factory.text({ + defaultValue: "http://localhost:8001", + validate: z.string().url(), + }), showBroadcastMessage: factory.switch({ defaultValue: true, }), })); }, - supportedIntegrations: ["archiveTeamWarrior"], }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx index 94795991c..a8bf09051 100644 --- a/packages/widgets/src/immich/album-carousel/component.tsx +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Box, Center, Group, Image, Stack, Text } from "@mantine/core"; import { IconAlertCircle, IconCalendar } from "@tabler/icons-react"; @@ -26,6 +26,11 @@ export default function ImmichAlbumCarouselWidget({ { enabled: Boolean(options.albumId), staleTime: 15 * 60 * 1000 }, ); + const photoAssets = useMemo(() => { + const assets = album?.assets.filter((asset) => asset.type === "IMAGE") ?? []; + return options.randomizePhotos ? shuffle(assets) : assets; + }, [album?.assets, options.randomizePhotos]); + if (!options.albumId) { return ; } @@ -36,8 +41,6 @@ export default function ImmichAlbumCarouselWidget({ return ; } - const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE"); - if (photoAssets.length === 0) { return ; } @@ -53,6 +56,19 @@ export default function ImmichAlbumCarouselWidget({ ); } +function shuffle(items: T[]) { + const shuffled = [...items]; + for (let index = shuffled.length - 1; index > 0; index--) { + const randomIndex = Math.floor(Math.random() * (index + 1)); + const current = shuffled[index]; + const random = shuffled[randomIndex]; + if (current === undefined || random === undefined) continue; + shuffled[index] = random; + shuffled[randomIndex] = current; + } + return shuffled; +} + interface CarouselProps { assets: { id: string; diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts index 6ad37c50a..9f8110ffc 100644 --- a/packages/widgets/src/immich/album-carousel/index.ts +++ b/packages/widgets/src/immich/album-carousel/index.ts @@ -1,6 +1,8 @@ import { IconPhoto } from "@tabler/icons-react"; import z from "zod"; +import { clientApi } from "@homarr/api/client"; + import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -10,9 +12,20 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al integrationsRequired: true, createOptions() { return optionsBuilder.from((factory) => ({ - albumId: factory.text({ - defaultValue: "", + albumId: factory.integrationSelect({ withDescription: true, + clearable: true, + useOptions: (integrationIds: string[]) => { + const { + data = [], + isPending, + isError, + } = clientApi.widget.immich.getAlbums.useQuery( + { integrationId: integrationIds[0] ?? "" }, + { enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 }, + ); + return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError }; + }, }), rotationIntervalSeconds: factory.number({ defaultValue: 5, @@ -23,6 +36,10 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al defaultValue: false, withDescription: true, }), + randomizePhotos: factory.switch({ + defaultValue: false, + withDescription: true, + }), })); }, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 1d19f628a..a1518ee49 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -54,6 +54,7 @@ interface DynamicSelectInput extends CommonInput { useOptions: ( query: string, integrationIds: string[], + options: Record, ) => { isPending: boolean; options: DynamicSelectOption[]; diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index cc41e8b68..06c1318ee 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -21,7 +21,8 @@ import ReactMarkdown from "react-markdown"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common"; -import { getIconUrl } from "@homarr/definitions"; +import type { ReleaseProviderKind } from "@homarr/definitions"; +import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; @@ -55,11 +56,11 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas [options.newReleaseWithin, options.staleReleaseWithin], ); - // Group repositories by integration + // Group repositories by provider const groupedRepositories = useMemo(() => { return options.repositories.reduce( (acc, repo) => { - const key = repo.providerIntegrationId; + const key = repo.provider; if (!key) return acc; acc[key] ??= []; @@ -67,28 +68,29 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas return acc; }, - {} as Record, + {} as Partial>, ); }, [options.repositories]); // For each group, split into chunks of 5 const batchedRepositories = useMemo(() => { - return Object.entries(groupedRepositories).flatMap(([integrationId, group]) => + return Object.entries(groupedRepositories).flatMap(([provider, group]) => splitToChunksWithNItems(group, 5).map((chunk) => ({ - integrationId, + provider: provider as ReleaseProviderKind, repositories: chunk, })), ); }, [groupedRepositories]); - const queryResults = clientApi.useQueries((t) => - batchedRepositories.flatMap(({ integrationId, repositories }) => - t.widget.releases.getLatest({ - integrationId, + const queryResults = clientApi.useQueries((trpc) => + batchedRepositories.flatMap(({ provider, repositories }) => + trpc.widget.releases.getLatest({ repositories: repositories.map((repository) => ({ id: repository.id, + provider, identifier: repository.identifier, versionFilter: repository.versionFilter, + providerUrl: repository.providerUrl, })), }), ), @@ -102,13 +104,13 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas const repositories = useMemo(() => { const formattedResults = options.repositories .map((repository) => { - if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } }; + if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } }; const repositoryResult = results.flat().find(({ id }) => id === repository.id); if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } }; if (!repositoryResult.success) return { ...repository, error: repositoryResult.error }; - const { data: release, integration } = repositoryResult; + const { data: release, provider } = repositoryResult; const isReleaseWithin = (relativeDate: string) => Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate); @@ -116,7 +118,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas return { ...repository, ...release, - integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) }, + providerMetadata: { name: getReleaseProviderName(provider), iconUrl: getReleaseProviderIconUrl(provider) }, isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin), isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin), viewed: releasesViewedList[repository.id] === release.latestRelease, @@ -173,7 +175,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas className={combineClasses( "releases-repository", // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - `releases-repository-${repository.integration?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`, + `releases-repository-${repository.providerMetadata?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`, classes.releasesRepository, )} gap={0} @@ -187,7 +189,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas > - {repository.integration && ( + {repository.providerMetadata && ( - {repository.integration.name} + {repository.providerMetadata.name} )} @@ -624,7 +626,8 @@ const ExpandedDisplay = ({ c="red" style={{ whiteSpace: "pre-wrap" }} > - {repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message} + {repository.error.message ?? + (repository.error.code ? t(`error.messages.${repository.error.code}` as never) : null)} )} diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts index 8de5227ad..44461910d 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -1,6 +1,8 @@ import { IconRocket } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { releaseProviderKinds } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -42,7 +44,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases" defaultValue: [], validate: z.array( z.object({ - providerIntegrationId: z.string().optional(), + provider: z.enum(releaseProviderKinds).optional(), identifier: z.string().min(1), name: z.string().optional(), versionFilter: z @@ -53,6 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases" }) .optional(), iconUrl: z.string().optional(), + providerUrl: z.string().url().optional(), }), ), }), diff --git a/packages/widgets/src/releases/releases-repository.ts b/packages/widgets/src/releases/releases-repository.ts index 157d9a146..3da40932a 100644 --- a/packages/widgets/src/releases/releases-repository.ts +++ b/packages/widgets/src/releases/releases-repository.ts @@ -1,3 +1,5 @@ +import type { ReleaseProviderKind } from "@homarr/definitions"; + export interface ReleasesVersionFilter { prefix?: string; precision: number; @@ -6,11 +8,12 @@ export interface ReleasesVersionFilter { export interface ReleasesRepository { id: string; - providerIntegrationId?: string; + provider?: ReleaseProviderKind; identifier: string; name?: string; versionFilter?: ReleasesVersionFilter; iconUrl?: string; + providerUrl?: string; } export interface ReleasesRepositoryResponse extends ReleasesRepository { @@ -32,7 +35,7 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository { forksCount?: number; openIssues?: number; - integration?: { + providerMetadata?: { name: string; iconUrl?: string; }; diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx index 539d7e6c7..25eaa3d43 100644 --- a/packages/widgets/src/timetable/component.tsx +++ b/packages/widgets/src/timetable/component.tsx @@ -7,28 +7,25 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input"; import type { WidgetComponentProps } from "../definition"; -export default function TimetableWidget({ options, integrationIds }: WidgetComponentProps<"timetable">) { - // It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const integrationId = integrationIds[0]!; +export default function TimetableWidget({ options }: WidgetComponentProps<"timetable">) { const t = useScopedI18n("widget.timetable"); if (!options.station) { return
{t("noStation")}
; } - return ; + return ; } interface TimetableWidgetInnerProps { station: DynamicSelectOption; - integrationId: string; + baseUrl: string; } -const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => { +const TimetableWidgetInner = ({ station, baseUrl }: TimetableWidgetInnerProps) => { const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery( { - integrationId, + baseUrl, stationId: station.value, limit: 10, }, diff --git a/packages/widgets/src/timetable/index.ts b/packages/widgets/src/timetable/index.ts index f303b8c62..0b146bf81 100644 --- a/packages/widgets/src/timetable/index.ts +++ b/packages/widgets/src/timetable/index.ts @@ -1,4 +1,5 @@ import { IconBusStop } from "@tabler/icons-react"; +import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; @@ -7,18 +8,21 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("timetable", { icon: IconBusStop, - supportedIntegrations: ["searchCh"], createOptions() { return optionsBuilder.from((factory) => ({ + baseUrl: factory.text({ + defaultValue: "https://search.ch", + validate: z.string().url(), + }), station: factory.dynamicSelect({ - useOptions(query, integrationIds) { + useOptions(query, _integrationIds, options) { const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery( { - integrationId: integrationIds[0] ?? "", + baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch", query, }, { - enabled: integrationIds.length > 0, + enabled: query.length > 0, }, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 682dce7d5..adbe8a038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,9 +42,6 @@ catalogs: '@gfazioli/mantine-onboarding-tour': specifier: ^4.0.5 version: 4.0.5 - '@gitbeaker/rest': - specifier: ^43.8.0 - version: 43.8.0 '@homarr/gridstack': specifier: ^1.12.0 version: 1.12.0 @@ -96,9 +93,6 @@ catalogs: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0 - '@octokit/auth-app': - specifier: ^8.2.0 - version: 8.2.0 '@paralleldrive/cuid2': specifier: ^3.3.0 version: 3.3.0 @@ -1906,9 +1900,6 @@ importers: '@ctrl/transmission': specifier: 'catalog:' version: 7.4.0 - '@gitbeaker/rest': - specifier: 'catalog:' - version: 43.8.0 '@homarr/common': specifier: workspace:^0.1.0 version: link:../common @@ -1942,9 +1933,6 @@ importers: '@jellyfin/sdk': specifier: 'catalog:' version: 0.13.0(axios@1.15.0) - '@octokit/auth-app': - specifier: 'catalog:' - version: 8.2.0 ical.js: specifier: 'catalog:' version: 2.2.1 @@ -2271,6 +2259,9 @@ importers: undici: specifier: 'catalog:' version: 7.27.2 + ws: + specifier: 'catalog:' + version: 8.21.0 zod: specifier: 'catalog:' version: 4.4.3 @@ -2278,6 +2269,9 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/ws': + specifier: 'catalog:' + version: 8.18.1 typescript: specifier: 'catalog:' version: 6.0.3 @@ -4828,18 +4822,6 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@gitbeaker/core@43.8.0': - resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==} - engines: {node: '>=18.20.0'} - - '@gitbeaker/requester-utils@43.8.0': - resolution: {integrity: sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==} - engines: {node: '>=18.20.0'} - - '@gitbeaker/rest@43.8.0': - resolution: {integrity: sha512-xxqsNsUXaFang9b2e/NTIgqUeuUlifA2Opy1mOVqTDuJZZNIOTgUNyziwBJoleBhMC0XuvY3JNVMWthufcVjRw==} - engines: {node: '>=18.20.0'} - '@grpc/grpc-js@1.12.5': resolution: {integrity: sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==} engines: {node: '>=12.10.0'} @@ -12449,10 +12431,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch-browser@2.2.6: - resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} - engines: {node: '>=8.6'} - picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -13157,9 +13135,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - rate-limiter-flexible@8.1.0: - resolution: {integrity: sha512-J+4xBdVboibP1h0Imn4nFoCLT+UM9Os9vJaWaRWkLsQxS7jrhLJeLlmzP5hyCEsLwtgFIIY5KcWiJGyyVTMaKg==} - raw-body@2.5.3: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} @@ -15036,9 +15011,6 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - xcase@2.0.1: - resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} - xdg-basedir@5.1.0: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} @@ -18159,24 +18131,6 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@gitbeaker/core@43.8.0': - dependencies: - '@gitbeaker/requester-utils': 43.8.0 - qs: 6.15.2 - xcase: 2.0.1 - - '@gitbeaker/requester-utils@43.8.0': - dependencies: - picomatch-browser: 2.2.6 - qs: 6.15.2 - rate-limiter-flexible: 8.1.0 - xcase: 2.0.1 - - '@gitbeaker/rest@43.8.0': - dependencies: - '@gitbeaker/core': 43.8.0 - '@gitbeaker/requester-utils': 43.8.0 - '@grpc/grpc-js@1.12.5': dependencies: '@grpc/proto-loader': 0.7.13 @@ -26573,8 +26527,6 @@ snapshots: picocolors@1.1.1: {} - picomatch-browser@2.2.6: {} - picomatch@2.3.1: {} picomatch@4.0.4: {} @@ -27399,8 +27351,6 @@ snapshots: range-parser@1.2.1: {} - rate-limiter-flexible@8.1.0: {} - raw-body@2.5.3: dependencies: bytes: 3.1.2 @@ -29681,8 +29631,6 @@ snapshots: dependencies: is-wsl: 3.1.1 - xcase@2.0.1: {} - xdg-basedir@5.1.0: {} xml-js@1.6.11: