Compare commits

...
Author SHA1 Message Date
Thomas Camlong 2c1a5c92e8 chore(i18n): revert non-English translations to dev
Keep translation changes in en.json only; Crowdin handles other locales.
2026-07-01 17:25:43 +02:00
ajnart 0425bec2ca style: auto-fix lint and format 2026-07-01 13:00:06 +00:00
Thomas Camlong 06da6191cc Merge origin/dev into feat/widget-only-integration-refactor
Resolve integration.ts conflict (keep widget-only documentationUrl: null, take defaultUrl from dev) and regenerate pnpm-lock.yaml.
2026-07-01 14:57:00 +02:00
ajnart d670b69396 fix(timetable): restore station search by removing disabled query guard
The refactor from integration-based to option-based config changed the
useQuery enabled condition from 'integrationIds.length > 0' (always true
when an integration was linked) to 'query.length > 0'. The combination
of the debounced search value and the enabled toggle inside the nested
useOptions callback caused the query to never fire, making stations
unsearchable in the edit form.

Removing the enabled guard lets the query always run. The 300ms debounce
already prevents excessive requests.
2026-06-30 18:40:27 +02:00
Thomas Camlong 8747d5f93e fix: add id to repository schema and handle missing ids gracefully 2026-06-28 13:55:17 +02:00
Thomas Camlong af35a50fa0 fix: handle missing widgetSecrets table and add default releases repo
- getConfiguredKinds now returns [] if table doesn't exist yet
- Add homarr-labs/homarr as default releases repository (v#.#.#)
2026-06-28 08:47:09 +02:00
ajnart a56bf0f32d style: auto-fix lint and format 2026-06-27 11:04:33 +00:00
Thomas Camlong e687bc8c92 fix: address review - paginate GH releases, GitLab tag_name, docs, test
- Paginate GitHub releases via api.paginate to avoid missing tags beyond page 1
- Use tag_name instead of name for GitLab releases (name is a display label)
- Remove ArchiveTeam Warrior from widget-configured note in docs
- Fix test assertion to use objectContaining for error shape with message
2026-06-27 13:02:58 +02:00
Thomas Camlong 64c7512283 fix: compile versionRegex once and add fetch timeouts
- Compile regex once via compileVersionRegex, reject invalid patterns early
- Add 10s timeout to all provider fetch calls via fetchProvider wrapper
2026-06-27 12:56:48 +02:00
ajnart 398c88f2c6 style: auto-fix lint and format 2026-06-27 09:06:44 +00:00
Thomas Camlong 34d976eeea fix: apply version filter to LinuxServer.io provider
LSIO was bypassing versionRegex unlike all other providers.
2026-06-27 11:01:12 +02:00
Thomas Camlong 757d59854e fix: address PR review - atomic upsert, MCP metadata, stale tokens, save UX
- Wrap setSecret delete+insert in a cross-DB transaction
- Add MCP metadata to all widget secret procedures and register in mcp.ts
- Show configured tokens even when no repos use that provider
- Only clear token input after mutation succeeds, with loading state
2026-06-27 10:58:35 +02:00
Thomas Camlong 2155f13f24 fix: verbose error messages in all release providers
Every error now includes a diagnostic message:
- noMatchingVersion: shows the regex used, how many tags were
  checked, and sample tag names so users can see why matching failed
- noReleasesFound: distinguishes "no releases at all" from "no match"
- invalidIdentifier: shows the problematic identifier string
- unexpected: includes HTTP status codes from provider APIs

getLatestRelease returns { release } | { noMatch: reason } instead
of Release | null, carrying the diagnostic through.
2026-06-27 10:57:14 +02:00
Thomas Camlong d893515b93 fix: catch individual repo fetch failures to prevent batch crashes
Wrap each repository fetch in try/catch so one failure (Redis,
network, etc.) doesn't crash the entire query batch. Failed repos
now return a proper error response instead of causing all repos
to show "No response from provider".
2026-06-27 10:57:14 +02:00
Thomas Camlong 30107e4e76 fix: gracefully degrade when widget_secret table is missing or access denied
The releases widget was crashing because throwIfActionForbiddenAsync
or the widgetSecrets query would throw before DB migrations are run.
Wrap in try/catch so the widget works without tokens in that case.
2026-06-27 10:57:14 +02:00
ajnart 8c51a3769e style: auto-fix lint and format 2026-06-27 10:57:14 +02:00
Thomas Camlong 77884a3179 feat: add per-widget secret storage for release provider authentication
Adds encrypted token storage per release widget so users can authenticate
with GitHub, GitLab, Docker Hub, etc. without needing full integration setup.

- New `widget_secret` table (all 3 DB drivers) keyed by (itemId, kind)
- tRPC router with setSecret/deleteSecret/getConfiguredKinds (board modify access required)
- Releases router injects decrypted tokens into provider requests (board view access required)
- Tokens only used for repos matching widget's stored configuration (prevents proxy abuse)
- Provider auth: GitHub (Octokit auth), GitLab (PRIVATE-TOKEN), Docker Hub (Bearer),
  GHCR (registry token), npm (Bearer), Codeberg (token header)
- UI: "Provider Tokens" section in widget editor with PasswordInput per provider
2026-06-27 10:57:13 +02:00
ajnart d8a7477039 style: auto-fix lint and format 2026-06-27 10:57:13 +02:00
Thomas Camlong 8e1dcecf64 chore: update lockfile 2026-06-27 10:57:13 +02:00
Thomas Camlong c892731479 fix: address PR review findings for widget-only integration refactor
- Fix npm scoped package support (filter time metadata keys, make
  description optional, exclude falsy releases from version selection)
- Fix timetable date formatting (use Intl.DateTimeFormat for stable
  MM/DD/YYYY output) and named zod import
- Guard migration 0000 against unknown provider keys
- Fix migration 0003 to delete integration links by integrationId
- Reset immich album carousel index when photo list changes
- Add bounded limit to timetable router input
- Remove stale misspelled noProviderSeleceted translation key
- Fix provider name visibility in release widget settings
- Update docs: add ArchiveTeam Warrior note, fix typos, correct
  showPhotoInfo default
2026-06-27 10:57:13 +02:00
Thomas Camlong 62baaf7813 fix: update CHANGES.md for ArchiveTeam Warrior restoration, remove typo translation key 2026-06-27 10:57:13 +02:00
Thomas Camlong 8a5ad5861b fix: restore ArchiveTeam Warrior as a proper integration
The widget-only integrations refactor incorrectly removed the
ArchiveTeam Warrior integration entirely. It only needed to be
excluded from auto-seeding (by removing defaultUrl), not deleted.

This restores the integration class, reverts the widget/router/handler
to use integration middleware, and removes archiveTeamWarrior from the
widget-only-integrations migration.
2026-06-27 10:57:13 +02:00
ajnart d18d07d2f1 style: auto-fix lint and format 2026-06-27 10:57:12 +02:00
Thomas Camlong fb348044fb refactor widget-only integrations 2026-06-27 10:57:12 +02:00
90 changed files with 2485 additions and 2674 deletions

No files matched your search

+3
View File
@@ -2,6 +2,7 @@
# dependencies
node_modules
.pnpm-store/
.pnp
.pnp.js
.idea/
@@ -70,6 +71,8 @@ apps/nextjs/public/images/background.png
# personal agent config
.opencode
.agents/
skills-lock.json
# docusaurus
apps/docs/.docusaurus
+96
View File
@@ -0,0 +1,96 @@
# Changes
## Goal
Remove widget-only services from Homarr's global Integration system.
The affected services are release providers and Search.ch. 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.
ArchiveTeam Warrior remains a proper integration but is no longer auto-seeded on first install.
## 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.
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
- Kept as a proper integration (not removed).
- Removed `defaultUrl` from the integration definition to prevent auto-seeding on first install.
### Integration Cleanup
- Removed creators, exports, docs references, and implementation files for the deleted integrations.
- Removed unused dependencies from `packages/integrations/package.json`.
### 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.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 passes its widget URL option directly to its API router and request handler. No global integration lookup is needed.
ArchiveTeam Warrior continues to use the standard integration system, but users must manually add the integration (it is not auto-created on install).
## 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`.
@@ -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,50 +46,68 @@ 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.
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.
<img src={imageStartFromScratchStepAdminUser} alt={"Screenshot form to create admin user"} width={300} />
Depending on your auth provider configuration you will either first see the admin user creation form or the external group creation form.
If the 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 its boards.
You must choose a username and password (both can later be changed).
We enforce strict password requirements when you enter your password to protect your instance if you accidentally
expose it to the internet.
<img
src={imageStartFromScratchStepAdminUser}
alt={"Screenshot form to create admin user"}
width={300}
/>
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.
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.
This group will be used to synchronize users from your provider to Homarr.
When a user logs in through the provider, any of their groups that match groups in Homarr will be assigned to the user.
The name of the group must match exactly the name of the group in the provider.
<img src={imageStartFromScratchStepExternalAdminGroup} alt={"Screenshot form to create external admin group"} width={300} />
<img
src={imageStartFromScratchStepExternalAdminGroup}
alt={"Screenshot form to create external admin group"}
width={300}
/>
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.
<img src={imageStartFromScratchStepServerSettings} alt={"Screenshot of analytics and indexing settings in the onboarding"} width={300} />
<img
src={imageStartFromScratchStepServerSettings}
alt={"Screenshot of analytics and indexing settings in the onboarding"}
width={300}
/>
### 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).
<img src={imageStartFromScratchStepFinish} alt={"Screenshot of the finish onboarding process with links"} width={300} />
<img
src={imageStartFromScratchStepFinish}
alt={"Screenshot of the finish onboarding process with links"}
width={300}
/>
### 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).
<img src={manageExportOldHomarr} alt="Export from old Homarr" style={{ maxWidth: 500 }} />
<img
src={manageExportOldHomarr}
alt="Export from old Homarr"
style={{ maxWidth: 500 }}
/>
**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: <br/><img src={manageImportOldHomarrDropzone} style={{ maxWidth: 500 }} />
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.<br/><img src={manageImportOldHomarrChooseData} style={{ maxWidth: 500 }} />
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.<br/><img src={manageImportDecryptionToken} style={{ maxWidth: 500 }} />
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:
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
<img
src={manageBoardHeaderEditMode}
alt={"Screenshot of buttons in the edit mode"}
width={200}
/>
- **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":
<img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
<img
src={manageBoardHeaderAddItem}
alt={"Screenshot of the add item menu"}
width={200}
/>
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.
<img src={manageBoardHeaderChooseItem} alt={"Screenshot of choosing the app item in the modal"} width={300} />
<img
src={manageBoardHeaderChooseItem}
alt={"Screenshot of choosing the app item in the modal"}
width={300}
/>
As soon as you chose the app, the modal will close and you'll get the following item on your board:
<img src={manageBoardNoApp} alt="Screenshot of the No app element on the board" />
<img
src={manageBoardNoApp}
alt="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/)
@@ -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';
<IntegrationHeader
integration={codebergIntegration}
categories={['Version Control', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['personalAccessToken'],
steps: []
}]} />
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={dockerHubIntegration}
categories={['Container Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['username', 'personalAccessToken'],
steps: []
}]} />
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={gitHubContainerRegistryIntegration}
categories={['Package Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ["personalAccessToken"],
steps: []
}, {
tabLabel: "GitHub App",
credentials: ['githubAppId', 'githubInstallationId', 'privateKey'],
steps: ['Create GitHub App', 'Create a private key for it', 'Install it to your organization / user']
}]} />
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={githubIntegration}
categories={['Version Control', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['personalAccessToken'],
steps: []
}, {
tabLabel: "GitHub App",
credentials: ['githubAppId', 'githubInstallationId', 'privateKey'],
steps: ['Create GitHub App', 'Create a private key for it', 'Install it to your organization / user']
}]} />
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={gitlabIntegration}
categories={['Version Control', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ['personalAccessToken'],
steps: []
}]} />
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={linuxServerIoIntegration}
categories={['Container Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}]} />
@@ -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",
};
-34
View File
@@ -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';
<IntegrationHeader
integration={npmIntegration}
categories={['Package Management', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}]} />
-9
View File
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={quayIntegration}
categories={['Container Registry', 'Software']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: releasesWidget
}]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: [],
steps: []
}, {
credentials: ["personalAccessToken"],
steps: []
}]} />
@@ -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",
};
@@ -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';
<IntegrationHeader
integration={searchChIntegration}
categories={['Timetable']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: timetableWidget }]}
/>
### Adding the integration
<AddingIntegration />
@@ -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",
};
@@ -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: "",
},
@@ -25,7 +24,13 @@ export const immichAlbumCarouselWidget: WidgetDefinition = {
name: "Show photo info",
description: "Display date of the picture and the current index at the bottom of the slideshow",
values: { type: "boolean" },
defaultValue: "yes",
defaultValue: "no",
},
{
name: "Randomize photos",
description: "Show photos in a random order instead of always starting with the same album order.",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
+19 -45
View File
@@ -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 ".";
<WidgetHeader
widget={releasesWidget}
categories={['Releases', 'Software']}
/>
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
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";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={fullCard} alt="releases full" width={128 * 3} />
<img src={collapsibleCard} alt="releases collapsible" width={128 * 3} />
<img src={noDetailsCard} alt="releases no details" width={128 * 3} />
</div>
### Supported Integrations
### Supported Providers
<WidgetIntegrations items={[{
integration: githubIntegration,
}, {
integration: gitlabIntegration,
}, {
integration: dockerHubIntegration,
}, {
integration: npmIntegration,
}, {
integration: codebergIntegration,
}, {
integration: gitHubContainerRegistryIntegration,
note: "Use if release is only available in ghcr.io"
}, {
integration: quayIntegration
}, {
integration: linuxServerIoIntegration
}]} />
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
<AddingWidget />
### Configuration
<WidgetConfig configuration={releasesWidget.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`.
:::
+11 -4
View File
@@ -54,16 +54,23 @@ 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.",
+20 -24
View File
@@ -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 ".";
<WidgetHeader
widget={timetableWidget}
categories={['Timetable']}
/>
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
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";
<div className="flex gap-2">
<img src={trainDepartures} alt="timetable-widget-train-departures" width={128 * 2} />
<img src={streetDepartures} alt="timetable-widget-bus-and-tram-departures" width={128 * 2} />
<img
src={trainDepartures}
alt="timetable-widget-train-departures"
width={128 * 2}
/>
<img
src={streetDepartures}
alt="timetable-widget-bus-and-tram-departures"
width={128 * 2}
/>
</div>
### Adding the widget
<AddingWidget />
### Supported Integrations
<WidgetIntegrations items={[{
integration: searchChIntegration,
note: 'Switzerland only'
}]} />
### Configuration
<WidgetConfig configuration={timetableWidget.configuration} />
@@ -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",
@@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
itemId: item.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
@@ -48,10 +48,8 @@ export const categoryTranslationKeys: Record<string, string> = {
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",
+2
View File
@@ -17,6 +17,7 @@ import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
@@ -35,4 +36,5 @@ export const mcpRouter = createTRPCRouter({
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
@@ -270,78 +270,4 @@ describe("testConnectionAsync should run test connection of integration", () =>
externalUrl: null,
});
});
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,
});
});
});
+1
View File
@@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
+70 -19
View File
@@ -1,11 +1,15 @@
import SuperJSON from "superjson";
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -25,37 +29,84 @@ const releaseVersionFilterSchema = z.object({
export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
itemId: z.string().optional(),
repositories: z.array(
z.object({
id: z.string(),
id: z.string().optional(),
provider: z.enum(releaseProviderKinds),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
providerUrl: z.string().url().optional(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
const tokensByProvider = new Map<string, string>();
let allowedRepoIds: Set<string> | null = null;
if (input.itemId) {
try {
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
if (item && item.kind === "releases") {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
const repos = options.repositories as Array<{ id?: string }> | undefined;
if (repos) {
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
}
const secrets = await ctx.db.query.widgetSecrets.findMany({
where: eq(widgetSecrets.itemId, input.itemId),
});
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch {
// Skip corrupt secrets
}
}
}
} catch {
// Access denied or table not yet migrated -- proceed without tokens
}
}
return await Promise.all(
input.repositories.map(async (repository) => {
const response = await releasesRequestHandler
.handler(ctx.integration, {
id: repository.id,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
})
.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
try {
const useToken = allowedRepoIds === null || allowedRepoIds.has(repository.id);
const response = await releasesRequestHandler
.handler({
id: repository.id,
provider: repository.provider,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
})
.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return {
id: repository.id,
integration: { name: ctx.integration.name, kind: ctx.integration.kind },
timestamp: response.timestamp,
...response.data,
};
return {
id: repository.id,
provider: repository.provider,
timestamp: response.timestamp,
...response.data,
};
} catch (error) {
return {
id: repository.id,
provider: repository.provider,
timestamp: new Date(),
success: false as const,
error: { code: "unexpected" as const, message: String(error) },
};
}
}),
);
}),
+16 -10
View File
@@ -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().int().min(1).max(100),
}),
)
.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,
});
@@ -0,0 +1,97 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
import { and, eq, handleTransactionsAsync } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const secretKindSchema = z.enum(releaseProviderKinds);
const resolveItemWithBoardAccess = async (ctx: { db: any; session: any }, itemId: string) => {
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, itemId) });
if (!item || item.kind !== "releases") throw new TRPCError({ code: "NOT_FOUND" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify");
return item;
};
export const widgetSecretsRouter = createTRPCRouter({
setSecret: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Set an authentication token for a release provider on a specific releases widget. Requires modify permission on the widget's board. Use widget_secrets_getConfiguredKinds to check existing tokens",
},
})
.input(z.object({ itemId: z.string(), kind: secretKindSchema, value: z.string().min(1).max(4096) }))
.mutation(async ({ ctx, input }) => {
await resolveItemWithBoardAccess(ctx, input.itemId);
const encrypted = encryptSecret(input.value);
const row = { itemId: input.itemId, kind: input.kind, value: encrypted, updatedAt: new Date() };
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (tx) => {
await tx
.delete(schema.widgetSecrets)
.where(and(eq(schema.widgetSecrets.itemId, input.itemId), eq(schema.widgetSecrets.kind, input.kind)));
await tx.insert(schema.widgetSecrets).values(row);
});
},
handleSync(db) {
db.transaction((tx) => {
tx.delete(widgetSecrets)
.where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)))
.run();
tx.insert(widgetSecrets).values(row).run();
});
},
});
}),
deleteSecret: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Remove an authentication token for a release provider from a specific releases widget. Requires modify permission on the widget's board",
},
})
.input(z.object({ itemId: z.string(), kind: secretKindSchema }))
.mutation(async ({ ctx, input }) => {
await resolveItemWithBoardAccess(ctx, input.itemId);
await ctx.db
.delete(widgetSecrets)
.where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)));
}),
getConfiguredKinds: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"List which release provider kinds have tokens configured for a releases widget. Returns an array of provider kind strings (e.g. github, gitlab, dockerHub). Requires modify permission on the widget's board",
},
})
.input(z.object({ itemId: z.string() }))
.query(async ({ ctx, input }) => {
await resolveItemWithBoardAccess(ctx, input.itemId);
try {
const secrets = await ctx.db.query.widgetSecrets.findMany({
where: eq(widgetSecrets.itemId, input.itemId),
columns: { kind: true },
});
return secrets.map((s: { kind: string }) => s.kind);
} catch {
// ponytail: table may not exist pre-migration, return empty
return [] as string[];
}
}),
});
@@ -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<WidgetComponentProps<"timetable">["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 }));
}
}
});
@@ -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<IntegrationKind, string>(
providerIntegrations.map((integration) => [integration.kind, integration.id]),
);
const updates: {
id: string;
options: object;
@@ -37,18 +23,19 @@ export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
if (options.repositories.length === 0) continue;
if (!options.repositories.some((repository) => "providerKey" in repository)) continue;
const updatedRepositories = options.repositories.map(
({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
// Ensure providerKey is camelCase
const updatedRepositories = options.repositories
.map(({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1);
const matchedProvider = releaseProviderKinds.find((kind) => kind === provider);
if (!matchedProvider) return null;
return {
id: createId(),
providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null,
provider: matchedProvider,
...otherFields,
};
},
);
})
.filter((repo) => repo !== null);
updates.push({
id: item.id,
@@ -68,5 +55,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}"`);
}
@@ -0,0 +1,100 @@
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",
] 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 existingItems = await db.query.items.findMany({
where: (items, { inArray }) => inArray(items.kind, ["releases", "timetable"]),
});
const updatedItems: { id: string; options: object }[] = [];
for (const item of existingItems) {
const options = SuperJSON.parse<Record<string, unknown>>(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<string, unknown>;
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 (item.kind === "timetable" && linkedIntegration?.kind === "searchCh") {
updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } });
}
}
for (const update of updatedItems) {
await db
.update(items)
.set({ options: SuperJSON.stringify(update.options) })
.where(eq(items.id, update.id));
}
await db.delete(integrationItems).where(inArray(integrationItems.integrationId, oldIntegrationIds));
await db.delete(integrations).where(inArray(integrations.id, oldIntegrationIds));
console.log(`Migrated widget-only integrations to widget options count="${updatedItems.length}"`);
}
+2
View File
@@ -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);
};
+1
View File
@@ -51,6 +51,7 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
widgetSecrets,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
+27
View File
@@ -541,6 +541,25 @@ export const customWidgetSecrets = mysqlTable(
}),
);
export const widgetSecrets = mysqlTable(
"widget_secret",
{
itemId: varchar({ length: 64 })
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
kind: varchar({ length: 64 }).notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.kind],
}),
}),
);
export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
@@ -746,12 +765,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
secrets: many(widgetSecrets),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({
item: one(items, {
fields: [widgetSecrets.itemId],
references: [items.id],
}),
}));
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
+27
View File
@@ -540,6 +540,25 @@ export const customWidgetSecrets = pgTable(
}),
);
export const widgetSecrets = pgTable(
"widget_secret",
{
itemId: varchar({ length: 64 })
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
kind: varchar({ length: 64 }).notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.kind],
}),
}),
);
export const cronJobConfigurations = pgTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
@@ -745,12 +764,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
secrets: many(widgetSecrets),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({
item: one(items, {
fields: [widgetSecrets.itemId],
references: [items.id],
}),
}));
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
+27
View File
@@ -530,6 +530,25 @@ export const customWidgetSecrets = sqliteTable(
}),
);
export const widgetSecrets = sqliteTable(
"widget_secret",
{
itemId: text()
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
kind: text().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.kind],
}),
}),
);
export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
name: text().notNull().primaryKey(),
cronExpression: text().notNull(),
@@ -735,12 +754,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
secrets: many(widgetSecrets),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({
item: one(items, {
fields: [widgetSecrets.itemId],
references: [items.id],
}),
}));
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
@@ -31,9 +31,6 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
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<IntegrationKind>([
"dockerHub",
"github",
"gitlab",
"npm",
"codeberg",
"linuxServerIO",
"gitHubContainerRegistry",
"quay",
"ical",
"searchCh",
"mock",
]);
const notDockerDiscoverable = new Set<IntegrationKind>(["ical", "mock"]);
interface ContainerMatchInput {
image: string;
@@ -316,14 +316,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"
@@ -331,7 +323,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"
@@ -31,14 +31,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
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<IntegrationKind, string | null> = {
truenas: "truenas",
unraid: "unraid",
coolify: "coolify",
searchCh: "search-ch",
immich: "immich",
paperlessNgx: "paperless-ngx",
tracearr: "tracearr",
+1
View File
@@ -17,3 +17,4 @@ export * from "./emptysuperjson";
export * from "./hotkeys";
export * from "./docker-integration-match";
export * from "./integration-url-template";
export * from "./release-provider";
+1 -73
View File
@@ -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"]],
@@ -501,8 +430,8 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: null,
defaultUrl: "http://localhost:8001",
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
},
// 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: {
@@ -602,7 +531,6 @@ export const integrationCategories = [
"search",
"mediaTranscoding",
"networkController",
"releasesProvider",
"notifications",
"firewall",
"timetable",
@@ -0,0 +1,78 @@
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<Record<ReleaseProviderKind, string>> = {
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;
// ponytail: only strip container image versions/digests for providers that use them
return registryPrefix ? removeContainerImageVersion(withoutRegistry) : withoutRegistry;
};
@@ -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",
);
});
});
@@ -30,7 +30,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
coolify: ["coolify"],
timetable: ["searchCh"],
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
@@ -39,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
};
@@ -80,18 +80,7 @@ export const featuredIntegrations: readonly IntegrationKind[] = [
"jellyfin",
];
export const hiddenFromOnboarding = new Set<IntegrationKind>([
"searchCh",
"github",
"dockerHub",
"gitlab",
"npm",
"codeberg",
"linuxServerIO",
"gitHubContainerRegistry",
"quay",
"mock",
]);
export const hiddenFromOnboarding = new Set<IntegrationKind>(["mock"]);
export const defaultBookmarkApps = [
{
-2
View File
@@ -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:",
+1 -19
View File
@@ -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,17 +14,14 @@ 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 { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
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 +30,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 +42,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 +105,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 +114,6 @@ export const integrationCreators = {
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
searchCh: SearchChIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
@@ -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<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
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<ReleaseResponse> {
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<DetailsProviderResponse | undefined> {
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,
};
}
}
@@ -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(),
});
@@ -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<string>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
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<TestingResult> {
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<ReleaseResponse> {
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<DetailsProviderResponse | undefined> {
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<string> {
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;
}
}
@@ -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)),
});
@@ -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<TestingResult> {
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<ReleaseResponse> {
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<ReleaseProviderResponse[]>((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<DetailsProviderResponse | undefined> {
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<OctokitOptions, "auth" | "authStrategy"> {
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<typeof createAppAuth>[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<ConstructorParameters<typeof Octokit>[0], undefined>;
@@ -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<TestingResult> {
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<ReleaseResponse> {
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<ReleaseProviderResponse[]>((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<DetailsProviderResponse | undefined> {
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<OctokitOptions, "auth" | "authStrategy"> {
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<typeof createAppAuth>[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<ConstructorParameters<typeof Octokit>[0], undefined>;
@@ -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<TestingResult> {
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<ReleaseResponse> {
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<ReleaseProviderResponse[]>((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<DetailsProviderResponse | undefined> {
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<string, unknown>): Promise<FormattedResponse> => {
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") } : {}),
});
}
}
+6 -7
View File
@@ -1,6 +1,6 @@
// General integrations
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
@@ -64,7 +64,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,17 +74,17 @@ 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";
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
export type {
ArchiveTeamWarriorBandwidth,
ArchiveTeamWarriorItem,
ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
@@ -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<ReleaseResponse>;
}
@@ -1,34 +0,0 @@
import type { TranslationObject } from "@homarr/translation";
export interface ReleasesRepository extends Record<string, unknown> {
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 };
@@ -1,6 +0,0 @@
import type { Station, Timetable, TimetableOptions } from "./timetable-types";
export interface TimetableIntegration {
searchStationsAsync(query: string): Promise<Station[]>;
getTimetableAsync(options: TimetableOptions): Promise<Timetable>;
}
@@ -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<typeof timetableOptionsSchema>;
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[];
}
@@ -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<TestingResult> {
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<ReleaseResponse> {
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,
},
};
}
}
@@ -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(),
}),
),
}),
}),
});
@@ -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<TestingResult> {
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<ReleaseResponse> {
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 };
}
}
@@ -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(),
});
@@ -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<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
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<ReleaseResponse> {
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<ReleaseProviderResponse[]>((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 } };
}
}
@@ -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(),
}),
),
});
@@ -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<TestingResult> {
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<Station[]> {
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<Timetable> {
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}
}),
);
-1
View File
@@ -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";
@@ -0,0 +1,829 @@
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" });
const PROVIDER_TIMEOUT_MS = 10_000;
const fetchProvider = (url: URL | RequestInfo, options?: RequestInit & { timeout?: number }) =>
fetchWithTrustedCertificatesAsync(url, { timeout: PROVIDER_TIMEOUT_MS, ...options });
export interface ReleasesRepositoryRequest extends Record<string, unknown> {
id: string;
provider: ReleaseProviderKind;
identifier: string;
versionRegex?: string;
providerUrl?: string;
token?: 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" | "unexpected";
message: string;
};
export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
const compileVersionRegex = (versionRegex?: string): RegExp | null => {
if (!versionRegex) return null;
try {
return new RegExp(versionRegex);
} catch {
return null;
}
};
export const getLatestRelease = (
releases: ReleaseProviderResponse[],
versionRegex?: string,
): { release: ReleaseProviderResponse } | { noMatch: string } => {
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return { noMatch: `Invalid regex: /${versionRegex}/` };
}
const validReleases = releases.filter((result) => {
if (!result.latestRelease) return false;
return regex ? regex.test(result.latestRelease) : true;
});
if (validReleases.length > 0) {
return {
release: validReleases.reduce((latest, current) =>
current.latestReleaseAt > latest.latestReleaseAt ? current : latest,
),
};
}
const sampleTags = releases
.slice(0, 5)
.map((r) => r.latestRelease)
.join(", ");
const reason = versionRegex
? `Regex /${versionRegex}/ matched 0/${releases.length} tags. Sample tags: [${sampleTags}]`
: `Found ${releases.length} releases but none had a valid tag`;
return { noMatch: reason };
};
export const getLatestMatchingReleaseAsync = async (input: ReleasesRepositoryRequest): Promise<ReleaseResponse> => {
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, input.token);
case "gitHubContainerRegistry":
return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
case "dockerHub":
return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
case "gitlab":
return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
case "npm":
return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
case "codeberg":
return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
case "linuxServerIO":
return await getLinuxServerIOReleaseAsync(baseUrl, identifier, input.versionRegex);
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, token?: string) =>
new Octokit({
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: false },
userAgent,
});
const getGithubReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
token?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed)
return {
success: false,
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
};
const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider", token);
try {
const allReleases = await api.paginate(api.rest.repos.listReleases, {
owner: parsed.owner,
repo: parsed.name,
per_page: 100,
});
if (allReleases.length === 0) {
return { success: false, error: { code: "noReleasesFound", message: `${identifier} has no GitHub releases` } };
}
const releases = allReleases.reduce<ReleaseProviderResponse[]>((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 result = getLatestRelease(releases, versionRegex);
if ("noMatch" in result) {
return { success: false, error: { code: "noMatchingVersion", message: result.noMatch } };
}
const details = await getGithubDetailsAsync(api, parsed.owner, parsed.name);
return { success: true, data: { ...details, ...result.release } };
} 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<DetailsProviderResponse> => {
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,
token?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerPackageName(identifier);
if (!parsed)
return {
success: false,
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/package` },
};
try {
const repositoryName = `${parsed.owner}/${parsed.name}`;
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return { success: false, error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` } };
}
const registryUrl = new URL(baseUrl);
const registryToken = token ?? (await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName));
const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, registryToken);
if (tags.length === 0) {
return { success: false, error: { code: "noReleasesFound", message: `${repositoryName} has no container tags` } };
}
const matchingTags = regex ? tags.filter((tag) => regex.test(tag)) : tags;
const tagsToCheck = versionRegex
? matchingTags.slice(-100)
: matchingTags.includes("latest")
? ["latest"]
: matchingTags.slice(-100);
if (tagsToCheck.length === 0) {
const sampleTags = tags.slice(0, 5).join(", ");
const msg = versionRegex
? `Regex /${versionRegex}/ matched 0/${tags.length} tags. Sample: [${sampleTags}]`
: `No "latest" tag found among ${tags.length} tags. Sample: [${sampleTags}]`;
return { success: false, error: { code: "noMatchingVersion", message: msg } };
}
const releases = await Promise.all(
tagsToCheck.map(async (tag) => ({
latestRelease: tag,
latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(
registryUrl,
repositoryName,
tag,
registryToken,
),
releaseUrl: `https://github.com/${repositoryName}/pkgs/container/${encodeURIComponent(parsed.name)}`,
})),
);
const result = getLatestRelease(releases, versionRegex);
if ("noMatch" in result) {
return { success: false, error: { code: "noMatchingVersion", message: result.noMatch } };
}
return {
success: true,
data: {
projectUrl: `https://github.com/${repositoryName}`,
...result.release,
},
};
} 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 fetchProvider(
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 fetchProvider(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 fetchProvider(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 fetchProvider(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,
token?: string,
): Promise<ReleaseResponse> => {
const parsed = identifier.includes("/") ? parseOwnerName(identifier) : { owner: "", name: identifier };
if (!parsed) return { success: false, error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}"` } };
const relativeUrl = parsed.owner
? `/v2/namespaces/${encodeURIComponent(parsed.owner)}/repositories/${encodeURIComponent(parsed.name)}`
: `/v2/repositories/library/${encodeURIComponent(parsed.name)}`;
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
let lastNoMatch = "";
for (let page = 0; page <= 5; page++) {
const response = await fetchProvider(
buildUrl(baseUrl, `${relativeUrl}/tags?page_size=100&page=${page}` as `/${string}`),
headers.Authorization ? { headers } : undefined,
);
if (!response.ok)
return {
success: false,
error: { code: "unexpected", message: `Docker Hub ${response.status}: ${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 matched = getLatestRelease(result.data.results, versionRegex);
if ("noMatch" in matched) {
lastNoMatch = matched.noMatch;
continue;
}
const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`, token);
return { success: true, data: { ...details, ...matched.release } };
}
return {
success: false,
error: { code: "noMatchingVersion", message: lastNoMatch || `No matching tags found for ${identifier}` },
};
};
const getDockerHubDetailsAsync = async (
baseUrl: string,
relativeUrl: `/${string}`,
token?: string,
): Promise<DetailsProviderResponse> => {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetchProvider(
buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`),
headers.Authorization ? { headers } : undefined,
);
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,
token?: string,
): Promise<ReleaseResponse> => {
const encodedIdentifier = encodeURIComponent(identifier);
const headers: Record<string, string> = {};
if (token) headers["PRIVATE-TOKEN"] = token;
const response = await fetchProvider(
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}/releases?per_page=100`),
headers["PRIVATE-TOKEN"] ? { headers } : undefined,
);
if (!response.ok)
return {
success: false,
error: { code: "unexpected", message: `GitLab ${response.status}: ${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", message: `${identifier} has no GitLab releases` } };
const releases = result.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.released_at) return acc;
const releaseDate = new Date(release.released_at);
acc.push({
latestRelease: release.tag_name,
latestReleaseAt: releaseDate,
releaseUrl: release._links?.self,
releaseDescription: release.description ?? undefined,
isPreRelease: releaseDate > new Date(),
});
return acc;
}, []);
const matched = getLatestRelease(releases, versionRegex);
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier, token);
return { success: true, data: { ...details, ...matched.release } };
};
const getGitlabDetailsAsync = async (
baseUrl: string,
encodedIdentifier: string,
token?: string,
): Promise<DetailsProviderResponse> => {
const headers: Record<string, string> = {};
if (token) headers["PRIVATE-TOKEN"] = token;
const response = await fetchProvider(
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`),
headers["PRIVATE-TOKEN"] ? { headers } : undefined,
);
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 npmTimeMetadataKeys = new Set(["created", "modified"]);
const npmReleasesSchema = z.object({
time: z
.record(
z.string(),
z.string().transform((value) => new Date(value)),
)
.transform((entries) =>
Object.entries(entries)
.filter(([key]) => !npmTimeMetadataKeys.has(key))
.map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })),
),
versions: z.record(z.string(), z.object({ description: z.string().optional() })),
name: z.string(),
});
const getNpmReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
token?: string,
): Promise<ReleaseResponse> => {
if (!identifier) return { success: false, error: { code: "invalidIdentifier", message: "Empty npm package name" } };
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetchProvider(
buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`),
headers.Authorization ? { headers } : undefined,
);
if (!response.ok)
return { success: false, error: { code: "unexpected", message: `npm ${response.status}: ${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 matched = getLatestRelease(releases, versionRegex);
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
return { success: true, data: matched.release };
};
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,
token?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed)
return {
success: false,
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
};
const headers: Record<string, string> = {};
if (token) headers.Authorization = `token ${token}`;
const response = await fetchProvider(
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/releases`),
headers.Authorization ? { headers } : undefined,
);
if (!response.ok)
return {
success: false,
error: { code: "unexpected", message: `Codeberg ${response.status}: ${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 matched = getLatestRelease(releases, versionRegex);
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name, token);
return { success: true, data: { ...details, ...matched.release } };
};
const getCodebergDetailsAsync = async (
baseUrl: string,
owner: string,
name: string,
token?: string,
): Promise<DetailsProviderResponse> => {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `token ${token}`;
const response = await fetchProvider(
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
headers.Authorization ? { headers } : undefined,
);
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,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed)
return {
success: false,
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/name` },
};
const response = await fetchProvider(buildUrl(baseUrl, "/api/v1/images"));
if (!response.ok)
return {
success: false,
error: { code: "unexpected", message: `LSIO ${response.status}: ${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: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
};
const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) {
return {
success: false,
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` },
};
}
if (regex && !regex.test(release.version)) {
return {
success: false,
error: {
code: "noMatchingVersion",
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
},
};
}
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<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed)
return {
success: false,
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
};
const response = await fetchProvider(
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: `Quay ${response.status}: ${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<ReleaseProviderResponse[]>((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 matched = getLatestRelease(releases, versionRegex);
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
return { success: true, data: { projectDescription: result.data.description, ...matched.release } };
};
+15 -16
View File
@@ -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<ReleaseResponse, ReleasesRepositoryRequest>({
requestAsync: async (input) => await getLatestMatchingReleaseAsync(input),
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<ReleaseResponse>("releases", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
@@ -0,0 +1,192 @@
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: expect.objectContaining({ 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<string, string>;
}
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, "\\$&");
+158 -30
View File
@@ -1,36 +1,164 @@
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<Station[], { baseUrl: string; query: string }>({
async requestAsync(input) {
return await searchStationsAsync(input.baseUrl, input.query);
},
queryKey: "timetableSearchStations",
cacheDuration: dayjs.duration(1, "day"),
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<Station[]>("timetable", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
export const timetableGetTimetableRequestHandler = {
handler: (itemOptions: { baseUrl: string; stationId: string; limit: number }) =>
createCachedRequestHandler<Timetable, { baseUrl: string; stationId: string; limit: number }>({
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>("timetable", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
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<Station[]> => {
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<Timetable> => {
const now = new Date();
const date = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Zurich",
month: "2-digit",
day: "2-digit",
year: "numeric",
}).format(now);
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Zurich",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
stop: options.stationId,
limit: options.limit,
show_delays: 1,
show_tracks: 1,
date,
time,
}),
);
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(),
}),
);
+22 -4
View File
@@ -1902,6 +1902,9 @@
"title": "Departures {station}",
"noStation": "No station selected",
"option": {
"baseUrl": {
"label": "Search.ch URL"
},
"station": {
"label": "Station"
}
@@ -2775,8 +2778,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",
@@ -2785,6 +2788,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",
@@ -3149,6 +3156,9 @@
"provider": {
"label": "Provider"
},
"providerUrl": {
"label": "Provider URL"
},
"identifier": {
"label": "Identifier",
"placeholder": "Name or Owner/Name"
@@ -3196,6 +3206,13 @@
"noProvider": {
"label": "No Provider",
"tooltip": "The provider could not be parsed, please manually set it after importing the images"
},
"tokens": {
"label": "Provider Tokens",
"token": "Token",
"configured": "Token configured",
"notConfigured": "No token set",
"save": "Save"
}
}
},
@@ -3218,8 +3235,9 @@
"invalidIdentifier": "Invalid identifier",
"noMatchingVersion": "No matching version found",
"noReleasesFound": "No releases found",
"noProviderSeleceted": "No provider selected",
"noProviderResponse": "No response from provider"
"noProviderSelected": "No provider selected",
"noProviderResponse": "No response from provider",
"unexpected": "Unexpected provider error"
}
}
},
+1
View File
@@ -8,6 +8,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
@@ -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;
@@ -12,6 +12,7 @@ import {
Group,
Image,
Loader,
PasswordInput,
Select,
Stack,
Text,
@@ -27,6 +28,7 @@ import {
IconCopy,
IconCopyCheckFilled,
IconEdit,
IconKey,
IconPackageImport,
IconPlus,
IconTrash,
@@ -38,16 +40,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,13 +63,10 @@ interface FormValidation {
errors: FormErrors;
}
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
itemId,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
@@ -78,38 +81,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<Record<string, Integration>>((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 +136,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
});
};
@@ -183,7 +167,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onClick={() =>
openImportModal({
repositories,
integrations,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
@@ -210,22 +193,22 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Divider my="sm" />
{repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
const providerName = repository.provider ? getReleaseProviderName(repository.provider) : "";
return (
<Stack key={repository.id} gap={5}>
<Group align="center" gap="xs">
<Image
src={repository.iconUrl ?? integration?.iconUrl ?? null}
src={
repository.iconUrl ?? (repository.provider ? getReleaseProviderIconUrl(repository.provider) : null)
}
style={{
height: "1.2em",
width: "1.2em",
}}
/>
<Text c="dimmed" fw={100} size="xs">
{integration?.name ?? ""}
<Text c="dimmed" size="xs">
{providerName}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -242,7 +225,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
})
}
variant="light"
@@ -269,10 +252,125 @@ export const WidgetMultiReleasesRepositoriesInput = ({
);
})}
</Stack>
{itemId && <ProviderTokensSection itemId={itemId} repositories={repositories} />}
</Fieldset>
);
};
const providersWithAuth: ReleaseProviderKind[] = [
"github",
"gitHubContainerRegistry",
"dockerHub",
"gitlab",
"npm",
"codeberg",
];
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch() });
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch() });
const authProviders = useMemo(() => {
const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined);
const configuredProviders = configuredKinds.filter((k): k is ReleaseProviderKind =>
providersWithAuth.includes(k as ReleaseProviderKind),
);
return [...new Set([...usedProviders, ...configuredProviders])].filter((p) => providersWithAuth.includes(p));
}, [repositories, configuredKinds]);
if (authProviders.length === 0) return null;
return (
<>
<Divider my="sm" label={tRepository("tokens.label")} labelPosition="center" />
<Stack gap="xs">
{authProviders.map((provider) => (
<ProviderTokenInput
key={provider}
provider={provider}
hasToken={configuredKinds.includes(provider)}
onSave={async (value) => {
await setSecret.mutateAsync({ itemId, kind: provider, value });
}}
onDelete={async () => {
await deleteSecret.mutateAsync({ itemId, kind: provider });
}}
/>
))}
</Stack>
</>
);
};
const ProviderTokenInput = ({
provider,
hasToken,
onSave,
onDelete,
}: {
provider: ReleaseProviderKind;
hasToken: boolean;
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!value.trim()) return;
setSaving(true);
try {
await onSave(value.trim());
setValue("");
setEditing(false);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await onDelete();
setValue("");
setEditing(false);
} finally {
setSaving(false);
}
};
return (
<Group gap="xs" align="end">
<PasswordInput
label={`${getReleaseProviderName(provider)} ${tRepository("tokens.token")}`}
placeholder={hasToken ? tRepository("tokens.configured") : tRepository("tokens.notConfigured")}
value={value}
onChange={(e) => {
setValue(e.currentTarget.value);
setEditing(true);
}}
style={{ flex: 1 }}
size="xs"
leftSection={<IconKey size={14} />}
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
</Button>
)}
{hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size={14} />
</ActionIcon>
)}
</Group>
);
};
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
if (!versionFilter) return undefined;
@@ -294,7 +392,7 @@ interface RepositoryEditProps {
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
providerOptions: { value: ReleaseProviderKind; label: string }[];
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
@@ -302,11 +400,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
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 +454,14 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<div style={{ flex: 0.3 }}>
<WidgetIntegrationSelect
canSelectMultiple={false}
<Select
withAsterisk
label={tRepository("provider.label")}
data={integrationSelectOptions}
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
data={innerProps.providerOptions}
value={tempRepository.provider ?? null}
error={formErrors[`${innerProps.fieldPath}.provider`] as string}
onChange={(value) => {
handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined });
handleChange({ provider: value ? (value as ReleaseProviderKind) : undefined });
}}
/>
</div>
@@ -397,6 +489,16 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
/>
</Group>
<TextInput
label={tRepository("providerUrl.label")}
placeholder={tempRepository.provider ? getReleaseProviderDefaultUrl(tempRepository.provider) : undefined}
value={tempRepository.providerUrl ?? ""}
onChange={(event) => {
handleChange({ providerUrl: event.currentTarget.value || undefined });
}}
error={formErrors[`${innerProps.fieldPath}.providerUrl`]}
/>
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput
label={tRepository("name.label")}
@@ -513,7 +615,6 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
interface ImportRepositorySelectProps {
repository: ReleasesRepositoryImport;
checked: boolean;
integration?: Integration;
versionFilterPrecisionOptions: string[];
disabled: boolean;
onImageSelectionChanged?: (isSelected: boolean) => void;
@@ -522,12 +623,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 (
<Group gap="xl" justify="space-between">
@@ -570,12 +671,12 @@ const ImportRepositorySelect = ({
)}
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={integration !== undefined} withArrow>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={provider !== undefined} withArrow>
<Group>
{integration ? (
{provider ? (
<MaskedImage
color="dimmed"
imageUrl={integration.iconUrl}
imageUrl={getReleaseProviderIconUrl(provider)}
style={{
height: "1em",
width: "1em",
@@ -586,7 +687,7 @@ const ImportRepositorySelect = ({
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
{provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
@@ -596,7 +697,6 @@ const ImportRepositorySelect = ({
interface RepositoryImportProps {
repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
@@ -625,28 +725,25 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ 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 +819,20 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
{importRepositories
.filter((repository) => !repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -756,22 +846,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Stack justify="center" gap="xs">
{importRepositories
.filter((repository) => repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -798,8 +881,8 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl",
});
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
const containerImageToProviderKind: Record<string, ReleaseProviderKind> = {
"ghcr.io": "gitHubContainerRegistry",
"docker.io": "dockerHub",
"lscr.io": "linuxServerIO",
"quay.io": "quay",
@@ -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,15 @@ 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]);
useEffect(() => {
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
@@ -36,8 +45,6 @@ export default function ImmichAlbumCarouselWidget({
return <NoPhotosInAlbum />;
}
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
if (photoAssets.length === 0) {
return <NoPhotosInAlbum />;
}
@@ -53,6 +60,19 @@ export default function ImmichAlbumCarouselWidget({
);
}
function shuffle<T>(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;
@@ -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"));
@@ -38,6 +38,7 @@ interface ModalProps<TSort extends WidgetKind> {
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
appId?: string;
}
@@ -146,6 +147,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
/>
);
})}
+1
View File
@@ -55,6 +55,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
useOptions: (
query: string,
integrationIds: string[],
options: Record<string, unknown>,
) => {
isPending: boolean;
options: DynamicSelectOption[];
+37 -33
View File
@@ -20,8 +20,9 @@ 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 { createId, isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
import type { ReleaseProviderKind } from "@homarr/definitions";
import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
@@ -35,7 +36,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
export default function ReleasesWidget({ options, itemId }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -55,11 +56,16 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[options.newReleaseWithin, options.staleReleaseWithin],
);
// Group repositories by integration
const normalizedRepositories = useMemo(
() => options.repositories.map((repo) => (repo.id ? repo : { ...repo, id: createId() })),
[options.repositories],
);
// Group repositories by provider
const groupedRepositories = useMemo(() => {
return options.repositories.reduce(
return normalizedRepositories.reduce(
(acc, repo) => {
const key = repo.providerIntegrationId;
const key = repo.provider;
if (!key) return acc;
acc[key] ??= [];
@@ -67,28 +73,30 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return acc;
},
{} as Record<string, ReleasesRepository[]>,
{} as Partial<Record<ReleaseProviderKind, ReleasesRepository[]>>,
);
}, [options.repositories]);
}, [normalizedRepositories]);
// 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({
itemId,
repositories: repositories.map((repository) => ({
id: repository.id,
provider,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
providerUrl: repository.providerUrl,
})),
}),
),
@@ -100,15 +108,15 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
);
const repositories = useMemo(() => {
const formattedResults = options.repositories
const formattedResults = normalizedRepositories
.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 +124,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,
@@ -139,7 +147,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return formattedResults;
}, [
results,
options.repositories,
normalizedRepositories,
options.showOnlyHighlighted,
options.topReleases,
relativeDateOptions.newReleaseWithin,
@@ -173,7 +181,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 +195,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -529,28 +537,23 @@ const ExpandedDisplay = ({
p={10}
>
<Group className="releases-repository-expanded-header" justify="space-between" align="center" gap="xs">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="iconColor" ff="monospace">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="dimmed" ff="monospace">
{repository.identifier}
</Text>
{repository.integration && (
{repository.providerMetadata && (
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={repository.integration.iconUrl}
imageUrl={repository.providerMetadata.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Text
className="releases-repository-expanded-header-provider-name"
size="xs"
c="iconColor"
ff="monospace"
>
{repository.integration.name}
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="dimmed" ff="monospace">
{repository.providerMetadata.name}
</Text>
</Group>
)}
@@ -614,7 +617,7 @@ const ExpandedDisplay = ({
{repository.error && (
<>
<Divider className="releases-repository-expanded-error-divider" mx="30%" />
<Title className="releases-repository-expanded-error-title" order={4} ta="center">
<Title className="releases-repository-expanded-error-title" order={4} ta="center" c="red">
{t("error.label")}
</Title>
<Text
@@ -624,7 +627,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)}
</Text>
</>
)}
+16 -2
View File
@@ -1,6 +1,9 @@
import { IconRocket } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { releaseProviderKinds } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -39,10 +42,20 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
validate: z.number().min(0),
}),
repositories: factory.multiReleasesRepositories({
defaultValue: [],
defaultValue: [
{
id: createId(),
provider: "github" as const,
identifier: "homarr-labs/homarr",
name: "Homarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/homarr.png",
versionFilter: { prefix: "v", precision: 3 },
},
],
validate: z.array(
z.object({
providerIntegrationId: z.string().optional(),
id: z.string().default(() => createId()),
provider: z.enum(releaseProviderKinds).optional(),
identifier: z.string().min(1),
name: z.string().optional(),
versionFilter: z
@@ -53,6 +66,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
})
.optional(),
iconUrl: z.string().optional(),
providerUrl: z.string().url().optional(),
}),
),
}),
@@ -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;
};
+5 -8
View File
@@ -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 <Center h="100%">{t("noStation")}</Center>;
}
return <TimetableWidgetInner station={options.station} integrationId={integrationId} />;
return <TimetableWidgetInner station={options.station} baseUrl={options.baseUrl} />;
}
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,
},
+10 -11
View File
@@ -1,4 +1,5 @@
import { IconBusStop } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
@@ -7,20 +8,18 @@ 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) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery(
{
integrationId: integrationIds[0] ?? "",
query,
},
{
enabled: integrationIds.length > 0,
},
);
useOptions(query, _integrationIds, options) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery({
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch",
query,
});
return {
isPending,
+167 -262
View File
@@ -42,9 +42,6 @@ catalogs:
'@gfazioli/mantine-onboarding-tour':
specifier: ^4.0.6
version: 4.0.6
'@gitbeaker/rest':
specifier: ^43.8.0
version: 43.8.0
'@homarr/gridstack':
specifier: ^1.12.0
version: 1.12.0
@@ -62,43 +59,40 @@ catalogs:
version: 1.4.0
'@mantine/charts':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/colors-generator':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/core':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/dates':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/dropzone':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/form':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/hooks':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/modals':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/notifications':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/spotlight':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@mantine/tiptap':
specifier: ^9.4.0
version: 9.4.0
version: 9.4.1
'@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
@@ -624,7 +618,7 @@ importers:
version: 2.10.0(@types/node@24.13.2)
'@vitejs/plugin-react':
specifier: 'catalog:'
version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.9(vitest@4.1.9)
@@ -663,13 +657,13 @@ importers:
version: 6.0.3
vite:
specifier: 'catalog:'
version: 8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
version: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
vite-tsconfig-paths:
specifier: 'catalog:'
version: 6.1.1(typescript@6.0.3)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
version: 6.1.1(typescript@6.0.3)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
vitest:
specifier: 'catalog:'
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
apps/docs:
dependencies:
@@ -799,7 +793,7 @@ importers:
version: 3.2.2(react@19.2.7)
'@gfazioli/mantine-onboarding-tour':
specifier: 'catalog:'
version: 4.0.6(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 4.0.6(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@homarr/analytics':
specifier: workspace:^0.1.0
version: link:../../packages/analytics
@@ -895,22 +889,22 @@ importers:
version: link:../../packages/widgets
'@mantine/colors-generator':
specifier: 'catalog:'
version: 9.4.0(chroma-js@3.2.0)
version: 9.4.1(chroma-js@3.2.0)
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/dropzone':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
'@mantine/modals':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/tiptap':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/react@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/react@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.29.0(zod@4.4.3)
@@ -997,10 +991,10 @@ importers:
version: 1.21.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.3.1(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mcp-handler:
specifier: 'catalog:'
version: 1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
@@ -1682,7 +1676,7 @@ importers:
version: link:../validation
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@paralleldrive/cuid2':
specifier: 'catalog:'
version: 3.3.0
@@ -1796,7 +1790,7 @@ importers:
version: link:../validation
'@mantine/form':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
zod:
specifier: 'catalog:'
version: 4.4.3
@@ -1836,7 +1830,7 @@ importers:
version: link:../validation
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
specifier: 'catalog:'
version: 19.2.7
@@ -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
@@ -2002,10 +1990,10 @@ importers:
version: link:../ui
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
react:
specifier: 'catalog:'
version: 19.2.7
@@ -2054,7 +2042,7 @@ importers:
version: link:../validation
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -2088,7 +2076,7 @@ importers:
version: link:../ui
'@mantine/notifications':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -2137,10 +2125,10 @@ importers:
version: link:../validation
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
adm-zip:
specifier: 'catalog:'
version: 0.5.17
@@ -2308,7 +2296,7 @@ importers:
version: link:../server-settings
'@mantine/dates':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next:
specifier: 'catalog:'
version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
@@ -2363,13 +2351,13 @@ importers:
version: link:../ui
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
'@mantine/spotlight':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -2412,7 +2400,7 @@ importers:
version: 4.3.1
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next:
specifier: 'catalog:'
version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
@@ -2452,13 +2440,13 @@ importers:
version: link:../validation
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/dates':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -2467,7 +2455,7 @@ importers:
version: 4.2.0
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next:
specifier: 'catalog:'
version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
@@ -2589,16 +2577,16 @@ importers:
version: link:../validation
'@mantine/charts':
specifier: 'catalog:'
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1))
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1))
'@mantine/core':
specifier: 'catalog:'
version: 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/form':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.0(react@19.2.7)
version: 9.4.1(react@19.2.7)
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)
@@ -2664,10 +2652,10 @@ importers:
version: 1.21.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.3.1(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next:
specifier: 'catalog:'
version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
@@ -4242,6 +4230,9 @@ packages:
'@drizzle-team/brocli@0.12.0':
resolution: {integrity: sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ==}
'@emnapi/core@1.11.0':
resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
@@ -4981,18 +4972,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'}
@@ -5595,88 +5574,88 @@ packages:
'@libsql/core@0.14.0':
resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==}
'@mantine/charts@9.4.0':
resolution: {integrity: sha512-bcymoPmIejo/p/p+PZAzKqly61QUzhp9T9/UgKcpsGlDlVScallD+1DhfS+chlQkPct7Ii6JPAEm0UT18p56LA==}
'@mantine/charts@9.4.1':
resolution: {integrity: sha512-sGP1Org+nkkyJ81o6uBoUiAffVsy8Uemu+uwVVNaCVavDrRBGj3I4wCfSNLQdrwr+aoFbDLAyWaM0ON9bZWCbg==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
react: ^19.2.0
react-dom: ^19.2.0
recharts: '>=3.2.1'
'@mantine/colors-generator@9.4.0':
resolution: {integrity: sha512-txytFAScaiQJhlF+cWN1am35f+ADx8ol9VOVSDv8MwgrmgBPUxau1IQNfLgttjBQy938U2v/xE92am6GSbBMWA==}
'@mantine/colors-generator@9.4.1':
resolution: {integrity: sha512-HEJAJACPfS5Y2XRf8ILOUpH7/nag7YN8v3SxGo+vTCyvWnNNqNM19V+weohrHSLnM+TLgHehFzHdG8CzmJkiXQ==}
peerDependencies:
chroma-js: '>=2.4.2'
'@mantine/core@9.4.0':
resolution: {integrity: sha512-BvTzaJ5Nut4ZiWLCcy1/cRXE8yjCgxsBhyAynY1pEg6XBw/fChEqSgbEB2dybOnkiAxvYt89TrnTrBBo9qJtmg==}
'@mantine/core@9.4.1':
resolution: {integrity: sha512-lZWEICrum4+vwKxzh/mk4RB1N6BqZ0Cshdl6lhm7OYLiQzmOaxKtOgyaWz+vr65G8mqTXjalZalB+wmMVBYK2Q==}
peerDependencies:
'@mantine/hooks': 9.4.0
'@mantine/hooks': 9.4.1
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/dates@9.4.0':
resolution: {integrity: sha512-15iCWxykutEVoFUPTq4z+An3YQZb7UH6Fwzb3stsVhTMLm6FyGLYSSB6PL+9sBV1TPllIJSm1IizY2SSTDGqQQ==}
'@mantine/dates@9.4.1':
resolution: {integrity: sha512-DoQDvQMAlIw6ohwhUwYuTYQevG9y5AQmYWkhOHUuavABFEZmtBglkwziBoATvxT09I/6I56UMPhproXcS6XUfg==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
dayjs: '>=1.0.0'
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/dropzone@9.4.0':
resolution: {integrity: sha512-lEvw4nTbOQ/sWaiI2qhfFAjrTlzK7Au+bLd4TWEuYTwFFv6sxJictnzNvgr8NZoYDvp6dOf2l4Kks9nmxtVw2A==}
'@mantine/dropzone@9.4.1':
resolution: {integrity: sha512-QmtwDsViRmIdiwpc3d1loV3ryb8L6O4y3aiuMnAcLxL31a9hUPlHUZAs6KSuA+XbziZDG4BjxRVYjB70PxwORg==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/form@9.4.0':
resolution: {integrity: sha512-GH+RXpbMRx00fuU8N8v0DhB1xdyxRbzgaoql6UHu1JJ2a95AGXa5/zY3xQ+CL6YMCu954oZ4Qqe8amOsht0/xQ==}
'@mantine/form@9.4.1':
resolution: {integrity: sha512-e4tqC/D8ii7maPySWIWY306MNljPRprHPhu0/jaZrX7sRyJy+C8V9aU+ZFKYliM1CeiH1UX88+q4hWFUWVEdQw==}
peerDependencies:
react: ^19.2.0
'@mantine/hooks@9.4.0':
resolution: {integrity: sha512-SUkge8KlhyLoSSKhEHERx7jLotNu632kldhQPlLn5kbOuJixoEbU8fKcuwpXSsZTtzRgCCT2IkoN+rORhtZTHg==}
'@mantine/hooks@9.4.1':
resolution: {integrity: sha512-eTI8wmzPx3r98zgKIEuvukmoGTHBhmtI6+9E6o2DbTmEU2eM1bCdjE2vFdf0op2AlRO0KEYEcZhNQi4T/SJk0A==}
peerDependencies:
react: ^19.2.0
'@mantine/modals@9.4.0':
resolution: {integrity: sha512-XFili5IbS+sLV5n98VjKTMcPmgahsXva1VO4xS5kPM9UtlAmA8c1/KUQd+0lJd0JgwL7I6S2k6/rgPFVqZ94HA==}
'@mantine/modals@9.4.1':
resolution: {integrity: sha512-z5G3Biz7/5Ybz5Z8u5Xyvo9UBmFhAX/YXMf6U96ejvtAB11xVnCo8m37R5DJyzeFEqB4D1tvUgWiVVPBb+yadw==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/notifications@9.4.0':
resolution: {integrity: sha512-B7ZiuwGlb6lI97OgUeNdz77o/KifJqDMJBo7QyWpfmchbqf3Tp7mZMxfn4SnILQCtFmEg6qKji7lt9tgOA279g==}
'@mantine/notifications@9.4.1':
resolution: {integrity: sha512-Sm1g8E8RkFmesxpNz9zFvOjkTVxOa7dJJjMGoHNIXH+9/wO0s9kInny4mVnxTbLJJsD6nTqIQlk1v8tq2C4Ftw==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/spotlight@9.4.0':
resolution: {integrity: sha512-Wm3RsKvBPSXKgNrq1jOorf1WdqgRYv2xTpMJb07M8eOCfCjsiJAsoAFM62HxOXxZfYwg7bYhYQgSJYM0Z9NEKg==}
'@mantine/spotlight@9.4.1':
resolution: {integrity: sha512-gJ+7DdnGULeobtGXVGbVgvJSG3TeecxLXNxzpZo+ohl1OetPftCBGJOUrX2tYOOC6nJE1eHSIr/kpB31YIRjhQ==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
react: ^19.2.0
react-dom: ^19.2.0
'@mantine/store@9.4.0':
resolution: {integrity: sha512-BiJXxIdCBtfcODunPpzAKLuiIpxDeGo1mRd2gmzgSStV5BMxWpM0zEL8S49rN+xuFARwzGp3t1ks7B6EqthH8Q==}
'@mantine/store@9.4.1':
resolution: {integrity: sha512-/DCZq0aqqIdm03J0RJ7pbzoau2cwY8ndSOjskHOKHhTeWMnOBdCKXA3oMO5aIDldDqJ9T7Io4Boh4Pt461zNQw==}
peerDependencies:
react: ^19.2.0
'@mantine/tiptap@9.4.0':
resolution: {integrity: sha512-dcJ33omBW35vele7Brxetjgdk06vU0Ten7y8CL7ES9m5TP7Nre7a6sbfsmFXNwm3OzWAxoa/P9oHQvftiA48yQ==}
'@mantine/tiptap@9.4.1':
resolution: {integrity: sha512-0qN6SFh9BuwXNOHUf4ygx8LPvWcgU1zUULY7IJ9zKiVHfa1K6b0BRa8LpLVKtVv7K5NLyj2uHJwv+mXB+ypOJw==}
peerDependencies:
'@mantine/core': 9.4.0
'@mantine/hooks': 9.4.0
'@mantine/core': 9.4.1
'@mantine/hooks': 9.4.1
'@tiptap/extension-link': '>=3.3.0'
'@tiptap/react': '>=3.3.0'
react: ^19.2.0
@@ -7647,6 +7626,9 @@ packages:
cpu: [arm64]
os: [win32]
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -12017,6 +11999,11 @@ packages:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
nanoid@5.1.15:
resolution: {integrity: sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==}
engines: {node: ^18 || >=20}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
@@ -12610,10 +12597,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'}
@@ -13318,9 +13301,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'}
@@ -15204,9 +15184,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'}
@@ -17929,6 +17906,12 @@ snapshots:
'@drizzle-team/brocli@0.12.0': {}
'@emnapi/core@1.11.0':
dependencies:
'@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
optional: true
'@emnapi/core@1.11.1':
dependencies:
'@emnapi/wasi-threads': 1.2.2
@@ -17954,7 +17937,7 @@ snapshots:
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.28.1
esbuild: 0.28.0
source-map-support: 0.5.21
'@esbuild-kit/esm-loader@2.6.5':
@@ -18391,32 +18374,14 @@ snapshots:
'@formatjs/fast-memoize': 3.1.0
tslib: 2.8.1
'@gfazioli/mantine-onboarding-tour@4.0.6(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@gfazioli/mantine-onboarding-tour@4.0.6(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
'@tabler/icons-react': 3.44.0(react@19.2.7)
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
@@ -19020,22 +18985,22 @@ snapshots:
js-base64: 3.7.8
optional: true
'@mantine/charts@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1))':
'@mantine/charts@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1))':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
recharts: 3.9.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)
'@mantine/colors-generator@9.4.0(chroma-js@3.2.0)':
'@mantine/colors-generator@9.4.1(chroma-js@3.2.0)':
dependencies:
chroma-js: 3.2.0
'@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
clsx: 2.1.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
@@ -19045,66 +19010,66 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
clsx: 2.1.1
dayjs: 1.11.21
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@mantine/dropzone@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/dropzone@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-dropzone: 15.0.0(react@19.2.7)
'@mantine/form@9.4.0(react@19.2.7)':
'@mantine/form@9.4.1(react@19.2.7)':
dependencies:
'@standard-schema/spec': 1.1.0
fast-deep-equal: 3.1.3
klona: 2.0.6
react: 19.2.7
'@mantine/hooks@9.4.0(react@19.2.7)':
'@mantine/hooks@9.4.1(react@19.2.7)':
dependencies:
react: 19.2.7
'@mantine/modals@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/modals@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@mantine/notifications@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/notifications@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/store': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
'@mantine/store': 9.4.1(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-transition-group: 4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/spotlight@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/spotlight@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/store': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
'@mantine/store': 9.4.1(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@mantine/store@9.4.0(react@19.2.7)':
'@mantine/store@9.4.1(react@19.2.7)':
dependencies:
react: 19.2.7
'@mantine/tiptap@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/react@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@mantine/tiptap@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/react@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
'@tiptap/react': 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
@@ -19233,9 +19198,9 @@ snapshots:
'@napi-rs/wasm-runtime@1.0.7':
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@tybys/wasm-util': 0.10.3
'@emnapi/core': 1.11.0
'@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.2
optional: true
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
@@ -20182,7 +20147,7 @@ snapshots:
fuse.js: 7.4.2
js-base64: 3.7.8
jsonc-parser: 3.3.1
nanoid: 5.1.16
nanoid: 5.1.15
pretty-ms: 9.3.0
radix-vue: 1.9.17(vue@3.5.35(typescript@6.0.3))
set-cookie-parser: 3.1.0
@@ -20251,7 +20216,7 @@ snapshots:
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
fuse.js: 7.4.2
microdiff: 1.5.0
nanoid: 5.1.16
nanoid: 5.1.15
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
transitivePeerDependencies:
@@ -20395,15 +20360,15 @@ snapshots:
'@scalar/themes@0.16.2':
dependencies:
nanoid: 5.1.16
nanoid: 5.1.15
'@scalar/typebox@0.1.3': {}
'@scalar/types@0.16.0':
dependencies:
'@scalar/helpers': 0.9.0
nanoid: 5.1.16
type-fest: 5.7.0
nanoid: 5.1.15
type-fest: 5.6.0
zod: 4.4.3
'@scalar/use-codemirror@0.14.12(typescript@6.0.3)':
@@ -20457,7 +20422,7 @@ snapshots:
'@scalar/types': 0.16.0
'@scalar/validation': 0.6.0
js-base64: 3.7.8
type-fest: 5.7.0
type-fest: 5.6.0
vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0
transitivePeerDependencies:
@@ -21277,6 +21242,11 @@ snapshots:
'@turbo/windows-arm64@2.10.0':
optional: true
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
optional: true
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -21738,10 +21708,10 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
'@vitejs/plugin-react@6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
'@vitejs/plugin-react@6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
optionalDependencies:
babel-plugin-react-compiler: 1.0.0
@@ -21757,7 +21727,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/expect@4.1.9':
dependencies:
@@ -21768,15 +21738,6 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.6(@types/node@24.13.2)(typescript@6.0.3)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/mocker@4.1.9(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.9
@@ -21785,7 +21746,6 @@ snapshots:
optionalDependencies:
msw: 2.14.6(@types/node@24.13.2)(typescript@6.0.3)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
optional: true
'@vitest/pretty-format@4.1.9':
dependencies:
@@ -21814,7 +21774,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/utils@4.1.9':
dependencies:
@@ -25588,19 +25548,19 @@ snapshots:
dependencies:
semver: 7.7.4
mantine-datatable@9.3.1(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
mantine-datatable@9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
clsx: 2.1.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
mantine-react-table-open@9.0.3(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/dates': 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.0(react@19.2.7)
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/dates': 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
'@tabler/icons-react': 3.44.0(react@19.2.7)
'@tanstack/match-sorter-utils': 8.19.4
'@tanstack/react-table': 8.20.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -26308,7 +26268,7 @@ snapshots:
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
type-fest: 5.7.0
type-fest: 5.6.0
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
@@ -26359,6 +26319,8 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.15: {}
nanoid@5.1.16: {}
nanostores@1.3.0: {}
@@ -26879,8 +26841,6 @@ snapshots:
picocolors@1.1.1: {}
picomatch-browser@2.2.6: {}
picomatch@2.3.1: {}
picomatch@4.0.4: {}
@@ -27705,8 +27665,6 @@ snapshots:
range-parser@1.2.1: {}
rate-limiter-flexible@8.1.0: {}
raw-body@2.5.3:
dependencies:
bytes: 3.1.2
@@ -29585,34 +29543,16 @@ snapshots:
dependencies:
global: 4.4.0
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.3(typescript@6.0.3)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
- typescript
vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
rolldown: 1.1.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.13.2
esbuild: 0.28.0
fsevents: 2.3.3
jiti: 2.7.0
sass: 1.101.0
sugarss: 5.0.0(postcss@8.5.15)
terser: 5.46.1
tsx: 4.21.0
yaml: 2.9.0
vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
@@ -29630,38 +29570,6 @@ snapshots:
terser: 5.46.1
tsx: 4.21.0
yaml: 2.9.0
optional: true
vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.13.2
'@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
'@vitest/ui': 4.1.9(vitest@4.1.9)
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.15))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
@@ -29693,7 +29601,6 @@ snapshots:
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
optional: true
vue-component-type-helpers@3.3.4: {}
@@ -30059,8 +29966,6 @@ snapshots:
dependencies:
is-wsl: 3.1.1
xcase@2.0.1: {}
xdg-basedir@5.1.0: {}
xml-js@1.6.11: