Compare commits

...
Author SHA1 Message Date
Thomas 190e30dcf5 Merge branch 'dev' into feat/appearance-updates 2026-05-07 17:52:30 +02:00
43cc68bdd4 feat: add Umami Analytics integration and widget (#5362)
Co-authored-by: Josh G <febrile42@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-05-07 17:50:58 +02:00
Meier Lukas 0937555d49 feat(entity-state): add data attributes for custom-css styling (#5655) 2026-05-07 16:54:57 +02:00
homarr-renovate[bot] 663fe980bf chore(deps): update dependency yaml to v2.8.4 (#5659)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 09:39:58 +00:00
homarr-renovate[bot] 9e2c487389 chore(deps): update dependency isomorphic-dompurify to ^3.12.0 (#5658)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 06:19:31 +00:00
homarr-renovate[bot] 698e551790 chore(deps): update tanstack-query monorepo to ^5.100.8 (#5657)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-07 06:18:42 +00:00
homarr-renovate[bot] 71e4526ad5 chore(deps): update dependency zod to ^4.4.2 (#5656)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 22:04:29 +00:00
Meier Lukas b07a4a0f3e fix(update-checker): hide prereleases on non-prerelease versions (#5645) 2026-05-06 21:23:14 +02:00
homarr-renovate[bot] 62a476a949 chore(deps): update dependency eslint to ^10.3.0 (#5652)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 16:35:25 +00:00
homarr-renovate[bot] 6671ac48f9 chore(deps): update tanstack-query monorepo to ^5.100.7 (#5651)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 09:30:45 +00:00
homarr-renovate[bot] 3c7a7615ba chore(deps): update dependency nanoid@&gt;&#x3d;4.0.0 &lt;5.0.9 to >=5.1.11 (#5648)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 06:13:36 +00:00
homarr-renovate[bot] 2f301bb56a chore(deps): update turbo monorepo to ^2.9.7 (#5649)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-06 03:01:59 +00:00
homarr-renovate[bot] e06abbc176 chore(deps): update dependency nanoid@&gt;&#x3d;4.0.0 &lt;5.0.9 to >=5.1.10 (#5646)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 21:10:38 +00:00
homarr-renovate[bot] 04daeba089 chore(deps): update dependency jsdom to ^29.1.1 (#5643)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 10:55:08 +00:00
homarr-renovate[bot] 7c83d2cb6e chore(deps): update dependency hono@&lt;4.6.5 to >=4.12.16 (#5642)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 10:54:29 +00:00
homarr-renovate[bot] 2829d145af chore(deps): update dependency zod to ^4.4.1 (#5641)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 02:56:52 +00:00
homarr-renovate[bot] 44bf36fff9 chore(deps): update dependency timezones-ical-library to ^2.2.0 (#5640)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-05 00:01:35 +00:00
Kyle bd99c2cfaf Merge branch 'dev' into feat/appearance-updates 2026-05-04 17:58:31 -06:00
homarr-renovate[bot] acd11c3fc1 chore(deps): update dependency isomorphic-dompurify to ^3.11.0 (#5638)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-04 16:53:37 +00:00
homarr-renovate[bot] b9c701a71b chore(deps): update dependency next-intl to v4.11.0 (#5635)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 19:08:04 +00:00
homarr-renovate[bot] e52ecd64ee chore(deps): update dependency next-intl to v4.10.1 (#5632)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 16:59:56 +00:00
homarr-renovate[bot] af77f729ec chore(deps): update tanstack-query monorepo to ^5.100.6 (#5633)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 16:59:30 +00:00
homarr-renovate[bot] 7c39e4de13 chore(deps): update dependency next-intl to v4.10.0 (#5631)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 15:56:23 +00:00
homarr-renovate[bot] 3e918e5d3d chore(deps): update dependency tsdav to ^2.2.0 (#5627)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 14:57:50 +00:00
homarr-renovate[bot] 9cd45f7832 chore(deps): update trpc monorepo to ^11.17.0 (#5630)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 13:19:32 +00:00
homarr-renovate[bot] 90ca3770a1 chore(deps): update tiptap monorepo to v3.22.5 (#5628)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-03 10:12:22 +00:00
homarr-renovate[bot]andMeier Lukas a183339063 chore(deps): update dependency eslint to v10 (#5038)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-02 21:41:16 +02:00
homarr-renovate[bot] 0a4e4c3b70 chore(deps): update dependency next-intl to v4.9.2 (#5625)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 19:07:29 +00:00
homarr-renovate[bot] b52e664d0f chore(deps): update dependency swagger-ui-react to ^5.32.5 (#5621)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 13:21:15 +00:00
homarr-renovate[bot] 9f4634a95d chore(deps): update dependency mysql2 to v3.22.3 (#5619)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 11:25:52 +00:00
homarr-renovate[bot] a6f6c2e945 chore(deps): update dependency jsdom to ^29.1.0 (#5620)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-05-02 02:53:35 +00:00
homarr-crowdin[bot]andMeier Lukas b6d23e7546 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
2026-05-02 01:32:16 +00:00
Meier Lukas b0f2c94674 chore: update bug report template 2026-05-01 22:51:17 +02:00
homarr-releases[bot] 9cf0070442 chore: update bug report template (#5612)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-05-01 20:23:03 +00:00
Meier LukasandCopilot 647afbe2cb ci: add beta release workflow (#5601)
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 22:20:58 +02:00
Releases Homarr e31555776e chore(release): 1.60.0 [skip ci] 2026-05-01 20:12:31 +00:00
homarr-releases[bot] 3c88ae9023 chore(release): automatic release v1.60.0 2026-05-01 20:11:31 +00:00
Meier Lukas 5200c17baf chore: retrigger release v1.60.0 2026-05-01 22:10:44 +02:00
Releases Homarr 97311b338a chore(release): 1.60.0 [skip ci] 2026-05-01 20:04:34 +00:00
homarr-releases[bot] a6b7e9df93 chore(release): automatic release v1.60.0 2026-05-01 20:03:37 +00:00
homarr-update-contributors[bot] e9289bd174 chore: update contributors (#5609)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-05-01 14:25:59 +00:00
Meier Lukas e8edc203b6 fix: migrate from node-cron to croner (#5407) 2026-05-01 16:21:38 +02:00
Meier Lukas ed6680ba6a perf(icons): updater takes 10 seconds to run with high cpu usage (#5608) 2026-05-01 16:21:22 +02:00
homarr-renovate[bot] 16c6929396 chore(deps): update tanstack-query monorepo to ^5.100.5 (#5606)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-30 19:30:50 +00:00
homarr-renovate[bot] dba9efc726 chore(deps): update tanstack-query monorepo to ^5.100.4 (#5603)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-04-30 17:53:27 +00:00
Meier Lukas ef012981db chore: replace app-id with client-id for actions/create-github-app-token due to deprecation (#5602) 2026-04-30 18:28:55 +02:00
Meier Lukas c24f4954ea ci: fix minor issues (#5604) 2026-04-30 18:27:57 +02:00
Meier Lukas a268fd5a26 chore(issue-template): fix issues for integration and widget template 2026-04-30 17:49:24 +02:00
Kyle cc7e5ddcb5 Merge branch 'dev' into feat/appearance-updates 2026-04-23 23:45:53 -06:00
Kyle 4eacce3e0b Merge branch 'dev' into feat/appearance-updates 2026-04-23 12:26:52 -06:00
Kyle 3b44d13791 Merge branch 'dev' into feat/appearance-updates 2026-04-21 23:35:29 -06:00
Kyle Grieder d809398e64 fix: prevent white flash on page load when color scheme is auto 2026-04-21 22:45:35 -06:00
Kyle 3199a7f242 Merge branch 'dev' into feat/appearance-updates 2026-04-21 21:40:37 -06:00
Kyle 748f59f444 Merge branch 'dev' into feat/appearance-updates 2026-04-14 13:29:45 -06:00
Kyle ab51d2f274 Merge branch 'dev' into feat/appearance-updates 2026-04-13 14:31:50 -06:00
Kyle f5b3c5c442 Merge branch 'dev' into feat/appearance-updates 2026-04-12 20:04:15 -06:00
Kyle 7151abdb88 Merge branch 'dev' into feat/appearance-updates 2026-04-12 13:31:34 -06:00
Kyle Grieder 76154b5752 fix: use computed color scheme in system resources charts
useMantineColorScheme() returns 'auto' when system mode is active,
causing the chart background color to fall through to the light variant.
Switch to useComputedColorScheme() which always resolves to the actual
'light' or 'dark' value, keeping charts visually correct in all modes.
2026-04-12 13:30:28 -06:00
Kyle Grieder 0d2675e1bb feat: add auto/system color scheme option
- Extend ColorScheme type to include 'auto' alongside 'light' and 'dark'
- Allow 'auto' to persist to DB and cookie (remove the explicit guard)
- Server default and new-user default both changed to 'auto'
- layout.tsx: omit inline backgroundColor when scheme is 'auto'
- Avatar menu: replace toggle item with a Combobox (Auto/Light/Dark)
  mirroring the existing language selector pattern
- Appearance settings form and color scheme comboboxes updated with
  IconDeviceDesktop for the new auto option
- Spotlight command handles 'auto' state via useComputedColorScheme
- Old import: map legacy 'environment' value to 'auto'
- Add translation keys for 'auto' in all three relevant scopes
2026-04-12 13:30:21 -06:00
80 changed files with 3574 additions and 1545 deletions

No files matched your search

+2
View File
@@ -33,6 +33,8 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.60.1-beta.1
- 1.60.0
- 1.59.3
- 1.59.2
- 1.59.1
+5 -5
View File
@@ -25,16 +25,16 @@ body:
label: Public API available?
description: Is there a public API available, that we can consume in Homarr?
options:
- Yes, available on a website
- Yes, available in the application itself
- No
- "Yes, available on a website"
- "Yes, available in the application itself"
- "No"
validations:
required: true
- type: dropdown
attributes:
label: Are you willing to contribute this yourself?
options:
- Yes
- No
- "Yes"
- "No"
validations:
required: true
+2 -2
View File
@@ -24,7 +24,7 @@ body:
attributes:
label: Are you willing to contribute this yourself?
options:
- Yes
- No
- "Yes"
- "No"
validations:
required: true
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
- name: Install GitHub CLI
+1 -1
View File
@@ -11,7 +11,7 @@ on:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
@@ -22,7 +22,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app-id: ${{ secrets.CROWDIN_APP_ID }}
client-id: ${{ secrets.CROWDIN_APP_ID }}
permission-contents: write # required to commit to crowdin branch
permission-pull-requests: write # required to create pull request
@@ -0,0 +1,104 @@
name: "[Deployment] Beta Release"
on:
workflow_dispatch:
inputs:
send-notifications:
type: boolean
required: false
default: true
description: Send notifications
permissions:
contents: write
pull-requests: write
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get Next Version
id: semver
uses: ietf-tools/semver-action@v1
with:
token: ${{ github.token }}
branch: next
- name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }}-beta (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
id: obtainApprovalToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to beta branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge
uses: "pascalgn/automerge-action@v0.16.4"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to beta
MERGE_COMMIT_MESSAGE: "chore(release): release ${{ steps.semver.outputs.next }}-beta"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails
MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Discord Fail Notification
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The beta release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
@@ -53,7 +53,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
- uses: actions/checkout@v6
@@ -234,7 +234,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to publish the release
- name: Log in to the Container registry
@@ -274,6 +274,6 @@ jobs:
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
uses: Ilshidur/action-discord@0.4.0
with:
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
+14 -13
View File
@@ -21,10 +21,10 @@ jobs:
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
uses: Ilshidur/action-discord@0.4.0
with:
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
- name: Checkout repository
@@ -38,14 +38,15 @@ jobs:
token: ${{ github.token }}
branch: dev
- name: Create pull request
id: create-pull-request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ github.events.inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
uses: Ilshidur/action-discord@0.4.0
with:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token
@@ -53,7 +54,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR
env:
@@ -65,7 +66,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to main branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge
@@ -74,7 +75,7 @@ jobs:
uses: "pascalgn/automerge-action@v0.16.4"
env:
GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }}
MERGE_METHOD: merge # we prefer merge commits for merging to master
MERGE_METHOD: merge # we prefer merge commits for merging to main
MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}"
MERGE_DELETE_BRANCH: false # never set to true!
PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}"
@@ -83,23 +84,23 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}"
- name: Discord Fail Notification
if: failure() && github.events.inputs.send-notifications
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
uses: Ilshidur/action-discord@0.4.0
with:
args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed"
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
owner: homarr-labs
repositories: |
documentation
@@ -48,7 +48,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
@@ -24,7 +24,7 @@ jobs:
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code
+1 -1
View File
@@ -71,4 +71,4 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
.opencode
+30
View File
@@ -1,3 +1,33 @@
## [1.60.0](https://github.com/homarr-labs/homarr/compare/v1.59.3...v1.60.0) (2026-05-01)
### Features
* **about:** add opencollective contributors ([#5481](https://github.com/homarr-labs/homarr/issues/5481)) ([3d202d4](https://github.com/homarr-labs/homarr/commit/3d202d409febf33d0218b589c979744c84f87407))
### Bug Fixes
* migrate from node-cron to croner ([#5407](https://github.com/homarr-labs/homarr/issues/5407)) ([e8edc20](https://github.com/homarr-labs/homarr/commit/e8edc203b6c862141668b043ef4232c22b8fae24))
* **openapi:** openapi document empty ([#5579](https://github.com/homarr-labs/homarr/issues/5579)) ([ce8e330](https://github.com/homarr-labs/homarr/commit/ce8e330c82bc0be9f299f3071a1e80daafc5f789))
### Performance Improvements
* **icons:** updater takes 10 seconds to run with high cpu usage ([#5608](https://github.com/homarr-labs/homarr/issues/5608)) ([ed6680b](https://github.com/homarr-labs/homarr/commit/ed6680ba6a42864185818eb8d90b2303846c1761))
## [1.60.0](https://github.com/homarr-labs/homarr/compare/v1.59.3...v1.60.0) (2026-05-01)
### Features
* **about:** add opencollective contributors ([#5481](https://github.com/homarr-labs/homarr/issues/5481)) ([3d202d4](https://github.com/homarr-labs/homarr/commit/3d202d409febf33d0218b589c979744c84f87407))
### Bug Fixes
* migrate from node-cron to croner ([#5407](https://github.com/homarr-labs/homarr/issues/5407)) ([e8edc20](https://github.com/homarr-labs/homarr/commit/e8edc203b6c862141668b043ef4232c22b8fae24))
* **openapi:** openapi document empty ([#5579](https://github.com/homarr-labs/homarr/issues/5579)) ([ce8e330](https://github.com/homarr-labs/homarr/commit/ce8e330c82bc0be9f299f3071a1e80daafc5f789))
### Performance Improvements
* **icons:** updater takes 10 seconds to run with high cpu usage ([#5608](https://github.com/homarr-labs/homarr/issues/5608)) ([ed6680b](https://github.com/homarr-labs/homarr/commit/ed6680ba6a42864185818eb8d90b2303846c1761))
## [1.59.3](https://github.com/homarr-labs/homarr/compare/v1.59.2...v1.59.3) (2026-04-24)
### Bug Fixes
@@ -8,13 +8,12 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
@@ -35,7 +34,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
const updateCookieValue = (value: MantineColorScheme) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
@@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
+2 -1
View File
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
@@ -129,7 +130,7 @@ export default async function Layout(props: {
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
}}
suppressHydrationWarning
>
@@ -1,7 +1,7 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
@@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
};
const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
@@ -0,0 +1,94 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
const schemeIcons: Record<ColorScheme, React.ElementType> = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
interface ColorSchemeComboboxProps {
value: ColorScheme;
onChange: (value: ColorScheme) => void;
width?: string;
}
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const handleOnOptionSubmit = React.useCallback(
(val: string) => {
if (!val) return;
onChange(val as ColorScheme);
combobox.closeDropdown();
},
[onChange, combobox],
);
const handleOnClick = React.useCallback(() => {
combobox.toggleDropdown();
}, [combobox]);
return (
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
w={width}
>
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{colorSchemes.map((scheme) => (
<Combobox.Option value={scheme} key={scheme}>
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
const OptionItem = ({
scheme,
currentScheme,
tOptions,
showCheck,
}: {
scheme: ColorScheme;
currentScheme: ColorScheme;
tOptions: (key: ColorScheme) => string;
showCheck?: boolean;
}) => {
const Icon = schemeIcons[scheme];
return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<Icon size={16} stroke={1.5} />
<Text>{tOptions(scheme)}</Text>
</Group>
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
</Group>
);
};
@@ -1,49 +1,17 @@
"use client";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { useMantineColorScheme } from "@mantine/core";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { ColorSchemeCombobox } from "./color-scheme-combobox";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<SelectWithCustomItems
value={colorScheme}
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tOptions(scheme),
}))}
SelectOption={ColorSchemeCustomOption}
w={w}
/>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
};
@@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconTool,
} from "@tabler/icons-react";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
@@ -34,13 +26,9 @@ interface UserAvatarMenuProps {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
const session = useSession();
const router = useRouter();
@@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
+12
View File
@@ -0,0 +1,12 @@
// Prevent flash of white background when color scheme is "auto" and OS prefers dark.
// CSS media queries are evaluated synchronously during the browser's initial paint,
// before any JavaScript executes, so this eliminates the white→dark flicker.
html[data-mantine-color-scheme="auto"] {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
html[data-mantine-color-scheme="auto"] {
background-color: #242424;
}
}
+1
View File
@@ -37,6 +37,7 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"croner": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"fastify": "catalog:",
+24 -15
View File
@@ -1,4 +1,4 @@
import { schedule, validate as validateCron } from "node-cron";
import { Cron } from "croner";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IJobManager } from "@homarr/cron-job-api";
@@ -21,28 +21,31 @@ export class JobManager implements IJobManager {
public async triggerAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.runManuallyAsync(name);
}
public async stopAsync(name: JobGroupKeys): Promise<void> {
await this.jobGroup.stopAsync(name);
public stop(name: JobGroupKeys): void {
this.jobGroup.stop(name);
}
public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void> {
logger.info("Updating cron job interval", { name, expression: cron });
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
if (!validateCron(cron)) {
throw new Error(`Invalid cron expression: ${cron}`);
}
if (job.preventCustomInterval && cron !== job.cronExpression) {
throw new Error(`Custom cron expressions are not allowed for job ${name}`);
}
await this.updateConfigurationAsync(name, { cronExpression: cron });
await this.jobGroup.getTask(name)?.destroy();
const updatedConfig = await this.updateConfigurationAsync(name, { cronExpression: cron });
this.jobGroup.getTask(name)?.stop();
this.jobGroup.setTask(
name,
schedule(cron, () => void job.executeAsync(), {
name,
}),
new Cron(
cron,
{
name,
timezone: job.timezone,
paused: !updatedConfig.isEnabled,
},
() => void job.executeAsync(),
),
);
logger.info("Cron job interval updated", { name, expression: cron });
}
@@ -52,7 +55,7 @@ export class JobManager implements IJobManager {
if (!job) throw new Error(`Job ${name} not found`);
await this.updateConfigurationAsync(name, { isEnabled: false });
await this.jobGroup.stopAsync(name);
this.jobGroup.stop(name);
logger.info("Cron job disabled", { name });
}
public async enableAsync(name: JobGroupKeys): Promise<void> {
@@ -86,21 +89,27 @@ export class JobManager implements IJobManager {
name,
configuration: JSON.stringify(configuration),
});
return;
return {
name,
cronExpression: configuration.cronExpression ?? existingConfig.cronExpression,
isEnabled: configuration.isEnabled ?? existingConfig.isEnabled,
};
}
const job = this.jobGroup.getJobRegistry().get(name);
if (!job) throw new Error(`Job ${name} not found`);
await this.db.insert(cronJobConfigurations).values({
const updatedConfig = {
name,
cronExpression: configuration.cronExpression ?? job.cronExpression,
isEnabled: configuration.isEnabled ?? true,
});
};
await this.db.insert(cronJobConfigurations).values(updatedConfig);
logger.debug("Cron job configuration updated", {
name,
configuration: JSON.stringify(configuration),
});
return updatedConfig;
}
public async getAllAsync(): Promise<
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.59.3",
"version": "1.60.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -84,9 +84,9 @@
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.5",
"esbuild@<=0.24.2": ">=0.28.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
"hono@<4.6.5": ">=4.12.15",
"hono@<4.6.5": ">=4.12.16",
"linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.9",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.5",
+2 -2
View File
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import z from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api";
import { cronJobApi } from "@homarr/cron-job-api/client";
import type { TaskStatus } from "@homarr/cron-job-status";
import { createCronJobStatusChannel } from "@homarr/cron-job-status";
@@ -35,7 +35,7 @@ export const cronJobsRouter = createTRPCRouter({
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
cron: z.string(),
}),
)
.mutation(async ({ input }) => {
+1 -1
View File
@@ -110,7 +110,7 @@ export const userRouter = createTRPCRouter({
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
| QueryResult
| MySqlRawQueryResult;
let count = 0;
let count: number;
if (Array.isArray(queryResult)) {
count = queryResult[0].affectedRows;
} else {
+1
View File
@@ -30,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
});
+205
View File
@@ -0,0 +1,205 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync } from "@homarr/integrations";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
umamiMultiEventRequestHandler,
umamiRequestHandler,
umamiTopPagesRequestHandler,
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
try {
const instance = await createIntegrationAsync(ctx.integration);
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getVisitorStats: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata("Failed to load event names", { websiteId: input.websiteId }, { cause: error }),
);
return [];
}
}),
getTopPages: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load top pages",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
{ cause: error },
),
);
return [];
}
}),
getTopReferrers: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load top referrers",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
{ cause: error },
),
);
return [];
}
}),
getMultiEventTimeSeries: publicProcedure
.input(
z.object({
websiteId: z.string(),
timeFrame: z.string(),
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const sortedNames = [...input.eventNames].sort();
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Failed to load multi-event time series",
{ websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: JSON.stringify(input.eventNames) },
{ cause: error },
),
);
return [];
}
}),
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
} catch (error) {
logger.warn(
new ErrorWithMetadata("Failed to load active visitors", { websiteId: input.websiteId }, { cause: error }),
);
return 0;
}
}),
subscribeActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.subscription(({ ctx, input }) => {
return observable<number>((emit) => {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const unsubscribe = innerHandler.subscribe((count) => {
emit.next(count);
});
return () => {
unsubscribe();
};
});
}),
});
+1 -1
View File
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "dark",
colorScheme: "auto",
},
} as Session;
};
+4
View File
@@ -41,6 +41,10 @@ export class Stopwatch {
return dayjs().millisecond(this.startTime).fromNow(true);
}
getElapsedInFormattedMilliseconds() {
return `${this.getElapsedInMilliseconds().toFixed(0)}ms`;
}
getElapsedInMilliseconds() {
return performance.now() - this.startTime;
}
-2
View File
@@ -32,7 +32,6 @@
"@trpc/client": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"node-cron": "catalog:",
"react": "catalog:",
"zod": "catalog:"
},
@@ -40,7 +39,6 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "catalog:",
"@types/react": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
+4 -9
View File
@@ -1,5 +1,4 @@
import { initTRPC, TRPCError } from "@trpc/server";
import { validate } from "node-cron";
import { z } from "zod/v4";
import type { JobGroupKeys } from "@homarr/cron-jobs";
@@ -13,7 +12,7 @@ export const jobNameSchema = z.enum(jobGroup.getKeys());
export interface IJobManager {
startAsync(name: JobGroupKeys): Promise<void>;
triggerAsync(name: JobGroupKeys): Promise<void>;
stopAsync(name: JobGroupKeys): Promise<void>;
stop(name: JobGroupKeys): void;
updateIntervalAsync(name: JobGroupKeys, cron: string): Promise<void>;
disableAsync(name: JobGroupKeys): Promise<void>;
enableAsync(name: JobGroupKeys): Promise<void>;
@@ -52,10 +51,6 @@ const apiKeyProcedure = t.procedure.use(({ ctx, next }) => {
});
});
export const cronExpressionSchema = z.string().refine((expression) => validate(expression), {
error: "Invalid cron expression",
});
export const jobRouter = createTrpcRouter({
start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.startAsync(input);
@@ -63,14 +58,14 @@ export const jobRouter = createTrpcRouter({
trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.triggerAsync(input);
}),
stop: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => {
await ctx.manager.stopAsync(input);
stop: apiKeyProcedure.input(jobNameSchema).mutation(({ input, ctx }) => {
ctx.manager.stop(input);
}),
updateInterval: apiKeyProcedure
.input(
z.object({
name: jobNameSchema,
cron: cronExpressionSchema,
cron: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
+1 -2
View File
@@ -27,13 +27,12 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^",
"@homarr/db": "workspace:^0.1.0",
"node-cron": "catalog:"
"croner": "catalog:"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:"
}
+11 -12
View File
@@ -1,4 +1,4 @@
import { createTask, validate } from "node-cron";
import { Cron } from "croner";
import { Stopwatch } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
@@ -83,10 +83,15 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
? defaultCronExpression
: (configuration?.cronExpression ?? defaultCronExpression);
const scheduledTask = createTask(cronExpression, () => void catchingCallbackAsync(), {
name,
timezone: creatorOptions.timezone,
});
const scheduledTask = new Cron(
cronExpression,
{
name,
timezone: creatorOptions.timezone,
paused: true,
},
() => void catchingCallbackAsync(),
);
creatorOptions.logger.logDebug("The scheduled task for cron job was created", {
name,
cronExpression: defaultCronExpression,
@@ -116,6 +121,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
},
preventManualExecution: options.preventManualExecution ?? false,
preventCustomInterval: options.preventCustomInterval ?? false,
timezone: creatorOptions.timezone,
};
};
};
@@ -132,13 +138,6 @@ export const createCronJobCreator = <TAllowedNames extends string = string>(
defaultCronExpression: TExpression,
options: CreateCronJobOptions = { runOnStart: false },
) => {
creatorOptions.logger.logDebug("Validating cron expression for cron job", {
name,
cronExpression: defaultCronExpression,
});
if (!validate(defaultCronExpression)) {
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
}
creatorOptions.logger.logDebug("Cron job expression for cron job is valid", {
name,
cronExpression: defaultCronExpression,
+11 -11
View File
@@ -1,4 +1,4 @@
import type { ScheduledTask } from "node-cron";
import type { Cron } from "croner";
import { objectEntries, objectKeys } from "@homarr/common";
import { db } from "@homarr/db";
@@ -34,7 +34,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
});
}
const tasks = new Map<string, ScheduledTask>();
const tasks = new Map<string, Cron>();
return {
initializeAsync: async () => {
@@ -62,7 +62,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
name: job.name,
});
await job.onStartAsync();
await tasks.get(name as string)?.start();
tasks.get(name as string)?.resume();
},
startAllAsync: async () => {
for (const job of jobRegistry.values()) {
@@ -74,7 +74,7 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
name: job.name,
});
await job.onStartAsync();
await tasks.get(job.name)?.start();
tasks.get(job.name)?.resume();
}
},
runManuallyAsync: async (name: keyof TJobs) => {
@@ -87,23 +87,23 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
options.logger.logInfo("Running schedule cron job manually.", {
name: job.name,
});
await tasks.get(name as string)?.execute();
await tasks.get(name as string)?.trigger();
},
stopAsync: async (name: keyof TJobs) => {
stop: (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
options.logger.logInfo("Stopping schedule of cron job.", {
name: job.name,
});
await tasks.get(name as string)?.stop();
tasks.get(name as string)?.pause();
},
stopAllAsync: async () => {
stopAll: () => {
for (const job of jobRegistry.values()) {
options.logger.logInfo("Stopping schedule of cron job.", {
name: job.name,
});
await tasks.get(job.name)?.stop();
tasks.get(job.name)?.pause();
}
},
getJobRegistry() {
@@ -112,8 +112,8 @@ export const createJobGroupCreator = <TAllowedNames extends string = string>(
getTask(name: keyof TJobs) {
return tasks.get(name as string) ?? null;
},
setTask(name: keyof TJobs, task: ScheduledTask) {
tasks.set(name as string, task);
setTask(name: keyof TJobs, cron: Cron) {
tasks.set(name as string, cron);
},
getKeys() {
return objectKeys(jobs);
+2
View File
@@ -20,6 +20,7 @@ import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -54,6 +55,7 @@ export const jobGroup = createCronJobGroup({
weather: weatherJob,
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
+39 -47
View File
@@ -1,9 +1,12 @@
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { createId, splitToNChunks, Stopwatch } from "@homarr/common";
import { env } from "@homarr/common/env";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
import { and, db, eq, handleTransactionsAsync, inArray, notInArray, or } from "@homarr/db";
import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons";
@@ -26,7 +29,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
logger.info("Fetched icons from repositories", {
repositoryCount: repositoryIconGroups.length,
iconCount: countIcons,
duration: stopWatch.getElapsedInHumanWords(),
duration: stopWatch.getElapsedInFormattedMilliseconds(),
});
const databaseIconRepositories = await db.query.iconRepositories.findMany({
@@ -35,7 +38,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
},
});
const skippedChecksums: `${string}.${string}`[] = [];
const skippedChecksums = new Map<string, string[]>();
let countDeleted = 0;
let countInserted = 0;
@@ -50,6 +53,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
if (!repositoryIconGroup.success) {
continue;
}
const localSkippedChecksums: string[] = [];
const repositoryInDb = databaseIconRepositories.find(
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
@@ -66,7 +70,7 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
for (const icon of repositoryIconGroup.icons) {
if (dbIconsInRepository.some((dbIcon) => dbIcon.checksum === icon.checksum)) {
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
localSkippedChecksums.push(icon.checksum);
continue;
}
@@ -79,38 +83,28 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
});
countInserted++;
}
}
const deadIcons = allDbIcons.filter(
(icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`),
);
skippedChecksums.set(iconRepositoryId, localSkippedChecksums);
}
const deadIconRepositories = databaseIconRepositories.filter(
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
);
const deadIconsFilter = or(
...[...skippedChecksums.entries()].map(([iconRepositoryId, checksums]) =>
and(eq(icons.iconRepositoryId, iconRepositoryId), notInArray(icons.checksum, checksums)),
),
);
await handleTransactionsAsync(db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (newIconRepositories.length >= 1) {
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
}
const result = (await transaction.delete(schema.icons).where(deadIconsFilter)) as
| MySqlRawQueryResult
| QueryResult;
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
await transaction.insert(schema.icons).values(chunck);
}
}
if (deadIcons.length >= 1) {
await transaction.delete(schema.icons).where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
);
}
countDeleted += Array.isArray(result) ? result[0].affectedRows : (result.rowCount ?? 0);
if (deadIconRepositories.length >= 1) {
await transaction.delete(schema.iconRepositories).where(
@@ -121,33 +115,22 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
);
}
countDeleted += deadIcons.length;
});
},
handleSync() {
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
await transaction.insert(schema.icons).values(chunck);
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
});
},
handleSync() {
db.transaction((transaction) => {
const result = transaction.delete(icons).where(deadIconsFilter).run();
countDeleted += result.changes;
if (deadIconRepositories.length >= 1) {
transaction
@@ -161,13 +144,22 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
.run();
}
countDeleted += deadIcons.length;
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
});
},
});
logger.info("Updated icons in database", {
duration: stopWatch.getElapsedInHumanWords(),
duration: stopWatch.getElapsedInFormattedMilliseconds(),
added: countInserted,
deleted: countDeleted,
});
@@ -0,0 +1,14 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami";
import { createCronJob } from "../../lib";
export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, {
widgetKinds: ["umami"],
getInput: {
umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []),
},
}),
);
+3 -1
View File
@@ -4,6 +4,7 @@ import {
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -143,8 +144,9 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
const name = getIntegrationName(kind);
const defaultUrl = getIntegrationDefaultUrl(kind);
const hasNoAuthOption = integrationDefs[kind].secretKinds.some((kinds) => kinds.length === 0);
if (defaultUrl !== undefined) {
if (defaultUrl !== undefined && hasNoAuthOption) {
acc.push({
id: "new",
name: `${name} Default`,
+2
View File
@@ -37,6 +37,8 @@
"push:sqlite": "pnpm with-env drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
"studio": "pnpm with-env drizzle-kit studio --config ./configs/sqlite.config.ts",
"studio:mysql": "pnpm with-env drizzle-kit studio --config ./configs/mysql.config.ts",
"studio:postgresql": "pnpm with-env drizzle-kit studio --config ./configs/postgresql.config.ts",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
+10
View File
@@ -370,6 +370,15 @@ export const integrationDefs = {
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
},
umami: {
name: "Umami",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg",
category: ["analytics"],
defaultUrl: "https://api.umami.is/v1",
// @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -461,6 +470,7 @@ export const integrationCategories = [
"notes",
"mediaMonitoring",
"speedtest",
"analytics",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
+1 -1
View File
@@ -1,2 +1,2 @@
export const colorSchemes = ["light", "dark"] as const;
export const colorSchemes = ["auto", "light", "dark"] as const;
export type ColorScheme = (typeof colorSchemes)[number];
+1
View File
@@ -37,5 +37,6 @@ export const widgetKinds = [
"immich-albumCarousel",
"tracearr",
"speedtestTracker",
"umami",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -77,7 +77,7 @@ export class AnchorIntegration extends Integration {
});
if (!response.ok) {
let errorBody: string | null = null;
let errorBody: string | null;
try {
errorBody = await response.text();
} catch {
@@ -46,6 +46,7 @@ import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -121,6 +122,7 @@ export const integrationCreators = {
searchCh: SearchChIntegration,
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
+2
View File
@@ -33,6 +33,7 @@ export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -68,6 +69,7 @@ export type {
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
+1
View File
@@ -16,3 +16,4 @@ 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 "./umami/umami-types";
@@ -0,0 +1,450 @@
import dayjs from "dayjs";
import { ResponseError } from "@homarr/common/server";
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 {
UmamiEventSeries,
UmamiMetricItem,
UmamiPageviewDataPoint,
UmamiPageviews,
UmamiStats,
UmamiVisitorStats,
UmamiWebsite,
} from "./umami-types";
import {
umamiActiveVisitorsSchema,
umamiAuthResponseSchema,
umamiMetricItemSchema,
umamiPageviewDataPointSchema,
umamiPageviewsSchema,
umamiStatsSchema,
umamiWebsiteSchema,
} from "./umami-types";
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
const UMAMI_CUSTOM_EVENT_TYPE = 2;
const logger = createLogger({ module: "umami-integration" });
export class UmamiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await input.fetchAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getWebsitesAsync(): Promise<UmamiWebsite[]> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
const json = await response.json();
// Response envelope differs by hosting:
// - Umami self-hosted returns the array directly: [website, ...]
// - Umami Cloud wraps it: { data: [website, ...] }
// Same divergence applies to the /events list responses below.
return Array.isArray(json)
? umamiWebsiteSchema.array().parse(json)
: umamiWebsiteSchema.array().parse((json as { data: unknown }).data);
}
// Server-side timeFrame resolution is intentional. Keeping `timeFrame` (a
// stable string like "7d") in the cache key means all users viewing the same
// dashboard share one Redis cache entry per (integration, website, timeFrame)
// for the 5-minute TTL. Client-supplied startAt/endAt would include a
// per-second `now` and fragment the cache one-per-user-per-second.
//
// Window bounds mirror Umami UI preset semantics, verified against the
// Umami Cloud dashboard for the same site and timeFrame: each "Last N"
// preset includes the in-progress period as one of N + 1 buckets, snapped
// to bucket boundaries.
// - "24h": current hour + 24 prior hours (25-bucket hour grid)
// - "7d": current day + 7 prior days ( 8-bucket day grid)
// - "30d": current day + 30 prior days (31-bucket day grid)
// - "today" / "month" / "lastMonth": calendar-aligned windows.
//
// Caveat: dayjs() uses the server's local timezone. Callers needing user-local
// boundaries (e.g. "today") may see off-by-one behaviour when the server
// runs in UTC but the user is in a different timezone.
private computeTimeRange(timeFrame: string): { startAt: number; endAt: number; unit: string } {
const startOfToday = dayjs().startOf("day");
const endOfToday = dayjs().endOf("day");
switch (timeFrame) {
case "today":
return { startAt: startOfToday.valueOf(), endAt: endOfToday.valueOf(), unit: "hour" };
case "7d":
return {
startAt: startOfToday.subtract(7, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "30d":
return {
startAt: startOfToday.subtract(30, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "month":
return {
startAt: dayjs().startOf("month").valueOf(),
endAt: dayjs().endOf("month").valueOf(),
unit: "day",
};
case "lastMonth": {
const lastMonth = dayjs().subtract(1, "month");
return {
startAt: lastMonth.startOf("month").valueOf(),
endAt: lastMonth.endOf("month").valueOf(),
unit: "day",
};
}
case "24h":
default:
return {
startAt: dayjs().subtract(24, "hour").startOf("hour").valueOf(),
endAt: dayjs().endOf("hour").valueOf(),
unit: "hour",
};
}
}
public async getVisitorStatsAsync(
websiteId: string,
timeFrame = "24h",
eventName?: string,
): Promise<UmamiVisitorStats> {
const authHeaders = await this.getAuthHeadersAsync();
const website = await this.getWebsiteByIdAsync(websiteId, authHeaders);
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const [stats, pageviews, eventMetrics, eventTimeSeries] = await Promise.all([
this.getWebsiteStatsAsync(website.id, startAt, endAt, authHeaders),
this.getWebsitePageviewsAsync(website.id, startAt, endAt, authHeaders, unit),
eventName ? this.getWebsiteEventMetricsAsync(website.id, startAt, endAt, authHeaders) : Promise.resolve(null),
eventName
? this.getWebsiteEventTimeSeriesAsync(website.id, startAt, endAt, unit, eventName, authHeaders)
: Promise.resolve(null),
]);
const eventCount = eventName && eventMetrics ? (eventMetrics.find(({ x }) => x === eventName)?.y ?? 0) : undefined;
const eventByTimestamp = new Map<number, number>(
(eventTimeSeries ?? []).map(({ x, y }) => [this.parseUmamiDate(x), y]),
);
const dataPoints = pageviews.sessions.map((point) => ({
timestamp: point.x,
visitors: point.y,
...(eventTimeSeries ? { events: eventByTimestamp.get(this.parseUmamiDate(point.x)) ?? 0 } : {}),
}));
return {
domain: website.domain,
websiteName: website.name,
websiteId: website.id,
totalVisitors: stats.visitors,
totalPageviews: stats.pageviews,
totalVisits: stats.visits,
bounceRate: stats.visits > 0 ? Math.round((stats.bounces / stats.visits) * 100) : 0,
avgDuration: stats.visits > 0 ? Math.round(stats.totaltime / stats.visits) : 0,
dataPoints,
timeFrame,
eventCount,
};
}
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
const authHeaders = await this.getAuthHeadersAsync();
const now = Date.now();
const startAt = dayjs().subtract(30, "day").valueOf();
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: now.toString(),
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event names", { status: response.status, websiteId });
return [];
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
const names = new Set<string>();
for (const item of rawArray) {
const record = item as Record<string, unknown>;
if (typeof record.eventName === "string" && record.eventName) {
names.add(record.eventName);
}
}
return [...names].sort();
}
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url(`/websites/${websiteId}/active`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load active visitors", { status: response.status, websiteId });
return 0;
}
return umamiActiveVisitorsSchema.parse(await response.json()).visitors;
}
/**
* Umami self-hosted returns "YYYY-MM-DD HH:MM:SS"; Umami Cloud returns ISO 8601.
* Normalise both to epoch ms so timestamps from /pageviews and /events join.
*/
private parseUmamiDate(dateString: string): number {
return new Date(dateString.includes("T") ? dateString : `${dateString.replace(" ", "T")}Z`).getTime();
}
private async getWebsiteByIdAsync(websiteId: string, authHeaders: Record<string, string>): Promise<UmamiWebsite> {
const url = this.url(`/websites/${websiteId}`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
return umamiWebsiteSchema.parse(await response.json());
}
public async getTopPagesAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "path", authHeaders, limit);
}
public async getTopReferrersAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "referrer", authHeaders, limit);
}
public async getMultiEventTimeSeriesAsync(
websiteId: string,
timeFrame: string,
eventNames: string[],
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results = await Promise.all(
eventNames.map(async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
return { eventName, dataPoints: dataPoints ?? [] };
}),
);
return results;
}
private async getWebsiteEventMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiMetricItem[]> {
return await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders);
}
private async getWebsiteMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
type: "event" | "path" | "referrer",
authHeaders: Record<string, string>,
limit?: number,
): Promise<UmamiMetricItem[]> {
const params: Record<string, string> = {
startAt: startAt.toString(),
endAt: endAt.toString(),
type,
};
if (limit !== undefined) params.limit = limit.toString();
const url = this.url(`/websites/${websiteId}/metrics`, params);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load website metrics", { status: response.status, websiteId, type });
return [];
}
const json = await response.json();
if (Array.isArray(json)) return umamiMetricItemSchema.array().parse(json);
logger.warn("Unexpected metrics response shape", { websiteId, type });
return [];
}
private async getWebsiteEventTimeSeriesAsync(
websiteId: string,
startAt: number,
endAt: number,
unit: string,
eventName: string,
authHeaders: Record<string, string>,
): Promise<UmamiPageviewDataPoint[] | undefined> {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
return undefined;
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
if (rawArray.length === 0) return undefined;
const firstItem = rawArray[0] as Record<string, unknown>;
// Two response shapes coexist:
// - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
// - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
// We aggregate the raw Cloud records ourselves into the same bucket grid so
// the result joins cleanly with /pageviews timestamps.
// Aggregated { x, y } format — parse directly (self-hosted instances)
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
// Raw event records — aggregate by time bucket (Umami Cloud)
if ("createdAt" in firstItem) {
const pad = (number: number) => String(number).padStart(2, "0");
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
const buckets = new Map<string, number>();
for (const item of rawArray) {
const event = item as { eventType?: number; eventName?: string; createdAt: string };
if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue;
const bucket = truncate(event.createdAt);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.sort(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
}
return undefined;
}
private async getWebsiteStatsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiStats> {
const url = this.url(`/websites/${websiteId}/stats`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiStatsSchema.parse(await response.json());
}
private async getWebsitePageviewsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
unit = "hour",
): Promise<UmamiPageviews> {
const url = this.url(`/websites/${websiteId}/pageviews`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiPageviewsSchema.parse(await response.json());
}
private async getAuthHeadersAsync(): Promise<Record<string, string>> {
if (this.hasSecretValue("apiKey")) {
return { "x-umami-api-key": this.getSecretValue("apiKey") };
}
const token = await this.getJwtTokenAsync();
return { Authorization: `Bearer ${token}` };
}
private async getJwtTokenAsync(): Promise<string> {
const url = this.url("/auth/login");
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) throw new ResponseError(response);
return umamiAuthResponseSchema.parse(await response.json()).token;
}
}
@@ -0,0 +1,84 @@
import { z } from "zod/v4";
export const umamiWebsiteSchema = z.object({
id: z.string(),
name: z.string(),
domain: z.string(),
});
export type UmamiWebsite = z.infer<typeof umamiWebsiteSchema>;
export const umamiAuthResponseSchema = z.object({
token: z.string(),
});
export type UmamiAuthResponse = z.infer<typeof umamiAuthResponseSchema>;
export const umamiStatsSchema = z.object({
pageviews: z.number(),
visitors: z.number(),
visits: z.number(),
bounces: z.number(),
totaltime: z.number(),
});
export type UmamiStats = z.infer<typeof umamiStatsSchema>;
export const umamiPageviewDataPointSchema = z.object({
x: z.string(),
y: z.number(),
});
export type UmamiPageviewDataPoint = z.infer<typeof umamiPageviewDataPointSchema>;
export const umamiPageviewsSchema = z.object({
pageviews: z.array(umamiPageviewDataPointSchema),
sessions: z.array(umamiPageviewDataPointSchema),
});
export type UmamiPageviews = z.infer<typeof umamiPageviewsSchema>;
export const umamiMetricItemSchema = z.object({
// x can be null for direct traffic (no referrer) in the referrers endpoint
x: z
.string()
.nullable()
.transform((value) => value ?? ""),
y: z.number(),
});
export type UmamiMetricItem = z.infer<typeof umamiMetricItemSchema>;
export const umamiEventRecordSchema = z.object({
id: z.string(),
eventName: z.string(),
createdAt: z.string(),
});
export type UmamiEventRecord = z.infer<typeof umamiEventRecordSchema>;
export const umamiActiveVisitorsSchema = z.object({
visitors: z.number(),
});
export type UmamiActiveVisitors = z.infer<typeof umamiActiveVisitorsSchema>;
// Internal aggregated type returned by the integration — not a direct API response
export interface UmamiEventSeries {
eventName: string;
dataPoints: UmamiPageviewDataPoint[];
}
export interface UmamiVisitorStats {
domain: string;
websiteId: string;
websiteName: string;
totalVisitors: number;
totalPageviews: number;
totalVisits: number;
bounceRate: number;
avgDuration: number;
dataPoints: { timestamp: string; visitors: number; events?: number }[];
timeFrame: string;
eventCount?: number;
}
+1 -1
View File
@@ -24,7 +24,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
oldId: id,
id: createId(),
name: user.name.toLowerCase(),
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
colorScheme: settings?.colorScheme === "environment" ? "auto" : settings?.colorScheme,
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons,
@@ -0,0 +1,138 @@
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import { Octokit } from "octokit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getAvailableUpdatesAsync } from "../update-checker";
const mockEnv = vi.hoisted(() => ({
NO_EXTERNAL_CONNECTION: false,
}));
vi.mock("@homarr/common/env", () => ({
env: mockEnv,
}));
vi.mock("octokit", () => {
const mockOctokit = {
rest: {
repos: {
listReleases: vi.fn(),
},
},
};
return {
Octokit: class {
rest = mockOctokit.rest;
},
};
});
describe("getAvailableUpdatesAsync", () => {
beforeEach(() => {
mockEnv.NO_EXTERNAL_CONNECTION = false;
});
test("should return available non pre-release updates", async () => {
// Arrange
const currentVersion = "v1.2.3";
const releases = fakeReleasesWithPrereleases();
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]);
expect(listReleasesSpy).toHaveBeenCalledWith({
owner: "homarr-labs",
repo: "homarr",
});
});
test("should return all available updates", async () => {
// Arrange
const currentVersion = "v1.2.4-beta.2";
const releases = fakeReleasesWithPrereleases();
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual([
"v1.3.1-beta.1",
"v1.3.0",
"v1.3.0-beta.1",
"v1.2.4",
"v1.2.4-beta.3",
]);
expect(listReleasesSpy).toHaveBeenCalledWith({
owner: "homarr-labs",
repo: "homarr",
query: undefined,
});
});
test("should skip releases with invalid semver tags", async () => {
// Arrange
const currentVersion = "v1.2.3";
const releases = fakeReleases(["v1.2.4", false], ["invalid-tag", false], ["v1.3.0", false]);
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockReturnValue(Promise.resolve({ data: releases, status: 200, headers: {}, url: "" }));
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result.map((update) => update.tagName)).toEqual(["v1.3.0", "v1.2.4"]);
});
test("should throw error for invalid current version", async () => {
// Arrange
const currentVersion = "invalid-version";
// Act
const act = () => getAvailableUpdatesAsync(currentVersion);
// Assert
await expect(act()).rejects.toThrow("non semantic current version");
});
test("should return empty array when NO_EXTERNAL_CONNECTION=true", async () => {
// Arrange
mockEnv.NO_EXTERNAL_CONNECTION = true;
const currentVersion = "v1.0.0";
// Act
const result = await getAvailableUpdatesAsync(currentVersion);
// Assert
expect(result).toEqual([]);
});
});
const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease);
const fakeRelease = ([tagName, isPrerelease]: [string, boolean]) =>
({
tag_name: tagName,
html_url: `https://github.com/homarr-labs/homarr/releases/tag/${tagName}`,
name: tagName,
body_html: undefined,
prerelease: isPrerelease,
}) as RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"][number];
const fakeReleasesWithPrereleases = () =>
fakeReleases(
["v1.1.0", false],
["v1.2.2", false],
["v1.2.3", false],
["v1.2.4-beta.1", true],
["v1.2.4-beta.2", true],
["v1.2.4-beta.3", true],
["v1.2.4", false],
["v1.3.0-beta.1", true],
["v1.3.0", false],
["v1.3.1-beta.1", true],
);
+84
View File
@@ -0,0 +1,84 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const umamiRequestHandler = createCachedIntegrationRequestHandler<
UmamiVisitorStats,
"umami",
{ websiteId: string; timeFrame: string; eventName?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiVisitorStats",
});
export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler<
string[],
"umami",
{ websiteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getEventNamesAsync(input.websiteId);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiEventNames",
});
export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopPages",
});
export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopReferrers",
});
export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler<
UmamiEventSeries[],
"umami",
{ websiteId: string; timeFrame: string; eventNames: string[] }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiMultiEvent",
});
export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler<
number,
"umami",
{ websiteId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "umamiActiveVisitors",
});
+92 -48
View File
@@ -1,10 +1,11 @@
import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer } from "semver-parser";
import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser";
import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
@@ -16,58 +17,101 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION)
return {
availableUpdates: [],
};
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const currentVersion = (packageJson as { version: string }).version;
const availableReleases = [];
for (const release of releases.data) {
if (!isValidSemVer(release.tag_name)) {
logger.warn("Unable to parse semantic tag. Update check might not work.", { tagName: release.tag_name });
continue;
}
availableReleases.push(release);
}
const availableNewerReleases = availableReleases
.filter((release) => compareSemVer(release.tag_name, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tag_name, releaseA.tag_name));
if (availableNewerReleases.length > 0) {
logger.info(
"Update checker found a new available version",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ version: availableReleases[0]!.tag_name, currentVersion },
);
} else {
logger.debug("Update checker did not find any available updates", { currentVersion });
}
return {
availableUpdates: availableNewerReleases.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
})),
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
};
},
createRedisChannel() {
return createChannelWithLatestAndEvents<{
availableUpdates: { name: string | null; contentHtml?: string; url: string; tagName: string }[];
availableUpdates: Update[];
}>("homarr:update");
},
});
interface Update {
name: string | null;
contentHtml?: string;
url: string;
tagName: string;
isPrerelease: boolean;
}
const isPrereleaseTag = (tagName: string) => {
try {
const parsed = parseSemVer(tagName);
return Boolean(parsed.pre?.length);
} catch {
return false;
}
};
export const getAvailableUpdatesAsync = async (currentVersion: string) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (!isValidSemVer(currentVersion)) {
throw new ErrorWithMetadata("Unable to check for updates due to non semantic current version", {
currentVersion,
});
}
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const isCurrentPrerelease = isPrereleaseTag(currentVersion);
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const { skippedTags, semanticReleases } = releases.data
.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
isPrerelease: release.prerelease,
}))
.reduce(
(prev, curr) => {
if (!isValidSemVer(curr.tagName)) {
prev.skippedTags.push(curr.tagName);
return prev;
}
prev.semanticReleases.push(curr);
return prev;
},
{ semanticReleases: [] as Update[], skippedTags: [] as string[] },
);
if (skippedTags.length > 0) {
logger.warn(
"Some releases were skipped during the update check because their tag name is not a valid semantic version",
{
skippedTags: skippedTags.join(","),
},
);
}
const availableUpdates = semanticReleases
.filter((release) => isCurrentPrerelease || !release.isPrerelease)
.filter((release) => compareSemVer(release.tagName, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
if (availableUpdates.length === 0) {
logger.debug("No available updates found", { currentVersion });
return [];
}
logger.info("Found available updates", {
version: availableUpdates[0]?.tagName,
count: availableUpdates.length,
currentVersion,
});
return availableUpdates;
};
+1 -1
View File
@@ -36,7 +36,7 @@ export const defaultServerSettings = {
enableGravatar: true,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,
defaultColorScheme: "auto" as ColorScheme,
},
culture: {
defaultLocale: "en" as SupportedLanguage,
@@ -1,7 +1,8 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import {
IconBox,
IconCategoryPlus,
IconDeviceDesktop,
IconFileImport,
IconLanguage,
IconMailForward,
@@ -52,13 +53,17 @@ export const globalCommandGroup = createGroup<Command>({
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
name:
colorScheme === "auto"
? tOption("colorScheme.auto")
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
+104
View File
@@ -1080,6 +1080,7 @@
},
"colorScheme": {
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -2096,6 +2097,104 @@
"internalServerError": "Failed to fetch Tracearr data"
}
},
"umami": {
"name": "Umami Analytics",
"description": "Display visitor stats from your Umami analytics instance",
"visitors": "visitors",
"active": "active",
"pageviews": "pageviews",
"visits": "visits",
"bounceRate": "bounce",
"avgDuration": "avg",
"option": {
"websiteId": {
"label": "Website",
"description": "Select a website tracked by your Umami instance",
"placeholder": "Select a website",
"loading": "Loading websites...",
"noWebsites": "No websites found",
"configureIntegrationFirst": "Connect a Umami integration first"
},
"timeFrame": {
"label": "Time frame",
"description": "The time period to display visitor data for",
"option": {
"today": "Today",
"24h": "Last 24 hours",
"7d": "Last 7 days",
"30d": "Last 30 days",
"month": "This month",
"lastMonth": "Last month"
}
},
"eventName": {
"label": "Event",
"description": "Show total occurrences of a specific event",
"placeholder": "Select an event",
"loading": "Loading events...",
"noEvents": "No events found",
"none": "No event",
"configureWebsiteFirst": "Select a website first to load events"
},
"chartType": {
"label": "Chart type",
"description": "Display style for the chart",
"option": {
"bar": "Bar chart",
"sparkline": "Sparkline"
}
},
"chartStyle": {
"label": "Chart style",
"description": "How to display the event series in the chart",
"option": {
"grouped": "Side by side",
"overlay": "Overlay (subset)"
}
},
"viewMode": {
"label": "View",
"description": "What to display in the widget body",
"option": {
"chart": "Chart",
"events": "Events chart",
"topPages": "Top pages",
"topReferrers": "Top referrers"
}
},
"eventNames": {
"label": "Events",
"description": "Select one or more events to chart",
"placeholder": "Select events...",
"loading": "Loading events...",
"noEvents": "No events found",
"configureWebsiteFirst": "Select a website first to load events"
},
"topCount": {
"label": "Items to show",
"description": "Number of top items to display (max 500)"
}
},
"event": {
"occurrences": "events",
"label": "events"
},
"topPages": {
"direct": "Direct"
},
"topReferrers": {
"direct": "Direct"
},
"events": {
"selectPrompt": "Select events to chart"
},
"error": {
"noIntegration": "No Umami integration configured",
"noWebsite": "No website configured",
"noData": "No data available",
"internalServerError": "Failed to fetch Umami data"
}
},
"common": {
"location": {
"query": "City / Postal code",
@@ -3568,6 +3667,7 @@
"defaultColorScheme": {
"label": "Default color scheme",
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -3668,6 +3768,9 @@
},
"tracearr": {
"label": "Tracearr"
},
"umamiActiveVisitors": {
"label": "Umami Active Visitors"
}
},
"interval": {
@@ -4380,6 +4483,7 @@
"title": "Global commands",
"option": {
"colorScheme": {
"auto": "Toggle appearance mode",
"light": "Switch to light mode",
"dark": "Switch to dark mode"
},
+2 -2
View File
@@ -3791,8 +3791,8 @@
"subtitle": "{count} contribuendo alle traduzioni in molte lingue"
},
"sponsors": {
"title": "",
"subtitle": ""
"title": "Sponsor",
"subtitle": "{count} sostengono il progetto finanziariamente"
},
"libraries": {
"title": "Librerie",
+88 -88
View File
@@ -1526,13 +1526,13 @@
}
},
"timetable": {
"name": "",
"description": "",
"title": "",
"noStation": "",
"name": "Timeplan",
"description": "Viser avgangstid for en stasjon",
"title": "Avganger {station}",
"noStation": "Ingen stasjon valgt",
"option": {
"station": {
"label": ""
"label": "Stasjon"
}
}
},
@@ -1607,32 +1607,32 @@
}
},
"anchorNote": {
"name": "",
"description": "",
"name": "Ankermerknad",
"description": "Viser et valgt notat fra anker",
"option": {
"noteId": {
"label": ""
"label": "Notat"
},
"showTitle": {
"label": ""
"label": "Vis tittel"
},
"showUpdatedAt": {
"label": ""
"label": "Vis sist oppdatert"
}
},
"empty": "",
"emptyContent": "",
"notFound": "",
"noNotes": "",
"integrationRequired": "",
"updatedAt": "",
"untitled": "",
"edit": "",
"save": "",
"cancel": "",
"readOnlyViewer": "",
"saveForbidden": "",
"saveFailed": ""
"empty": "Velg et notat som skal vises",
"emptyContent": "Dette notatet har ikke innhold",
"notFound": "Notat ikke funnet",
"noNotes": "Fant ingen notater",
"integrationRequired": "Velg en ankerintegrasjon først",
"updatedAt": "Oppdatert {date}",
"untitled": "Uten navn",
"edit": "Rediger",
"save": "Lagre",
"cancel": "Avbryt",
"readOnlyViewer": "Du har seertilgang til dette notatet. Redigering er deaktivert.",
"saveForbidden": "Du har ikke tillatelse til å redigere dette notatet.",
"saveFailed": "Kunne ikke lagre dette notatet. Prøv på nytt."
},
"iframe": {
"name": "iFrame",
@@ -1666,7 +1666,7 @@
"label": "Tillat geolokalisering"
},
"allowModals": {
"label": ""
"label": "Tillat modaler"
}
},
"error": {
@@ -1901,7 +1901,7 @@
"label": "Vis minneinfo"
},
"gpu": {
"label": ""
"label": "Vis CPU-info"
},
"showUptime": {
"label": "Vis oppetid"
@@ -2042,58 +2042,58 @@
}
},
"tracearr": {
"name": "",
"description": "",
"name": "Tracearr",
"description": "Forbedret medieserverovervåking med detaljert strøm- og transkodingsdata",
"option": {
"showStreams": {
"label": ""
"label": "Vis aktive strømmer"
},
"showStats": {
"label": ""
"label": "Vis statistikksammendrag"
},
"showRecentActivity": {
"label": ""
"label": "Vis nylig aktivitet"
},
"showViolations": {
"label": ""
"label": "Vis brudd"
}
},
"stats": {
"activeStreams": "",
"totalUsers": "",
"transcodes": "",
"bandwidth": ""
"activeStreams": "Aktive strømmninger",
"totalUsers": "Totalt antall brukere",
"transcodes": "Transkoder",
"bandwidth": "Båndbredde"
},
"streams": {
"empty": "",
"user": "",
"media": "",
"state": "",
"quality": "",
"device": "",
"progress": "",
"empty": "Ingen aktive strømminger",
"user": "Bruker",
"media": "Media",
"state": "Tilstand",
"quality": "Kvalitet",
"device": "Enhet",
"progress": "Fremgang",
"videoDecision": {
"directPlay": "",
"transcode": "",
"directStream": ""
"directPlay": "Direkte avspilling",
"transcode": "Transkode",
"directStream": "Direktestrøm"
}
},
"recentActivity": {
"title": "",
"empty": "",
"watched": "",
"partial": ""
"title": "Nylig aktivitet",
"empty": "Ingen nylig aktivitet",
"watched": "Sett",
"partial": "Delvis"
},
"violations": {
"title": "",
"empty": "",
"rule": "",
"user": ""
"title": "Brudd",
"empty": "Ingen nylige brudd",
"rule": "Regel",
"user": "Bruker"
},
"noSectionsEnabled": "",
"noSectionsEnabled": "Ingen seksjoner aktivert",
"error": {
"noIntegration": "",
"internalServerError": ""
"noIntegration": "Ingen Tracearr-integrasjon konfigurert",
"internalServerError": "Kunne ikke hente Tracearr data"
}
},
"common": {
@@ -2546,7 +2546,7 @@
"label": "Skjul beskrivelse"
},
"showPosterImage": {
"label": ""
"label": "Vis plakatbilde"
}
}
},
@@ -2761,42 +2761,42 @@
"option": {}
},
"speedtestTracker": {
"name": "",
"description": "",
"name": "Speedtest tracker",
"description": "Viser hastighetstesten i fra din Speedtest Tracker-instans.",
"option": {
"showLatestResult": {
"label": ""
"label": "Vis siste resultat"
},
"showStats": {
"label": ""
"label": "Vis gjennomsnitt"
},
"showRecentResults": {
"label": ""
"label": "Vis nylige resultater"
},
"showPingGraph": {
"label": ""
"label": "Vis ping-graf"
}
},
"latestResult": "",
"averages": "",
"tests": "",
"recentResults": "",
"recentResultsSpeed": "",
"recentResultsPing": "",
"download": "",
"upload": "",
"ping": "",
"status": "",
"avgDownload": "",
"avgUpload": "",
"avgPing": "",
"healthy": "",
"unhealthy": "",
"ok": "",
"fail": "",
"noSectionsEnabled": "",
"latestResult": "Siste resultat",
"averages": "Gjennomsnitt",
"tests": "{count} tester",
"recentResults": "Nylige resultater",
"recentResultsSpeed": "Hastighet",
"recentResultsPing": "Ping",
"download": "Nedlasting",
"upload": "Opplastning",
"ping": "Ping",
"status": "Status",
"avgDownload": "Snitt nedlasting",
"avgUpload": "Snitt opplasting",
"avgPing": "Snitt ping",
"healthy": "Frisk",
"unhealthy": "Usunn",
"ok": "OK",
"fail": "Feil",
"noSectionsEnabled": "Aktiver minst ett avsnitt i widgetalternativer.",
"error": {
"internalServerError": ""
"internalServerError": "Kunne ikke hente hastighetsteste data"
}
},
"systemResources": {
@@ -2812,7 +2812,7 @@
"option": {
"cpu": "CPU",
"memory": "Minne",
"gpu": "",
"gpu": "GPU",
"network": "Nettverk"
}
},
@@ -2829,7 +2829,7 @@
"card": {
"cpu": "CPU",
"memory": "Minne",
"gpu": "",
"gpu": "GPU",
"network": "NETT",
"up": "OPP",
"down": "NED"
@@ -3595,7 +3595,7 @@
"label": "Minecraft serverstatus"
},
"timetable": {
"label": ""
"label": "Timeplan"
},
"iconsUpdater": {
"label": "Ikon oppdatering"
@@ -3667,7 +3667,7 @@
"label": "Vær"
},
"tracearr": {
"label": ""
"label": "Tracearr"
}
},
"interval": {
@@ -3791,8 +3791,8 @@
"subtitle": "{count} bidrar med oversettelser på mange språk"
},
"sponsors": {
"title": "",
"subtitle": ""
"title": "Sponsorer",
"subtitle": "{count} støtter prosjektet økonomisk"
},
"libraries": {
"title": "Biblioteker",
+6
View File
@@ -12,6 +12,9 @@ import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -27,6 +30,9 @@ const mapping = {
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
dynamicSelect: WidgetDynamicSelectInput,
umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -0,0 +1,46 @@
"use client";
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventName">) => {
const t = useScopedI18n("widget.umami.option.eventName");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds;
const websiteId = form.values.options.websiteId as string | undefined;
const { data: eventNames, isPending } = clientApi.widget.umami.getEventNames.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId: websiteId ?? "" },
{ enabled: Boolean(integrationIds[0]) && Boolean(websiteId) },
);
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
</Text>
);
}
const data = [{ value: "", label: t("none") }, ...(eventNames ?? [])];
return (
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : undefined}
searchable
nothingFoundMessage={t("noEvents")}
data={data}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -0,0 +1,44 @@
"use client";
import { MultiSelect, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiEventNamesInput = ({ property, kind }: CommonWidgetInputProps<"umamiEventNames">) => {
const t = useScopedI18n("widget.umami.option.eventNames");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds;
const websiteId = form.values.options.websiteId as string | undefined;
const { data: eventNames, isPending } = clientApi.widget.umami.getEventNames.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId: websiteId ?? "" },
{ enabled: Boolean(integrationIds[0]) && Boolean(websiteId) },
);
if (!websiteId || integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureWebsiteFirst")}
</Text>
);
}
return (
<MultiSelect
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
searchable
nothingFoundMessage={t("noEvents")}
data={eventNames ?? []}
{...form.getInputProps(`options.${property}`)}
/>
);
};
@@ -0,0 +1,44 @@
"use client";
import { Select, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetUmamiWebsiteInput = ({ property, kind }: CommonWidgetInputProps<"umamiWebsite">) => {
const t = useScopedI18n("widget.umami.option.websiteId");
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds;
const { data: websites, isPending } = clientApi.widget.umami.getWebsites.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
);
if (integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
return (
<Select
label={tInput("label")}
description={tInput("description")}
placeholder={isPending ? t("loading") : t("placeholder")}
clearable
searchable
nothingFoundMessage={t("noWebsites")}
data={(websites ?? []).map((site) => ({ value: site.id, label: site.domain }))}
{...form.getInputProps(`options.${property}`)}
/>
);
};
+2
View File
@@ -46,6 +46,7 @@ import * as systemDisks from "./system-disks";
import * as systemResources from "./system-resources";
import * as timetable from "./timetable";
import * as tracearr from "./tracearr";
import * as umami from "./umami";
import * as video from "./video";
import * as weather from "./weather";
@@ -91,6 +92,7 @@ export const widgetImports = {
"immich-albumCarousel": immichAlbumCarousel,
tracearr,
speedtestTracker,
umami,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+15
View File
@@ -160,6 +160,21 @@ const optionsFactory = {
defaultValue: "",
withDescription: false,
}),
umamiEventName: () => ({
type: "umamiEventName" as const,
defaultValue: "",
withDescription: true,
}),
umamiEventNames: () => ({
type: "umamiEventNames" as const,
defaultValue: [] as string[],
withDescription: true,
}),
umamiWebsite: () => ({
type: "umamiWebsite" as const,
defaultValue: "",
withDescription: true,
}),
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
input: SortableItemListInput<TItem, TOptionValue>,
) => ({
@@ -76,6 +76,7 @@ export default function SmartHomeEntityStateWidget({
return (
<UnstyledButton
mod={{ "entity-state": entityState, "entity-id": options.entityId }}
onClick={handleClick}
w="100%"
h="100%"
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core";
import { Card, Center, Group, Loader, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
@@ -36,14 +36,13 @@ export const CommonChart = ({
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme();
const scheme = useMantineColorScheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
const backgroundColor =
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { UmamiContent } from "./umami-content";
export default function UmamiWidget({ options, integrationIds }: WidgetComponentProps<"umami">) {
if (!options.websiteId || options.websiteId.trim() === "") {
return <NoWebsiteConfigured />;
}
return (
<UmamiContent
integrationIds={integrationIds}
websiteId={options.websiteId}
timeFrame={options.timeFrame}
eventName={options.eventName || undefined}
eventNames={options.eventNames}
chartStyle={options.chartStyle}
chartType={options.chartType}
viewMode={options.viewMode}
topCount={options.topCount}
/>
);
}
function NoWebsiteConfigured() {
const t = useScopedI18n("widget.umami");
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("error.noWebsite")}
</Text>
</Stack>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { IconChartBar, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth"] as const;
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
supportedIntegrations: ["umami"],
createOptions() {
return optionsBuilder.from(
(factory) => ({
websiteId: factory.umamiWebsite(),
timeFrame: factory.select({
options: timeFrameValues.map((value) => ({
value,
label: (t) => t(`widget.umami.option.timeFrame.option.${value}`),
})),
defaultValue: "24h",
withDescription: true,
}),
viewMode: factory.select({
options: [
{ value: "chart", label: (t) => t("widget.umami.option.viewMode.option.chart") },
{ value: "events", label: (t) => t("widget.umami.option.viewMode.option.events") },
{ value: "topPages", label: (t) => t("widget.umami.option.viewMode.option.topPages") },
{ value: "topReferrers", label: (t) => t("widget.umami.option.viewMode.option.topReferrers") },
] as const,
defaultValue: "chart",
withDescription: true,
}),
chartType: factory.select({
options: [
{ value: "bar", label: (t) => t("widget.umami.option.chartType.option.bar") },
{ value: "sparkline", label: (t) => t("widget.umami.option.chartType.option.sparkline") },
] as const,
defaultValue: "bar",
withDescription: true,
}),
chartStyle: factory.select({
options: [
{ value: "grouped", label: (t) => t("widget.umami.option.chartStyle.option.grouped") },
{ value: "overlay", label: (t) => t("widget.umami.option.chartStyle.option.overlay") },
] as const,
defaultValue: "grouped",
withDescription: true,
}),
eventName: factory.umamiEventName(),
eventNames: factory.umamiEventNames(),
topCount: factory.number({
validate: z.number().int().min(1).max(500),
defaultValue: 5,
step: 1,
withDescription: true,
}),
}),
{
viewMode: {
shouldHide: (options) => !options.websiteId,
},
chartType: {
shouldHide: (options) =>
!options.websiteId || (options.viewMode !== "chart" && options.viewMode !== "events"),
},
chartStyle: {
shouldHide: (options) => options.viewMode !== "chart" || !options.eventName || options.chartType !== "bar",
},
eventName: {
shouldHide: (options) => !options.websiteId || options.viewMode !== "chart",
},
eventNames: {
shouldHide: (options) => !options.websiteId || options.viewMode !== "events",
},
topCount: {
shouldHide: (options) => !options.websiteId || options.viewMode === "chart" || options.viewMode === "events",
},
},
);
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.umami.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,220 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
import type { UmamiVisitorStats } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
interface UmamiContentProps {
integrationIds: string[];
websiteId: string;
timeFrame: TimeFrame;
eventName?: string;
eventNames: string[];
chartStyle: string;
chartType: string;
viewMode: string;
topCount: number;
}
export function UmamiContent({
integrationIds,
websiteId,
timeFrame,
eventName,
eventNames,
chartStyle,
chartType,
viewMode,
topCount,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const [results] = clientApi.widget.umami.getVisitorStats.useSuspenseQuery({
integrationIds,
websiteId,
timeFrame,
eventName,
});
const activeVisitorsInput = { integrationId: integrationIds[0] ?? "", websiteId };
const [activeVisitors] = clientApi.widget.umami.getActiveVisitors.useSuspenseQuery(activeVisitorsInput);
const utils = clientApi.useUtils();
clientApi.widget.umami.subscribeActiveVisitors.useSubscription(activeVisitorsInput, {
onData(count) {
utils.widget.umami.getActiveVisitors.setData(activeVisitorsInput, () => count);
},
});
const { data: multiEventSeries } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].sort() },
{ enabled: viewMode === "events" && eventNames.length > 0 },
);
const multiEventTotal = multiEventSeries
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
: undefined;
const firstResult = results[0];
if (!firstResult) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("error.noData")}
</Text>
</Stack>
);
}
const { visitorStats } = firstResult;
const chartData = visitorStats.dataPoints.map((point: UmamiVisitorStats["dataPoints"][number]) => ({
label: formatXLabel(point.timestamp, timeFrame),
visitors: point.visitors,
...(point.events !== undefined ? { events: point.events } : {}),
}));
const hasEventSeries = visitorStats.dataPoints.some(({ events }) => events !== undefined);
const series = [
{ name: "visitors", color: "blue.5" },
...(hasEventSeries ? [{ name: "events", color: "orange.5" }] : []),
];
const isOverlay = chartStyle === "overlay" && hasEventSeries;
const overlayBarSize = 16;
return (
<Stack gap={4} p="xs" h="100%">
<Group justify="space-between" align="baseline" wrap="nowrap">
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
</Text>
{(eventName && viewMode === "chart" && visitorStats.eventCount !== undefined) ||
(viewMode === "events" && multiEventTotal !== undefined) ? (
<Group gap={6} align="baseline" wrap="nowrap">
<Group gap={4} align="baseline">
<Text size="lg" fw={700} lh={1}>
{viewMode === "events"
? (multiEventTotal ?? 0).toLocaleString()
: (visitorStats.eventCount ?? 0).toLocaleString()}
</Text>
<Text size="xs" c="dimmed">
{t("event.label")}
</Text>
</Group>
<Text size="xs" c="dimmed">
/
</Text>
<Group gap={4} align="baseline">
<Text size="lg" fw={700} lh={1}>
{visitorStats.totalVisitors.toLocaleString()}
</Text>
<Text size="xs" c="dimmed">
{t("visitors")}
</Text>
</Group>
</Group>
) : (
<Group gap={4} align="baseline">
<Text size="xl" fw={700} lh={1}>
{visitorStats.totalVisitors.toLocaleString()}
</Text>
<Text size="xs" c="dimmed">
{t("visitors")}
</Text>
</Group>
)}
</Group>
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
<Text size="xs" c="green">
● {activeVisitors.toLocaleString()} {t("active")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.bounceRate}% {t("bounceRate")}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
</Text>
</Group>
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{viewMode === "events" ? (
<UmamiEventsContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
eventNames={eventNames}
chartType={chartType}
/>
) : viewMode === "topPages" ? (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
) : viewMode === "topReferrers" ? (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
) : chartType === "sparkline" ? (
<LineChart
h="100%"
data={chartData}
dataKey="label"
series={series}
withDots={false}
curveType="monotone"
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
) : (
<BarChart
h="100%"
data={chartData}
dataKey="label"
series={series}
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }}
barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
)}
</Box>
</Stack>
);
}
@@ -0,0 +1,138 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
interface UmamiEventsContentProps {
integrationIds: string[];
websiteId: string;
timeFrame: string;
eventNames: string[];
chartType: string;
}
export function UmamiEventsContent({
integrationIds,
websiteId,
timeFrame,
eventNames,
chartType,
}: UmamiEventsContentProps) {
const t = useScopedI18n("widget.umami");
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const [series] = clientApi.widget.umami.getMultiEventTimeSeries.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
eventNames: [...eventNames].sort(),
});
if (eventNames.length === 0) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("events.selectPrompt")}
</Text>
</Stack>
);
}
// Collect all unique timestamps across all series
const allTimestamps = Array.from(
new Set(series.flatMap(({ dataPoints }) => dataPoints.map(({ x: xPoint }) => xPoint))),
).sort();
// Build per-event lookup by timestamp string
const byEvent = new Map(
series.map((serie: UmamiEventSeries) => [
serie.eventName,
new Map(serie.dataPoints.map((point) => [point.x, point.y])),
]),
);
const chartData = allTimestamps.map((timestamp) => {
const row: Record<string, string | number> = { label: formatXLabel(timestamp, timeFrame) };
for (const serie of series) {
row[serie.eventName] = byEvent.get(serie.eventName)?.get(timestamp) ?? 0;
}
return row;
});
const chartSeries = series.map((serie: UmamiEventSeries, index: number) => ({
name: serie.eventName,
color: EVENT_COLORS[index % EVENT_COLORS.length] ?? "blue.5",
}));
return (
<Stack gap={4} h="100%">
{chartSeries.length > 1 && (
<Group gap={12} justify="center" wrap="wrap" style={{ flexShrink: 0, rowGap: 2 }}>
{chartSeries.map((serie) => (
<Group key={serie.name} gap={4} align="center" wrap="nowrap">
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${serie.color.replace(".", "-")})`,
flexShrink: 0,
}}
/>
<Text size="xs" c="dimmed" truncate>
{serie.name}
</Text>
</Group>
))}
</Group>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
{chartType === "sparkline" ? (
<LineChart
h="100%"
data={chartData}
dataKey="label"
series={chartSeries}
withDots={false}
curveType="monotone"
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
) : (
<BarChart
h="100%"
data={chartData}
dataKey="label"
series={chartSeries}
tickLine="none"
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withYAxis={false}
barProps={{ radius: 2 }}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
interval: "preserveStartEnd",
}}
/>
)}
</Box>
</Stack>
);
}
@@ -0,0 +1,77 @@
"use client";
import { Group, ScrollArea, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiMetricItem } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
interface UmamiTopListProps {
integrationIds: string[];
websiteId: string;
timeFrame: string;
limit: number;
}
export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const [data] = clientApi.widget.umami.getTopPages.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
limit,
});
return <UmamiTopList items={data} heading={t("option.viewMode.option.topPages")} emptyLabel={t("topPages.direct")} />;
}
export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) {
const t = useScopedI18n("widget.umami");
const [data] = clientApi.widget.umami.getTopReferrers.useSuspenseQuery({
integrationId: integrationIds[0] ?? "",
websiteId,
timeFrame,
limit,
});
return (
<UmamiTopList
items={data}
heading={t("option.viewMode.option.topReferrers")}
emptyLabel={t("topReferrers.direct")}
/>
);
}
function UmamiTopList({
items,
heading,
emptyLabel,
}: {
items: UmamiMetricItem[];
heading: string;
emptyLabel: string;
}) {
return (
<Stack gap={2} h="100%">
<Text size="xs" c="dimmed" fw={500}>
{heading}
</Text>
<ScrollArea style={{ flex: 1 }} scrollbars="y">
<Stack gap={2}>
{items.map((item, i) => (
<Group key={item.x} gap="xs" wrap="nowrap" px={2}>
<Text size="xs" c="dimmed" w={18} ta="right" flex="0 0 auto">
{i + 1}.
</Text>
<Text size="xs" truncate="end" style={{ flex: 1 }}>
{item.x || emptyLabel}
</Text>
<Text size="xs" fw={600} flex="0 0 auto">
{item.y.toLocaleString()}
</Text>
</Group>
))}
</Stack>
</ScrollArea>
</Stack>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { useScopedI18n } from "@homarr/translation/client";
import { timeFrameValues } from "./index";
export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"];
export function formatXLabel(timestamp: string, timeFrame: string): string {
const date = new Date(timestamp);
switch (timeFrame) {
case "today":
case "24h":
return `${date.getHours().toString().padStart(2, "0")}:00`;
case "7d":
case "30d":
case "month":
case "lastMonth":
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
default:
return timestamp;
}
}
export type TimeFrame = (typeof timeFrameValues)[number];
export function formatTimeFrameLabel(
timeFrame: TimeFrame,
t: ReturnType<typeof useScopedI18n<"widget.umami">>,
): string {
if (timeFrameValues.includes(timeFrame)) {
return t(`option.timeFrame.option.${timeFrame}`);
}
return timeFrame;
}
+1078 -1105
View File
File diff suppressed because it is too large. Load diff
+44 -44
View File
@@ -2,11 +2,6 @@ packages:
- apps/*
- packages/*
- tooling/*
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- next
- "@next/*"
catalogMode: strict
catalog:
"@auth/core": ^0.41.2
"@auth/drizzle-adapter": ^1.11.2
@@ -18,6 +13,7 @@ catalog:
"@dnd-kit/sortable": ^10.0.0
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
'@eslint/js': 10.0.1
"@extractus/feed-extractor": 7.1.7
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
@@ -49,34 +45,34 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.0
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.41.1
"@tanstack/react-query": ^5.100.2
"@tanstack/react-query-devtools": ^5.100.2
"@tanstack/react-query-next-experimental": ^5.100.2
"@tanstack/react-query": ^5.100.8
"@tanstack/react-query-devtools": ^5.100.8
"@tanstack/react-query-next-experimental": ^5.100.8
"@testcontainers/mysql": ^11.14.0
"@testcontainers/postgresql": ^11.14.0
"@testcontainers/redis": ^11.14.0
"@tiptap/extension-color": 3.22.4
"@tiptap/extension-highlight": 3.22.4
"@tiptap/extension-image": 3.22.4
"@tiptap/extension-link": ^3.22.4
"@tiptap/extension-placeholder": ^3.22.4
"@tiptap/extension-table": 3.22.4
"@tiptap/extension-table-cell": 3.22.4
"@tiptap/extension-table-header": 3.22.4
"@tiptap/extension-table-row": 3.22.4
"@tiptap/extension-task-item": 3.22.4
"@tiptap/extension-task-list": 3.22.4
"@tiptap/extension-text-align": 3.22.4
"@tiptap/extension-text-style": 3.22.4
"@tiptap/extension-underline": 3.22.4
"@tiptap/react": ^3.22.4
"@tiptap/starter-kit": ^3.22.4
"@trpc/client": ^11.16.0
"@trpc/next": ^11.16.0
"@trpc/react-query": ^11.16.0
"@trpc/server": ^11.16.0
"@trpc/tanstack-react-query": ^11.16.0
"@turbo/gen": ^2.9.6
"@tiptap/extension-color": 3.22.5
"@tiptap/extension-highlight": 3.22.5
"@tiptap/extension-image": 3.22.5
"@tiptap/extension-link": ^3.22.5
"@tiptap/extension-placeholder": ^3.22.5
"@tiptap/extension-table": 3.22.5
"@tiptap/extension-table-cell": 3.22.5
"@tiptap/extension-table-header": 3.22.5
"@tiptap/extension-table-row": 3.22.5
"@tiptap/extension-task-item": 3.22.5
"@tiptap/extension-task-list": 3.22.5
"@tiptap/extension-text-align": 3.22.5
"@tiptap/extension-text-style": 3.22.5
"@tiptap/extension-underline": 3.22.5
"@tiptap/react": ^3.22.5
"@tiptap/starter-kit": ^3.22.5
"@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0
"@trpc/server": ^11.17.0
"@trpc/tanstack-react-query": ^11.17.0
"@turbo/gen": ^2.9.7
"@types/adm-zip": 0.5.8
"@types/bcrypt": 6.0.0
"@types/better-sqlite3": 7.6.13
@@ -86,7 +82,6 @@ catalog:
"@types/css-modules": ^1.0.5
"@types/dockerode": ^4.0.1
"@types/node": ^24.12.2
"@types/node-cron": ^3.0.11
"@types/node-unifi": ^2.5.1
"@types/pg": ^8.20.0
"@types/prismjs": ^1.26.6
@@ -113,6 +108,7 @@ catalog:
conventional-changelog-conventionalcommits: ^9.3.1
cookie: 1.1.1
cookies: ^0.9.1
croner: 10.0.1
cross-env: ^10.1.0
crypto-js: ^4.2.0
dayjs: ^1.11.20
@@ -125,9 +121,9 @@ catalog:
drizzle-orm: ^0.45.2
drizzle-zod: ^0.8.3
esbuild: ^0.28.0
eslint: ^9.39.4
eslint: ^10.3.0
eslint-config-prettier: ^10.1.8
eslint-config-turbo: ^2.9.6
eslint-config-turbo: ^2.9.7
eslint-plugin-import: ^2.32.0
eslint-plugin-jsx-a11y: ^6.10.2
eslint-plugin-react: ^7.37.5
@@ -138,20 +134,19 @@ catalog:
glob: ^13.0.6
ical.js: ^2.2.1
ioredis: 5.10.1
isomorphic-dompurify: ^3.10.0
isomorphic-dompurify: ^3.12.0
jotai: ^2.19.1
jsdom: ^29.0.2
jsdom: ^29.1.1
json5: ^2.2.3
ldapts: 8.1.7
lodash.clonedeep: ^4.5.0
mantine-form-zod-resolver: ^1.3.0
mantine-react-table: 2.0.0-beta.9
maria2: ^0.4.1
mysql2: 3.22.2
mysql2: 3.22.3
next: 16.2.4
next-auth: 5.0.0-beta.31
next-intl: 4.9.1
node-cron: ^4.2.1
next-intl: 4.11.0
node-loader: ^2.1.0
octokit: ^5.0.5
pg: ^8.20.0
@@ -172,13 +167,13 @@ catalog:
semver-parser: ^4.1.8
superjson: 2.2.6
svgson: ^5.3.1
swagger-ui-react: ^5.32.4
swagger-ui-react: ^5.32.5
testcontainers: ^11.14.0
timezones-ical-library: ^2.1.3
timezones-ical-library: ^2.2.0
trpc-to-openapi: ^3.2.0
tsdav: ^2.1.8
tsdav: ^2.2.0
tsx: 4.20.4
turbo: ^2.9.6
turbo: ^2.9.7
typescript: ^5.9.3
typescript-eslint: ^8.58.2
undici: 7.24.6
@@ -189,7 +184,12 @@ catalog:
winston: 3.19.0
ws: ^8.20.0
xml2js: ^0.6.2
yaml: 2.8.3
zod: ^4.3.6
yaml: 2.8.4
zod: ^4.4.2
zod-form-data: ^3.0.1
zod-validation-error: ^5.0.0
catalogMode: strict
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- next
- "@next/*"
+1 -1
View File
@@ -1 +1 @@
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"tancak","avatar_url":"https://avatars.githubusercontent.com/u/11760749?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontent.com/uLine truncated
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontent.com/u/47495914?v=4"},{"login":"bebsworthy","avatar_url":"https://avatars.githubusercontent.cLine truncated
+1 -1
View File
@@ -1 +1 @@
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated
+2 -3
View File
@@ -1,10 +1,9 @@
/// <reference types="./types.d.ts" />
import eslint from "@eslint/js";
import importPlugin from "eslint-plugin-import";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default tseslint.config(
export default defineConfig(
{
// Globally ignored files
ignores: ["**/*.config.js", "**/*.cjs", "**/lang/*.d.json.ts"],
+1
View File
@@ -27,6 +27,7 @@
"typescript-eslint": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "catalog:",
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="./types.d.ts" />
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
-45
View File
@@ -1,38 +1,3 @@
/**
* Since the ecosystem hasn't fully migrated to ESLint's new FlatConfig system yet,
* we "need" to type some of the plugins manually :(
*/
declare module "@eslint/js" {
// Why the hell doesn't eslint themselves export their types?
import type { Linter } from "eslint";
export const configs: {
readonly recommended: { readonly rules: Readonly<Linter.RulesRecord> };
readonly all: { readonly rules: Readonly<Linter.RulesRecord> };
};
}
declare module "eslint-plugin-import" {
import type { Linter, Rule } from "eslint";
export const configs: {
recommended: { rules: Linter.RulesRecord };
};
export const rules: Record<string, Rule.RuleModule>;
}
declare module "eslint-plugin-react" {
import type { Linter, Rule } from "eslint";
export const configs: {
recommended: { rules: Linter.RulesRecord };
all: { rules: Linter.RulesRecord };
"jsx-runtime": { rules: Linter.RulesRecord };
};
export const rules: Record<string, Rule.RuleModule>;
}
declare module "eslint-plugin-react-hooks" {
import type { Linter, Rule } from "eslint";
@@ -46,13 +11,3 @@ declare module "eslint-plugin-react-hooks" {
};
export const rules: Record<string, Rule.RuleModule>;
}
declare module "@next/eslint-plugin-next" {
import type { Linter, Rule } from "eslint";
export const configs: {
recommended: { rules: Linter.RulesRecord };
"core-web-vitals": { rules: Linter.RulesRecord };
};
export const rules: Record<string, Rule.RuleModule>;
}