Compare commits

..
Author SHA1 Message Date
Nerdy-Technician fe07d2379a fix(integrations): update PatchMon API key settings link
Point the PatchMon “Get API key” link to /settings/integrations and add coverage for the generated URL.

Closes #6284
2026-07-11 21:45:46 +01:00
126 changed files with 1719 additions and 9912 deletions

No files matched your search

-7
View File
@@ -6,14 +6,7 @@ npm-debug.log
README.md README.md
.next .next
.turbo .turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git .git
.github
dev dev
.build .build
e2e e2e
-2
View File
@@ -1,2 +0,0 @@
.secrets
!.secrets.example
-4
View File
@@ -1,4 +0,0 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
-36
View File
@@ -1,36 +0,0 @@
# Running CI locally
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
## PR preview images
Successful non-draft pull requests from this repository publish an amd64 image:
```bash
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
```
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
-8
View File
@@ -11,11 +11,3 @@
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation) - [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/). - [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
**When contributing a new integration or widget:**
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
tests (e.g. what happens if it is offline)
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
provide support and maintain the integration (e.g. in case of breaking or failure)
-47
View File
@@ -1,47 +0,0 @@
name: Auto-fix lint and format
on:
pull_request:
types: [opened, synchronize, reopened]
branches: ["*"]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
autofix:
name: Auto-fix
if: github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
-264
View File
@@ -1,264 +0,0 @@
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: ["*"]
pull_request_target:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
merge_group:
workflow_dispatch:
inputs:
operation:
description: Operation to run
required: true
default: validate
type: choice
options:
- validate
- publish
- cleanup
pr_number:
description: PR number for publish or cleanup
required: false
type: number
ref:
description: Git ref to validate or publish
required: false
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_TELEMETRY_DISABLED: 1
REGISTRY: ghcr.io
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
jobs:
fast-gate:
name: Fast gate
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Validate manual inputs
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
env:
PR_NUMBER: ${{ inputs.pr_number }}
BUILD_REF: ${{ inputs.ref }}
run: |
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
uses: docker/setup-buildx-action@v4
- name: Build container once
if: "!env.ACT"
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
- name: Build container once with Act
if: env.ACT
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Determine preview tag
id: preview
if: |
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: steps.preview.outputs.tag != ''
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push preview image
if: steps.preview.outputs.tag != ''
env:
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
run: |
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
- name: Show Act publication result
if: env.ACT
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
cleanup:
name: Clean up preview image
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
packages: write
env:
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Validate cleanup input
if: github.event_name == 'workflow_dispatch'
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: "!env.ACT"
uses: actions/github-script@v8
with:
script: |
const packageName = "homarr-test";
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ package_type: "container", package_name: packageName, org, per_page: 100 },
);
const target = versions.find((version) =>
version.metadata?.container?.tags?.includes(tag),
);
if (!target) {
core.info(`No package version found with tag "${tag}"`);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
org,
package_version_id: target.id,
});
core.info(`Deleted package version ${target.id} (${tag})`);
- name: Show Act cleanup result
if: env.ACT
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
+165
View File
@@ -0,0 +1,165 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm turbo format --affected --continue
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
- name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-e2e
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
@@ -148,11 +148,6 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
build-arm64: build-arm64:
@@ -192,11 +187,6 @@ jobs:
platforms: linux/arm64 platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env: env:
SKIP_ENV_VALIDATION: true SKIP_ENV_VALIDATION: true
extract-asset-amd64: extract-asset-amd64:
-2
View File
@@ -1,5 +1,3 @@
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10) ## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06) ## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
+11 -30
View File
@@ -1,43 +1,24 @@
# syntax=docker/dockerfile:1.25
FROM node:24.18.0-alpine AS base FROM node:24.18.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash && apk update RUN apk add --no-cache libc6-compat curl bash
RUN apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
COPY . . COPY . .
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true' ARG SKIP_ENV_VALIDATION='true'
ARG CI='true' ARG CI='true'
ARG DISABLE_REDIS_LOGS='true' ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ RUN corepack enable pnpm && pnpm build
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
+3
View File
@@ -23,6 +23,9 @@
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn" "eslint/no-unused-expressions": "warn"
}, },
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"], "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": { "settings": {
"react": { "version": "19" } "react": { "version": "19" }
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo"> <img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat) ![Version: 8.21.0](https://img.shields.io/badge/Version-8.21.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat) ![AppVersion: v1.69.2](https://img.shields.io/badge/AppVersion-v1.69.2-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion | | image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry | | imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress | | ingress.enabled | bool | `false` | Enable ingress |
+2 -1
View File
@@ -50,11 +50,12 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
<code>user.read</code>,{" "} <code>user.read</code>,{" "}
<code>user.update</code>,{" "} <code>user.update</code>,{" "}
<code>asset.read</code>,{" "} <code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "} <code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "} <code>asset.statistics</code>,{" "}
<code>server.statistics</code>,{" "} <code>server.statistics</code>,{" "}
<code>server.about</code>. <code>server.about</code>.
<br />
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
</span> </span>
), ),
@@ -1,44 +1,41 @@
--- ---
title: "Media Request List" title: 'Media Request List'
description: "See a list of all media requests from your integration" description: 'See a list of all media requests from your integration'
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react"; import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs"; import Tabs from '@theme/Tabs';
import TabItem from "@theme/TabItem"; import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from "."; import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from '@site/docs/integrations/overseerr';
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
<WidgetHeader
widget={mediaRequestListWidget}
categories={['Media requests']}
/>
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
### Screenshots ### Screenshots
import mediaRequestList from "./img/list.png"; import mediaRequestList from './img/list.png';
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} /> <img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations items={[{
items={[ integration: jellyseerrIntegration,
{ }, {
integration: jellyseerrIntegration, integration: overseerrIntegration,
}, }]} />
{
integration: overseerrIntegration,
},
]}
/>
### Interactions ### Interactions
@@ -50,5 +47,4 @@ import mediaRequestList from "./img/list.png";
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} /> <WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,18 +14,6 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", defaultValue: "yes",
}, },
{
name: "Statuses to show",
description: "Only requests with these statuses will be shown. Defaults to all statuses.",
values: "List of: 'pending', 'approved', 'declined', 'failed', 'completed'",
defaultValue: "All statuses",
},
{
name: "Show only recent requests",
description: "Limit to requests created within this many days. Set to 0 to disable the time filter.",
values: "0-365",
defaultValue: "0",
},
], ],
}, },
}; };
+20 -40
View File
@@ -1,25 +1,29 @@
--- ---
title: "Notebook" title: 'Notebook'
description: "A simple notebook widget that supports markdown." description: 'A simple notebook widget that supports markdown.'
hide_title: true hide_title: true
--- ---
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { notebookWidget } from "."; import { notebookWidget } from '.';
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users. <WidgetHeader
widget={notebookWidget}
categories={['Notes']}
/>
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
It is based on Tiptap.dev and supports most of its features. It is based on Tiptap.dev and supports most of its features.
### Screenshots ### Screenshots
import editOff from "./img/normal.png"; import editOff from './img/normal.png';
import editOn from "./img/edit.png"; import editOn from './img/edit.png';
<div className="flex gap-1"> <div className='flex gap-1'>
<img src={editOff} alt="notebook edit-off" width={128 * 3} /> <img src={editOff} alt="notebook edit-off" width={128 * 3} />
<img src={editOn} alt="notebook edit-on" width={128 * 3} /> <img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div> </div>
@@ -29,27 +33,24 @@ import editOn from "./img/edit.png";
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={notebookWidget.configuration} /> <WidgetConfig configuration={notebookWidget.configuration} />
### Editing ### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right. When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
![](./img/notebook-enter-editing.webp) ![](./img/notebook-enter-editing.webp)
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location. You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
![](./img/notebook-exit-editing.webp) ![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions ### Notebook toolbar functions
The notebook offers a variaty of functions to edit the text and more.
The notebook offers a variety of functions to edit the text and more.
![](./img/notebook-toolbar.webp) ![](./img/notebook-toolbar.webp)
#### Common text formatting #### Common text formatting
| Function | Description | | Function | Description |
| ---------------------- | ------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------- |
| Bold | Makes the selected text bold | | Bold | Makes the selected text bold |
@@ -61,51 +62,35 @@ The notebook offers a variety of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text | | Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading #### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles. Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignment #### Text Alignement
Select the alignement on the line. Left, center or right.
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Misc functions #### Misc functions
##### Blockquote ##### Blockquote
Puts the current line in a quoting block. Puts the current line in a quoting block.
##### Horizontal line ##### Horizontal line
Adds a line that goes from the far right to the far left as a separator. Adds a line that goes from the far right to the far left as a separator.
#### Lists #### Lists
##### Bullet lists ##### Bullet lists
Simple list with a dot preceding each element. Simple list with a dot preceding each element.
##### Ordered lists ##### Ordered lists
List with numbers preceding each element. List with numbers preceding each element.
##### Check lists ##### Check lists
List with a checkbox on each element. List with a checkbox on each element.
##### Indentation changes ##### Indentation changes
![](./img/notebook-toolbar-lists.webp) ![](./img/notebook-toolbar-lists.webp)
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item. When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions #### Insert functions
##### Link controls ##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text. The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too. Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -117,20 +102,16 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab. This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls ##### Image controls
![](./img/notebook-image-modal.webp) ![](./img/notebook-image-modal.webp)
##### Source ##### Source
You can use a normal link to an image or you can also use locally stored images. You can use a normal link to an image or you can also use locally stored images.
##### Width ##### Width
The aspect ratio will always be respected, so only the width field is necessary. The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens. You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table #### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it. This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
![](./img/notebook-table-modal.webp) ![](./img/notebook-table-modal.webp)
@@ -138,7 +119,6 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it. Clicking that button while in a table will delete it.
##### Table controls ##### Table controls
![](./img/notebook-toolbar-table.webp) ![](./img/notebook-toolbar-table.webp)
While in a table, further controls will appear to help edit the table. While in a table, further controls will appear to help edit the table.
@@ -154,4 +134,4 @@ All the following ones are for editing the wize of the table:
- Delete Column - Delete Column
- Add row on top - Add row on top
- Add row below - Add row below
- Delete Row - Delete Row
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7", "random-word-slugs": "^0.1.7",
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"swiper": "^14.0.5", "swiper": "^14.0.1",
"tailwindcss": "^4.3.2" "tailwindcss": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
@@ -2,7 +2,6 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react"; import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -34,7 +33,6 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister"; import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -92,13 +90,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
}, },
}); });
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
if (def.refetchInterval === undefined) continue;
const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
client.setQueryDefaults(key, { refetchInterval: interval });
}
return client; return client;
}); });
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone"; import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react"; import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card"; import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name} {file.name}
</Text> </Text>
<Text visibleFrom="md" c="gray.6" size="sm"> <Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)} {humanFileSize(file.size)}
</Text> </Text>
</Group> </Group>
<Button <Button
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { formatBytes } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local"; import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/> />
</TableTd> </TableTd>
<TableTd>{media.name}</TableTd> <TableTd>{media.name}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd> <TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd> <TableTd>
{media.creator ? ( {media.creator ? (
<Group gap="sm"> <Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common"; import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return ( return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}> <Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)} {humanFileSize(bytesUsage)}
</Text> </Text>
); );
}, },
@@ -193,10 +193,6 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top", positionToolbarAlertBanner: "top",
enableTableFooter: false, enableTableFooter: false,
enableBottomToolbar: false, enableBottomToolbar: false,
// MRT's built-in full-screen mode does not stretch the table to fill the
// viewport, so with few containers it renders as a thin strip (#6230). Disable
// it, matching the other management tables (users, invites).
enableFullScreenToggle: false,
positionGlobalFilter: "right", positionGlobalFilter: "right",
mantineSearchTextInputProps: { mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }), placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,7 +31,6 @@ import {
userChangeHomeBoardsSchema, userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema, userChangeSearchPreferencesSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
} from "@homarr/validation/user"; } from "@homarr/validation/user";
@@ -52,7 +51,6 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled, ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek, firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled, pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
}); });
type FormValues = z.infer<typeof userGeneralSettingsSchema>; type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -80,7 +78,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs, ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek, firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled, pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
}); });
export const UserGeneralSettingsForm = ({ export const UserGeneralSettingsForm = ({
@@ -98,7 +95,6 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation(); const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user); const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues); const initialValuesRef = useRef(initialValues);
@@ -118,7 +114,6 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation, changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation, changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation, changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
]; ];
const isPending = mutations.some((m) => m.isPending); const isPending = mutations.some((m) => m.isPending);
@@ -174,14 +169,6 @@ export const UserGeneralSettingsForm = ({
action: () => action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }), changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
}, },
{
when: changed("enableRightClickOnWidgets"),
action: () =>
changeEnableRightClickOnWidgetsMutation.mutateAsync({
id: user.id,
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
]; ];
const promises = saveActions.filter((s) => s.when).map((s) => s.action()); const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -318,11 +305,6 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")} label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })} {...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/> />
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack> </Stack>
</Card> </Card>
</SimpleGrid> </SimpleGrid>
@@ -1,25 +1,19 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy"; import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => { export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params; const { id } = await props.params;
const imageProxy = new ImageProxy(); const imageProxy = new ImageProxy();
const result = await imageProxy.forwardImageAsync(id); const image = await imageProxy.forwardImageAsync(id);
if (!image) {
if ("error" in result) { notFound();
const status = result.error === "upstream-error" ? result.statusCode : result.error === "fetch-error" ? 502 : 404;
return new Response(null, {
status,
headers: {
"X-Homarr-Image-Proxy-Error": result.error,
},
});
} }
return new Response(result.image, { return new Response(image, {
headers: { headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour "Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
}, },
}); });
}; };
@@ -1,17 +1,9 @@
"use client"; "use client";
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -19,12 +11,10 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets"; import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -48,7 +38,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const tMenu = useScopedI18n("item.menu.label"); const tMenu = useScopedI18n("item.menu.label");
const t = useI18n(); const t = useI18n();
const settings = useSettings(); const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
@@ -169,7 +158,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
if (!session) return <>{children}</>; if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -244,14 +232,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch} onClick={handleRefetch}
disabled={isWidgetFetching} disabled={isWidgetFetching}
> >
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap">
{tMenu("refresh")} {tMenu("refresh")}
<WidgetQueryStatus <Text size="xs" c="dimmed">
queryClient={queryClient} <WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
queryKey={widgetQueryKey} </Text>
isFetching={isWidgetFetching}
t={t}
/>
</Group> </Group>
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
@@ -288,65 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
}; };
interface WidgetQueryStatusProps { const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient; queryClient: QueryClient;
queryKey: QueryKey; queryKey: QueryKey;
isFetching: boolean; isFetching: boolean;
t: TranslationFunction; }) => {
} // 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { if (isFetching) return <Loader size={12} />;
const queries = queryClient.getQueryCache().findAll({ queryKey });
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
if (isFetching) { const timestamps = queryClient
return ( .getQueryCache()
<Group gap={4} wrap="nowrap"> .findAll({ queryKey })
<Loader size={12} /> .map((q) => q.state.dataUpdatedAt)
<Text size="xs" c="dimmed"> .filter(Boolean);
{t("item.menu.status.loading")} if (timestamps.length === 0) return null;
</Text>
</Group>
);
}
if (queries.length === 0) { const latest = Math.max(...timestamps);
return ( const seconds = Math.floor((Date.now() - latest) / 1000);
<Text size="xs" c="dimmed"> if (seconds < 5) return "just now";
{t("item.menu.status.idle")} if (seconds < 60) return `${seconds}s ago`;
</Text> return `${Math.floor(seconds / 60)}m ago`;
);
}
const hasError = queries.some((q) => q.state.status === "error");
if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return (
<Tooltip label={errorMessage} multiline position="left" w={250}>
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
</Text>
</Group>
</Tooltip>
);
}
return (
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
</Text>
</Group>
);
}; };
+2 -2
View File
@@ -6,7 +6,7 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.13.1" "pnpm": ">=11.10.0"
}, },
"packageManager": "pnpm@11.13.1" "packageManager": "pnpm@11.10.0"
} }
+6 -10
View File
@@ -1,15 +1,11 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.71.0", "version": "1.70.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules", "clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean", "clean:workspaces": "turbo clean",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
"ci:act:fast": "bash scripts/ci/act.sh fast",
"cli": "pnpm with-env tsx packages/cli/index.ts", "cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate", "db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run", "db:migration:mysql:run": "pnpm -F db migration:mysql:run",
@@ -37,9 +33,6 @@
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --" "with-env": "dotenv -e .env --"
}, },
"dependencies": {
"jsonpath-plus": "catalog:"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "catalog:", "@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:", "@semantic-release/changelog": "catalog:",
@@ -69,7 +62,10 @@
}, },
"engines": { "engines": {
"node": ">=24.18.0", "node": ">=24.18.0",
"pnpm": ">=11.13.1" "pnpm": ">=11.10.0"
}, },
"packageManager": "pnpm@11.13.1" "packageManager": "pnpm@11.10.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
} }
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, asc, eq, like } from "@homarr/db"; import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema"; import { searchEngines, users } from "@homarr/db/schema";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
@@ -130,15 +130,9 @@ export const searchEngineRouter = createTRPCRouter({
return null; return null;
}), }),
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => { search: protectedProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({ return await ctx.db.query.searchEngines.findMany({
// Public dashboards have no session: restrict anonymous users to generic where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
// (non-integration) engines so custom search engines work there too (#4132),
// while integration-backed engines stay available only when signed in.
where: and(
like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
),
with: { with: {
integration: { integration: {
columns: { columns: {
-77
View File
@@ -357,83 +357,6 @@ describe("delete should delete user", () => {
}); });
}); });
describe("changeEnableRightClickOnWidgets should toggle the right-click preference", () => {
test("non-admin can toggle their own preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await db.insert(users).values({
id: defaultOwnerId,
name: "owner",
});
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: false });
const updated = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(updated?.enableRightClickOnWidgets).toBe(false);
await caller.changeEnableRightClickOnWidgets({ id: defaultOwnerId, enableRightClickOnWidgets: true });
const restored = await db.query.users.findFirst({
where: eq(users.id, defaultOwnerId),
columns: { enableRightClickOnWidgets: true },
});
expect(restored?.enableRightClickOnWidgets).toBe(true);
});
test("non-admin cannot toggle another user's preference", async () => {
const db = createDb();
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const otherUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: otherUserId, name: "other" });
await expect(
caller.changeEnableRightClickOnWidgets({ id: otherUserId, enableRightClickOnWidgets: false }),
).rejects.toThrow("User not found");
const other = await db.query.users.findFirst({
where: eq(users.id, otherUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(other?.enableRightClickOnWidgets).toBe(true);
});
test("admin can toggle another user's preference", async () => {
const db = createDb();
const adminSession = createSession(["admin"]);
const caller = userRouter.createCaller({
db,
deviceType: undefined,
session: adminSession,
});
const targetUserId = createId();
await db.insert(users).values({ id: defaultOwnerId });
await db.insert(users).values({ id: targetUserId, name: "target" });
await caller.changeEnableRightClickOnWidgets({ id: targetUserId, enableRightClickOnWidgets: false });
const target = await db.query.users.findFirst({
where: eq(users.id, targetUserId),
columns: { enableRightClickOnWidgets: true },
});
expect(target?.enableRightClickOnWidgets).toBe(false);
});
});
const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => { const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) => {
await db.insert(onboarding).values({ await db.insert(onboarding).values({
id: createId(), id: createId(),
-29
View File
@@ -23,7 +23,6 @@ import {
userCreateSchema, userCreateSchema,
userDdgBangsSchema, userDdgBangsSchema,
userEditProfileSchema, userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema, userFirstDayOfWeekSchema,
userInitSchema, userInitSchema,
userPingIconsEnabledSchema, userPingIconsEnabledSchema,
@@ -346,7 +345,6 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true, mobileHomeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
@@ -383,7 +381,6 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true, mobileHomeBoardId: true,
firstDayOfWeek: true, firstDayOfWeek: true,
pingIconsEnabled: true, pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true, defaultSearchEngineId: true,
openSearchInNewTab: true, openSearchInNewTab: true,
ddgBangs: true, ddgBangs: true,
@@ -654,32 +651,6 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(users.id, ctx.session.user.id)); .where(eq(users.id, ctx.session.user.id));
}), }),
changeEnableRightClickOnWidgets: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/right-click-widgets",
tags: ["users"],
protect: true,
},
})
.input(convertIntersectionToZodObject(userEnableRightClickOnWidgetsSchema.and(byIdSchema)))
.output(z.void())
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
})
.where(eq(users.id, input.id));
}),
changePingIconsEnabled: protectedProcedure changePingIconsEnabled: protectedProcedure
.input(userPingIconsEnabledSchema.and(byIdSchema)) .input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -3,10 +3,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -19,14 +16,13 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema) .query(async ({ ctx }) => {
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
}); });
return results return results
+4 -7
View File
@@ -3,7 +3,6 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server"; import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db"; import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema"; import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions"; import { releaseProviderKinds } from "@homarr/definitions";
@@ -12,8 +11,6 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access"; import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => { const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined; if (!versionFilter) return undefined;
@@ -74,13 +71,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) { for (const secret of secrets) {
try { try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value)); tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch (error) { } catch {
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error }); // Skip corrupt secrets
} }
} }
} }
} catch (error) { } catch {
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error }); // Access denied or table not yet migrated -- proceed without tokens
} }
} }
+5 -104
View File
@@ -21,17 +21,11 @@ export const randomInt = (min: number, max: number) => {
}; };
/** /**
* Number of bytes to a human-readable string using binary (KiB) suffixes. * Number of bytes to si format. (Division by 1024)
* Does not accept floats; size in bytes should be an integer. * Does not accept floats, size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed. * Will return "NaI" and logs a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned * Concat as parameters so it is not added if the returned value is "NaI" or "∞".
* value is "NaI" or "∞". Returns "∞" if the size is too large to be * Returns "∞" if the size is too large to be represented in the current format.
* represented in the current format.
*
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
* paired "used / total" displays, and `formatByteRate` for byte-per-second
* rates. This function is kept for backwards compatibility but should not
* be used in new code.
*/ */
export const humanFileSize = (size: number, concat = ""): string => { export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB //64bit limit for Number stops at EiB
@@ -54,99 +48,6 @@ export const humanFileSize = (size: number, concat = ""): string => {
return "∞"; return "∞";
}; };
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
export type ByteUnitSystem = "binary" | "decimal";
export interface FormatBytesOptions {
/**
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
*/
unit?: ByteUnitSystem;
}
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
let index = 0;
let value = bytes;
while (value >= base && index < lastIndex) {
value /= base;
index++;
}
return index;
};
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
const resolveUnitConfig = (options: FormatBytesOptions) => {
const { unit = "binary" } = options;
return {
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
base: unit === "binary" ? 1024 : 1000,
};
};
/**
* Format a byte value as a human-readable string with a unit suffix.
*
* Picks the largest unit that keeps the scaled value below the base. Use
* `formatBytesPair` when two related values (e.g. used and available) should
* share a common unit.
*
* @example
* formatBytes(0); // "0.0 B"
* formatBytes(1024); // "1.0 KiB"
* formatBytes(985828802560); // "918.1 GiB"
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
*/
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
const { units, base } = resolveUnitConfig(options);
const safe = sanitizeBytes(bytes);
const index = pickUnitIndex(safe, base, units.length - 1);
const scaled = safe / base ** index;
return `${scaled.toFixed(1)} ${units[index]}`;
};
/**
* Format two related byte values (typically `used` and `available`) with a
* shared unit. The unit is picked from the sum of the two values so the
* combined display stays consistent.
*
* @example
* formatBytesPair(985828802560, 2858736793190);
* // { used: "0.9 TiB", available: "2.6 TiB" }
*/
export const formatBytesPair = (
used: number,
available: number,
options: FormatBytesOptions = {},
): { used: string; available: string } => {
const { units, base } = resolveUnitConfig(options);
const safeUsed = sanitizeBytes(used);
const safeAvailable = sanitizeBytes(available);
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
const suffix = units[index];
return {
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
};
};
/**
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
* string with a `/s` suffix. The value is formatted with the same rules as
* `formatBytes` and a trailing `/s` is appended.
*
* @example
* formatByteRate(0); // "0.0 B/s"
* formatByteRate(1024); // "1.0 KiB/s"
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
*/
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
`${formatBytes(bytes, options)}/s`;
const IMPERIAL_MULTIPLIER = 1.609344; const IMPERIAL_MULTIPLIER = 1.609344;
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER; export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
-90
View File
@@ -1,90 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
describe("formatBytes", () => {
it("returns bytes for small values", () => {
expect(formatBytes(0)).toBe("0.0 B");
expect(formatBytes(1)).toBe("1.0 B");
expect(formatBytes(1023)).toBe("1023.0 B");
});
it("scales binary units correctly", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
});
it("scales decimal units when requested", () => {
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
});
it("formats a typical storage value with one decimal", () => {
expect(formatBytes(985828802560)).toBe("918.1 GiB");
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
});
it("caps at the largest unit to avoid overflow", () => {
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
});
it("returns the zero value for negative or non-finite input", () => {
expect(formatBytes(-1)).toBe("0.0 B");
expect(formatBytes(Number.NaN)).toBe("0.0 B");
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
});
});
describe("formatBytesPair", () => {
it("picks the unit of the larger total and applies it to both values", () => {
const { used, available } = formatBytesPair(985828802560, 2858736793190);
expect(used).toBe("0.9 TiB");
expect(available).toBe("2.6 TiB");
});
it("uses a smaller unit when both values fit in it", () => {
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
expect(used).toBe("100.0 MiB");
expect(available).toBe("200.0 MiB");
});
it("falls back to zero when both inputs are invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, -1);
expect(used).toBe("0.0 B");
expect(available).toBe("0.0 B");
});
it("uses the same unit across both values when one is invalid", () => {
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
expect(used).toBe("0.0 TiB");
expect(available).toBe("1.0 TiB");
});
it("honours the decimal unit option", () => {
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
expect(used).toBe("0.5 TB");
expect(available).toBe("1.5 TB");
});
});
describe("formatByteRate", () => {
it("appends a /s suffix to the formatted value", () => {
expect(formatByteRate(0)).toBe("0.0 B/s");
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
});
it("honours the decimal unit option", () => {
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
});
it("returns zero for invalid input", () => {
expect(formatByteRate(-1)).toBe("0.0 B/s");
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
});
});
@@ -1 +0,0 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -302,13 +302,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0042_add_widget_secret", "tag": "0042_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 43,
"version": "5",
"when": 1783852800000,
"tag": "0043_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -64,13 +64,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0008_add_widget_secret", "tag": "0008_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1783786677702,
"tag": "0009_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
@@ -1 +0,0 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -288,13 +288,6 @@
"when": 1783176100870, "when": 1783176100870,
"tag": "0040_add_widget_secret", "tag": "0040_add_widget_secret",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1783786658943,
"tag": "0041_add_right_click_preference",
"breakpoints": true
} }
] ]
} }
-1
View File
@@ -86,7 +86,6 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(), completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(),
}); });
-1
View File
@@ -85,7 +85,6 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(), colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(), pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(), completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(), completedBoardTour: boolean().default(false).notNull(),
}); });
-1
View File
@@ -68,7 +68,6 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(), colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(), pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(), completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(), completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
}); });
+1 -1
View File
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
connection.end(); connection.end();
await mysqlContainer.stop(); await mysqlContainer.stop();
}, 120_000); }, 40_000);
}); });
+1
View File
@@ -467,6 +467,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"], category: ["archiving"],
documentationUrl: null, documentationUrl: null,
defaultUrl: "http://localhost:8001",
}, },
// 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) // 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: { mock: {
+8 -31
View File
@@ -19,12 +19,6 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`; headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`); }>(`image-proxy:url:${id}`);
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
export class ImageProxy { export class ImageProxy {
private static hmacKey: Buffer<ArrayBuffer> | null = null; private static hmacKey: Buffer<ArrayBuffer> | null = null;
@@ -53,34 +47,17 @@ export class ImageProxy {
return this.createImageUrl(id); return this.createImageUrl(id);
} }
public async forwardImageAsync(id: string): Promise<ForwardImageResult> { public async forwardImageAsync(id: string): Promise<ArrayBuffer | null> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id); const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) { if (!urlAndHeaders) {
return { error: "not-found" }; return null;
} }
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id); const proxyUrl = this.createImageUrl(id);
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
try {
response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
} catch (error) {
logger.error(
new ErrorWithMetadata(
"Failed to connect to image source",
{
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
},
{ cause: error },
),
);
return { error: "fetch-error" };
}
if (!response.ok) { if (!response.ok) {
logger.error( logger.error(
new ErrorWithMetadata("Failed to fetch image", { new ErrorWithMetadata("Failed to fetch image", {
@@ -91,7 +68,7 @@ export class ImageProxy {
statusCode: response.status, statusCode: response.status,
}), }),
); );
return { error: "upstream-error", statusCode: response.status }; return null;
} }
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
@@ -103,7 +80,7 @@ export class ImageProxy {
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`, size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
}); });
return { image: arrayBuffer, contentType: response.headers.get("content-type") }; return arrayBuffer;
} }
private createImageUrl(id: string): string { private createImageUrl(id: string): string {
@@ -1,3 +1,5 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis"; import "@homarr/redis";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -46,7 +48,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({ .map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`, deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
used: `${storageLoad[index]!}`, used: humanFileSize(storageLoad[index]!),
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`, available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0, percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
})), })),
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false }); const torrents = await client.listTorrents({ limit: input.limit });
const rates = torrents.reduce( const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 }, { down: 0, up: 0 },
@@ -117,12 +117,6 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null, metadata: null,
}; };
} }
@@ -1,5 +1,4 @@
import { import {
AssetMediaSize,
getAlbumInfo, getAlbumInfo,
getAllAlbums, getAllAlbums,
getMyUser, getMyUser,
@@ -76,9 +75,7 @@ export class ImmichIntegration extends Integration {
assets: await Promise.all( assets: await Promise.all(
albumAssets.map(async (asset) => { albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync( const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are this.url(`/api/assets/${asset.id}/original`).toString(),
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{ {
"x-api-key": this.getSecretValue("apiKey"), "x-api-key": this.getSecretValue("apiKey"),
}, },
@@ -13,12 +13,6 @@ export interface StreamSession {
episodeName?: string | null; episodeName?: string | null;
albumName?: string | null; albumName?: string | null;
episodeCount?: number | null; episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: { metadata: {
video: { video: {
resolution: { resolution: {
@@ -17,10 +17,6 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types"; import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types"; import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) @HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -54,9 +50,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -64,12 +57,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: { metadata: {
video: { video: {
resolution: resolution:
@@ -13,8 +13,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -29,8 +27,6 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias", episodeName: "Ozymandias",
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -45,8 +41,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -61,8 +55,6 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are", episodeName: "The We We Are",
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -77,8 +69,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: "Random Access Memories", albumName: "Random Access Memories",
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -93,8 +83,6 @@ const sessions: StreamSession[] = [
episodeName: null, episodeName: null,
albumName: null, albumName: null,
episodeCount: null, episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null, metadata: null,
}, },
}, },
@@ -153,9 +153,9 @@ export class OverseerrIntegration
); );
} else if (pendingResults.length > 0) requests = pendingResults; } else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults; else if (allResults.length > 0) requests = allResults;
else return []; else return Promise.all([]);
const settled = await Promise.allSettled( return await Promise.all(
requests.map(async (request): Promise<MediaRequest> => { requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type); const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
@@ -184,10 +184,6 @@ export class OverseerrIntegration
}; };
}), }),
); );
return settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
} }
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -305,10 +301,6 @@ export class OverseerrIntegration
}, },
}); });
if (!response.ok) {
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
if (type === "tv") { if (type === "tv") {
const series = (await response.json()) as TvInformation; const series = (await response.json()) as TvInformation;
return { return {
@@ -3,54 +3,6 @@ interface MediaContainer {
Track?: Session[]; Track?: Session[];
} }
interface Stream {
$: {
streamType: string;
codec?: string;
language?: string;
selected?: string;
width?: string;
height?: string;
frameRate?: string;
channels?: string;
};
}
interface Part {
Stream?: Stream[];
$: {
container?: string;
};
}
interface Media {
Part?: Part[];
$: {
videoResolution?: string;
videoCodec?: string;
audioCodec?: string;
bitrate?: string;
container?: string;
};
}
interface TranscodeSession {
$: {
throttled?: string;
progress?: string;
speed?: string;
videoDecision?: string;
audioDecision?: string;
sourceVideoCodec?: string;
sourceAudioCodec?: string;
videoCodec?: string;
audioCodec?: string;
container?: string;
width?: string;
height?: string;
};
}
interface Session { interface Session {
User?: { User?: {
$: { $: {
@@ -63,20 +15,13 @@ interface Session {
$: { $: {
product: string; product: string;
title: string; title: string;
address?: string;
state?: string;
local?: string;
}; };
}[]; }[];
Session?: { Session?: {
$: { $: {
id: string; id: string;
bandwidth?: string;
location?: string;
}; };
}[]; }[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: { $: {
grandparentTitle?: string; grandparentTitle?: string;
parentTitle?: string; parentTitle?: string;
@@ -84,8 +29,6 @@ interface Session {
index?: number; index?: number;
type: string; type: string;
live?: string; live?: string;
viewOffset?: string;
duration?: string;
}; };
} }
@@ -17,32 +17,6 @@ import type { PlexResponse } from "./interface";
const logger = createLogger({ module: "plexIntegration" }); const logger = createLogger({ module: "plexIntegration" });
function parseOptionalNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
function parseLocation(value: string | undefined): "lan" | "wan" | null {
return value === "lan" || value === "wan" ? value : null;
}
function parsePlaybackState(value: string | undefined): "playing" | "paused" | "buffering" | null {
return value === "playing" || value === "paused" || value === "buffering" ? value : null;
}
function parseResolution(
width: string | undefined,
height: string | undefined,
): { width: number; height: number } | null {
const parsedWidth = parseOptionalNumber(width);
const parsedHeight = parseOptionalNumber(height);
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> { public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey"); const token = super.getSecretValue("apiKey");
@@ -69,24 +43,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const userElement = mediaElement.User ? mediaElement.User[0] : undefined; const userElement = mediaElement.User ? mediaElement.User[0] : undefined;
const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined; const playerElement = mediaElement.Player ? mediaElement.Player[0] : undefined;
const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined; const sessionElement = mediaElement.Session ? mediaElement.Session[0] : undefined;
const transcodeElement = mediaElement.TranscodeSession ? mediaElement.TranscodeSession[0] : undefined;
const mediaInfoElement = mediaElement.Media ? mediaElement.Media[0] : undefined;
if (!playerElement) { if (!playerElement) {
return undefined; return undefined;
} }
const positionMs = parseOptionalNumber(mediaElement.$.viewOffset);
const durationMs = parseOptionalNumber(mediaElement.$.duration);
const playbackState = parsePlaybackState(playerElement.$.state);
const location = parseLocation(sessionElement?.$.location);
const streams = mediaInfoElement?.Part?.[0]?.Stream ?? [];
const videoStream = streams.find((stream) => stream.$.streamType === "1");
const audioStream = streams.find((stream) => stream.$.streamType === "2");
return { return {
sessionId: sessionElement?.$.id ?? "unknown", sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`, sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -102,30 +63,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null, episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null, episodeCount: mediaElement.$.index ?? null,
playback: { metadata: null,
state: playbackState,
positionMs,
durationMs,
},
location,
metadata: {
video: {
resolution: parseResolution(videoStream?.$.width, videoStream?.$.height),
frameRate: parseOptionalNumber(videoStream?.$.frameRate),
},
audio: {
channelCount: parseOptionalNumber(audioStream?.$.channels),
codec: audioStream?.$.codec ?? mediaInfoElement?.$.audioCodec ?? null,
},
transcoding: {
container: transcodeElement?.$.container ?? null,
resolution: parseResolution(transcodeElement?.$.width, transcodeElement?.$.height),
target: {
audioCodec: transcodeElement?.$.audioCodec ?? null,
videoCodec: transcodeElement?.$.videoCodec ?? null,
},
},
},
}, },
}; };
}) })
@@ -1,6 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch"; import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -58,7 +59,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597 cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({ fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name, deviceName: disk.name,
used: `${disk.fsUsed * 1024}`, // API is in KiB (kibibytes), convert to bytes used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes) available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})), })),
@@ -23,8 +23,6 @@ type OptionMapping = {
const optionMapping: OptionMapping = { const optionMapping: OptionMapping = {
"mediaRequests-requestList": { "mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
}, },
"mediaRequests-requestStats": {}, "mediaRequests-requestStats": {},
bookmarks: { bookmarks: {
+17 -4
View File
@@ -300,7 +300,10 @@ async function getContainersWithStatsAsync() {
const stats = await instance const stats = await instance
.getContainer(container.Id) .getContainer(container.Id)
.stats({ stream: false, "one-shot": true }) // Not in one-shot mode: the daemon then includes precpu_stats (the
// previous reading), which is required to compute the current CPU usage
// as a delta. One-shot omits it and would only expose lifetime totals.
.stats({ stream: false })
.catch( .catch(
() => () =>
({ ({
@@ -336,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
} }
const numberOfCpus = stats.cpu_stats.online_cpus; const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) { // Docker's own formula for the current (instantaneous) CPU usage: it compares
// the latest reading against the previous one (precpu_stats). Dividing the
// cumulative total_usage by the cumulative system_cpu_usage instead would
// report the container's lifetime average, which stays high long after a load
// spike has ended. precpu_stats is only present when the stats are NOT fetched
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
// cumulative totals, matching the previous behaviour.
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
if (systemDelta <= 0 || cpuDelta < 0) {
return 0; return 0;
} }
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100; return (cpuDelta / systemDelta) * numberOfCpus * 100;
} }
export function calculateMemoryUsage(stats: ContainerStats): number { export function calculateMemoryUsage(stats: ContainerStats): number {
@@ -13,7 +13,6 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> { interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>; requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number; cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
} }
export const createIntegrationRequestHandler = < export const createIntegrationRequestHandler = <
@@ -33,7 +32,6 @@ export const createIntegrationRequestHandler = <
return options.requestAsync(integration, input.options); return options.requestAsync(integration, input.options);
}, },
cacheTtlMs: options.cacheTtlMs, cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
}); });
return { return {
@@ -1,7 +1,6 @@
interface Options<TData, TInput extends Record<string, unknown>> { interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>; requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number; cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
} }
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number }; type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
@@ -36,6 +35,7 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (cached && Date.now() < cached.expiresAt) { if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp }; return { data: cached.data, timestamp: cached.timestamp };
} }
if (cached) cache.delete(key);
const existing = inflight.get(key); const existing = inflight.get(key);
if (existing) return existing; if (existing) return existing;
@@ -55,9 +55,6 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
}) })
.catch((err) => { .catch((err) => {
inflight.delete(key); inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
}
throw err; throw err;
}); });
@@ -1,44 +1,16 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types"; import type { MediaRequest } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const mediaRequestStatusValues = [
"pending",
"approved",
"declined",
"failed",
"completed",
] as const satisfies readonly MediaRequestStatus[];
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const mediaRequestListInputSchema = z.object({
statuses: z.array(z.enum(mediaRequestStatusValues)).nonempty(),
recentDays: z.number().min(0).max(365),
});
export type MediaRequestListInput = z.infer<typeof mediaRequestListInputSchema>;
export const mediaRequestListRequestHandler = createIntegrationRequestHandler< export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[], MediaRequest[],
IntegrationKindByCategory<"mediaRequest">, IntegrationKindByCategory<"mediaRequest">,
MediaRequestListInput Record<string, never>
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
const requests = await integrationInstance.getRequestsAsync(); return await integrationInstance.getRequestsAsync();
const cutoff = input.recentDays > 0 ? Date.now() - input.recentDays * MS_PER_DAY : null;
const statusSet = new Set<MediaRequestStatus>(input.statuses);
return requests.filter((request) => {
if (!statusSet.has(request.status)) return false;
if (cutoff !== null && request.createdAt.getTime() < cutoff) return false;
return true;
});
}, },
cacheTtlMs: 60_000,
fallbackToStaleOnError: true,
}); });
@@ -134,33 +134,7 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl, baseUrl,
auth: token, auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync }, request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { throttle: { enabled: true },
enabled: true,
onRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
onSecondaryRateLimit: (
retryAfter: number,
options: { url?: string; method?: string },
_octokit: unknown,
retryCount: number,
) => {
logger.warn(`GitHub secondary rate limit exceeded, retrying after ${retryAfter}s (attempt ${retryCount + 1})`, {
url: options.url,
method: options.method,
});
return retryCount < 1;
},
},
userAgent, userAgent,
}); });
@@ -745,19 +719,6 @@ const linuxServerIOReleasesSchema = z.object({
}), }),
}); });
// Parse a github.com project URL (as returned by the LSIO registry) into an
// "owner/repo" identifier, or null if it is not a github.com repository URL.
const parseGithubRepo = (url: string): string | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [owner, repo] = parsed.pathname.replace(/^\/+/, "").split("/");
return owner && repo ? `${owner}/${repo}` : null;
} catch {
return null;
}
};
const getLinuxServerIOReleaseAsync = async ( const getLinuxServerIOReleaseAsync = async (
baseUrl: string, baseUrl: string,
identifier: string, identifier: string,
@@ -787,29 +748,6 @@ const getLinuxServerIOReleaseAsync = async (
error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` }, error: { code: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
}; };
// LSIO's /api/v1/images only exposes the single latest version, so a version
// filter can never surface an older release (#4351). When the image has a GitHub
// repository, delegate to the GitHub release provider to fetch the real release
// history and apply the filter, while keeping LSIO's own image metadata. Any
// GitHub failure (no repo, no releases, rate limit) falls back to the LSIO latest.
const githubRepo = parseGithubRepo(release.github_url);
if (githubRepo) {
const githubResult = await getGithubReleaseAsync(getReleaseProviderDefaultUrl("github"), githubRepo, versionRegex);
if (githubResult.success) {
return {
success: true,
data: {
...githubResult.data,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
starsCount: release.stars,
createdAt: release.initial_date ?? githubResult.data.createdAt,
},
};
}
}
const regex = compileVersionRegex(versionRegex); const regex = compileVersionRegex(versionRegex);
if (versionRegex && !regex) { if (versionRegex && !regex) {
return { return {
@@ -75,6 +75,26 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1 // (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1); expect(calculateCpuUsage(stats)).toBe(1);
}); });
test("should use the delta against precpu_stats for the current usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// Despite a large lifetime total_usage, only the recent delta counts:
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// No CPU delta since the last reading -> current usage is 0, even though the
// lifetime average would be high. This is the regression the fix addresses.
expect(calculateCpuUsage(stats)).toBe(0);
});
}); });
describe("calculateMemoryUsage", () => { describe("calculateMemoryUsage", () => {
-3
View File
@@ -10,7 +10,6 @@ export type SettingsContextProps = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "ddgBangs" | "ddgBangs"
| "pingIconsEnabled" | "pingIconsEnabled"
| "enableRightClickOnWidgets"
> & > &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> & Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">; Pick<ServerSettings["user"], "enableGravatar">;
@@ -33,7 +32,6 @@ export type UserSettings = Pick<
| "openSearchInNewTab" | "openSearchInNewTab"
| "ddgBangs" | "ddgBangs"
| "pingIconsEnabled" | "pingIconsEnabled"
| "enableRightClickOnWidgets"
>; >;
export const createSettings = ({ export const createSettings = ({
@@ -50,7 +48,6 @@ export const createSettings = ({
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId, homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false, pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault, enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus, forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar, enableGravatar: serverSettings.user.enableGravatar,
@@ -56,13 +56,6 @@ export const userPreferenceDefinitions = [
mutationGroup: "pingIconsEnabled", mutationGroup: "pingIconsEnabled",
aliases: ["ping", "icon", "status"], aliases: ["ping", "icon", "status"],
}, },
{
key: "enableRightClickOnWidgets",
kind: "boolean",
guest: false,
mutationGroup: "enableRightClickOnWidgets",
aliases: ["right click", "context menu", "tile", "widget menu"],
},
{ {
key: "fullPreferencesPage", key: "fullPreferencesPage",
kind: "link", kind: "link",
@@ -3,7 +3,6 @@
import { import {
IconActivity, IconActivity,
IconCalendarWeek, IconCalendarWeek,
IconClick,
IconDeviceDesktop, IconDeviceDesktop,
IconExternalLink, IconExternalLink,
IconHome, IconHome,
@@ -47,7 +46,6 @@ export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
homeBoardId: IconHome, homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard, mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity, pingIconsEnabled: IconActivity,
enableRightClickOnWidgets: IconClick,
fullPreferencesPage: IconSettings, fullPreferencesPage: IconSettings,
}; };
@@ -93,7 +93,6 @@ export const useUserPreferences = () => {
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh }); const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh }); const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh }); const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const rightClickMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown => const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps]; key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
@@ -117,8 +116,6 @@ export const useUserPreferences = () => {
}), }),
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }), firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }), pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
enableRightClickOnWidgets: (uid, _key, value) =>
rightClickMutation.mutateAsync({ id: uid, enableRightClickOnWidgets: value as boolean }),
}; };
const groupPending: Record<string, boolean> = { const groupPending: Record<string, boolean> = {
@@ -126,7 +123,6 @@ export const useUserPreferences = () => {
homeBoards: homeBoardsMutation.isPending, homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending, firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending, pingIconsEnabled: pingMutation.isPending,
enableRightClickOnWidgets: rightClickMutation.isPending,
}; };
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => { const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
+1 -7
View File
@@ -1598,16 +1598,10 @@
}, },
"menu": { "menu": {
"label": { "label": {
"settings": "", "settings": "Settings",
"refresh": "", "refresh": "",
"options": "", "options": "",
"actions": "" "actions": ""
},
"status": {
"success": "",
"error": "",
"loading": "",
"idle": ""
} }
}, },
"create": { "create": {
-34
View File
@@ -198,10 +198,6 @@
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Use icons for pings" "label": "Use icons for pings"
}, },
"enableRightClickOnWidgets": {
"label": "Enable right-click on widgets",
"description": "Show Homarr's context menu when right-clicking a widget. Disable to use the browser's default menu (e.g. \"Open link in new tab\")."
},
"defaultSearchEngine": { "defaultSearchEngine": {
"label": "Default search engine" "label": "Default search engine"
}, },
@@ -1626,12 +1622,6 @@
"refresh": "Refresh", "refresh": "Refresh",
"options": "Options", "options": "Options",
"actions": "Actions" "actions": "Actions"
},
"status": {
"success": "OK",
"error": "Error",
"loading": "Loading",
"idle": "Idle"
} }
}, },
"create": { "create": {
@@ -1966,8 +1956,6 @@
"clear": "Clear formatting", "clear": "Clear formatting",
"heading": "Heading {level}", "heading": "Heading {level}",
"align": "Align text: {position}", "align": "Align text: {position}",
"directionLtr": "Set text direction left to right",
"directionRtl": "Set text direction right to left",
"blockquote": "Blockquote", "blockquote": "Blockquote",
"horizontalLine": "Horizontal line", "horizontalLine": "Horizontal line",
"bulletList": "Bullet list", "bulletList": "Bullet list",
@@ -2721,11 +2709,6 @@
"user": "User", "user": "User",
"name": "Name", "name": "Name",
"id": "Id", "id": "Id",
"status": "Status",
"directPlay": "Direct play",
"transcoding": "Transcoding",
"paused": "Paused",
"remaining": "{minutes} min left",
"metadata": { "metadata": {
"title": "Stats for nerds", "title": "Stats for nerds",
"video": { "video": {
@@ -3210,12 +3193,6 @@
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
"label": "Open links in new tab" "label": "Open links in new tab"
},
"statusFilter": {
"label": "Statuses to show"
},
"recentDays": {
"label": "Show only recent requests"
} }
}, },
"pending": { "pending": {
@@ -5226,9 +5203,6 @@
"title": "Containers", "title": "Containers",
"table": { "table": {
"updated": "Updated {when}", "updated": "Updated {when}",
"refresh": {
"lastUpdated": "Last refresh {when}"
},
"search": "Seach {count} containers", "search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected", "selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers", "footer": "Total {count} containers",
@@ -5957,14 +5931,6 @@
} }
} }
}, },
"enableRightClickOnWidgets": {
"label": "Enable right-click on widgets",
"children": {
"detail": {
"title": "Enable right-click on widgets"
}
}
},
"fullPreferencesPage": { "fullPreferencesPage": {
"label": "Open full preferences page" "label": "Open full preferences page"
} }
-4
View File
@@ -130,10 +130,6 @@ export const userPingIconsEnabledSchema = z.object({
pingIconsEnabled: z.boolean(), pingIconsEnabled: z.boolean(),
}); });
export const userEnableRightClickOnWidgetsSchema = z.object({
enableRightClickOnWidgets: z.boolean(),
});
export const userDdgBangsSchema = z.object({ export const userDdgBangsSchema = z.object({
ddgBangs: z.boolean(), ddgBangs: z.boolean(),
}); });
@@ -3,7 +3,7 @@
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions"; import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -84,4 +84,4 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
</Stack> </Stack>
); );
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0)); const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
+56 -13
View File
@@ -1,4 +1,6 @@
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core"; import { useCallback, useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types"; import type { CalendarEvent } from "@homarr/integrations/types";
@@ -11,9 +13,14 @@ interface CalendarDayProps {
disabled: boolean; disabled: boolean;
rootWidth: number; rootWidth: number;
rootHeight: number; rootHeight: number;
onOpen: (close: () => void) => void;
} }
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => { export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard(); const board = useRequiredBoard();
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius]; const actualItemRadius = mantineTheme.radius[board.itemRadius];
@@ -22,19 +29,39 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const shouldScaleDown = minAxisSize < 350; const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256; const isSmall = rootHeight < 256;
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return ( return (
<HoverCard <Popover
position="bottom" position="bottom"
withArrow withArrow
withinPortal withinPortal
radius="lg" radius="lg"
shadow="sm" shadow="sm"
transitionProps={{ transition: "pop" }} transitionProps={{
openDelay={350} transition: "pop",
closeDelay={400} }}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
opened={opened}
disabled={disabled} disabled={disabled}
> >
<HoverCard.Target> <Popover.Target>
<Container <Container
h="100%" h="100%"
w="100%" w="100%"
@@ -42,23 +69,38 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
pt={isSmall ? 0 : 10} pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10} pb={isSmall ? 0 : 10}
m={0} m={0}
pos="relative" ref={ref}
bd={`2px solid ${opened && !disabled ? primaryColor : "transparent"}`}
style={{ style={{
alignContent: "center", alignContent: "center",
borderRadius: actualItemRadius, borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer", cursor: disabled ? "default" : "pointer",
}} }}
onClick={() => {
if (!disabled) setPinned((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}> <Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()} {date.getDate()}
</Text> </Text>
<NotificationIndicator events={events} isSmall={isSmall} /> {!isTooSmallForIndicators && <NotificationIndicator events={events} isSmall={isSmall} />}
</Container> </Container>
</HoverCard.Target> </Popover.Target>
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}> {/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<CalendarEventList events={events} /> <CalendarEventList events={events} />
</HoverCard.Dropdown> </Popover.Dropdown>
</HoverCard> </Popover>
); );
}; };
@@ -69,6 +111,7 @@ interface NotificationIndicatorProps {
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => { const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String); const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
/* position bottom is lower when small to not be on top of number*/
return ( return (
<Flex <Flex
w="75%" w="75%"
+7 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core"; import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates"; import { Calendar } from "@mantine/dates";
@@ -65,6 +65,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256; const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]); const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return ( return (
<Calendar <Calendar
@@ -141,6 +146,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
disabled={isEditMode || eventsForDate.length === 0} disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width} rootWidth={width}
rootHeight={height} rootHeight={height}
onOpen={onDayOpen}
/> />
); );
}} }}
-1
View File
@@ -70,7 +70,6 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
export interface WidgetDefinition { export interface WidgetDefinition {
icon: TablerIcon; icon: TablerIcon;
queryKey?: QueryKey; queryKey?: QueryKey;
refetchInterval?: number | null;
supportedIntegrations?: IntegrationKind[]; supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean; integrationsRequired?: boolean;
maxIntegrations?: number; maxIntegrations?: number;
@@ -9,8 +9,6 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({ showToggleAllButtons: factory.switch({
@@ -9,8 +9,6 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({ usePiHoleColors: factory.switch({
+10 -17
View File
@@ -3,13 +3,13 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react"; import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise } from "@tabler/icons-react"; import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { formatBytes, useTimeAgo } from "@homarr/common"; import { humanFileSize, useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -110,7 +110,7 @@ const createColumns = (
return ( return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}> <Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)} {humanFileSize(bytesUsage)}
</Text> </Text>
); );
}, },
@@ -184,7 +184,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
const t = useScopedI18n("docker"); const t = useScopedI18n("docker");
const isTiny = width <= 256; const isTiny = width <= 256;
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery(); const { data } = clientApi.docker.getContainers.useQuery(undefined, {
refetchInterval: 30_000,
});
const containers = data?.containers ?? []; const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp); const relativeTime = useTimeAgo(timestamp);
@@ -295,21 +297,12 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
</Text> </Text>
<Text size="sm" style={{ whiteSpace: "nowrap" }}> <Text size="sm" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })} {t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
</Text> </Text>
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}> <Text size="sm" style={{ whiteSpace: "nowrap" }}>
<ActionIcon {t("table.updated", { when: relativeTime })}
size="sm" </Text>
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching}
onClick={() => void refetch()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
>
<IconRefresh style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group> </Group>
</Group> </Group>
)} )}
-1
View File
@@ -25,7 +25,6 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker, icon: IconBrandDocker,
queryKey: [["docker", "getContainers"]],
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
columns: factory.multiSelect({ columns: factory.multiSelect({
+19 -16
View File
@@ -43,7 +43,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common"; import { humanFileSize, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -386,7 +386,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last", sortUndefined: "last",
Cell: ({ cell }) => { Cell: ({ cell }) => {
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>(); const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
return downSpeed ? <Text size="xs">{formatByteRate(downSpeed)}</Text> : null; return downSpeed ? <Text size="xs">{humanFileSize(downSpeed, "/s")}</Text> : null;
}, },
}, },
{ {
@@ -466,7 +466,7 @@ export default function DownloadClientsWidget({
...columnsDefBase({ key: "received", showHeader: true }), ...columnsDefBase({ key: "received", showHeader: true }),
Cell: ({ cell }) => { Cell: ({ cell }) => {
const received = cell.getValue<ExtendedDownloadClientItem["received"]>(); const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
return <Text size="xs">{formatBytes(received)}</Text>; return <Text size="xs">{humanFileSize(received)}</Text>;
}, },
}, },
{ {
@@ -474,14 +474,14 @@ export default function DownloadClientsWidget({
sortUndefined: "last", sortUndefined: "last",
Cell: ({ cell }) => { Cell: ({ cell }) => {
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>(); const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
return sent && <Text size="xs">{formatBytes(sent)}</Text>; return sent && <Text size="xs">{humanFileSize(sent)}</Text>;
}, },
}, },
{ {
...columnsDefBase({ key: "size", showHeader: true }), ...columnsDefBase({ key: "size", showHeader: true }),
Cell: ({ cell }) => { Cell: ({ cell }) => {
const size = cell.getValue<ExtendedDownloadClientItem["size"]>(); const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
return <Text size="xs">{formatBytes(size)}</Text>; return <Text size="xs">{humanFileSize(size)}</Text>;
}, },
}, },
{ {
@@ -521,7 +521,7 @@ export default function DownloadClientsWidget({
sortUndefined: "last", sortUndefined: "last",
Cell: ({ cell }) => { Cell: ({ cell }) => {
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>(); const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
return upSpeed && <Text size="xs">{formatByteRate(upSpeed)}</Text>; return upSpeed && <Text size="xs">{humanFileSize(upSpeed, "/s")}</Text>;
}, },
}, },
], ],
@@ -669,21 +669,21 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
{item.type !== "miscellaneous" && ( {item.type !== "miscellaneous" && (
<NormalizedLine <NormalizedLine
itemKey="upSpeed" itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : formatByteRate(item.upSpeed)} values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
/> />
)} )}
<NormalizedLine <NormalizedLine
itemKey="downSpeed" itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : formatByteRate(item.downSpeed)} values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
/> />
{item.type !== "miscellaneous" && ( {item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : formatBytes(item.sent)} /> <NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
)} )}
<NormalizedLine itemKey="received" values={formatBytes(item.received)} /> <NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
<NormalizedLine itemKey="size" values={formatBytes(item.size)} /> <NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
<NormalizedLine <NormalizedLine
itemKey="progress" itemKey="progress"
values={new Intl.NumberFormat("en", { values={new Intl.NumberFormat("en", {
@@ -744,7 +744,10 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{ paused: [] as string[], active: [] as string[] }, { paused: [] as string[], active: [] as string[] },
); );
const someInteract = clients.some(({ interact }) => interact); const someInteract = clients.some(({ interact }) => interact);
const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0)); const totalSpeed = humanFileSize(
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
"/s",
);
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
@@ -846,21 +849,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli
{client.status.rates.up !== undefined ? ( {client.status.rates.up !== undefined ? (
<Group display="flex" justify="center" c="green" w="100%" gap={5}> <Group display="flex" justify="center" c="green" w="100%" gap={5}>
<Text flex={1} ta="right"> <Text flex={1} ta="right">
{`↑ ${formatByteRate(client.status.rates.up)}`} {`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
</Text> </Text>
<Text>{"-"}</Text> <Text>{"-"}</Text>
<Text flex={1} ta="left"> <Text flex={1} ta="left">
{formatBytes(client.status.totalUp ?? 0)} {humanFileSize(client.status.totalUp ?? 0)}
</Text> </Text>
</Group> </Group>
) : undefined} ) : undefined}
<Group display="flex" justify="center" c="blue" w="100%" gap={5}> <Group display="flex" justify="center" c="blue" w="100%" gap={5}>
<Text flex={1} ta="right"> <Text flex={1} ta="right">
{`↓ ${formatByteRate(client.status.rates.down)}`} {`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
</Text> </Text>
<Text>{"-"}</Text> <Text>{"-"}</Text>
<Text flex={1} ta="left"> <Text flex={1} ta="left">
{formatBytes(Math.floor(client.status.totalDown ?? 0))} {humanFileSize(Math.floor(client.status.totalDown ?? 0))}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
-1
View File
@@ -33,7 +33,6 @@ const columnsSort = columnsList.filter((column) =>
export const { definition, componentLoader } = createWidgetDefinition("downloads", { export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload, icon: IconDownload,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
-1
View File
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", { export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall, icon: IconWall,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from(() => ({})); return optionsBuilder.from(() => ({}));
}, },
@@ -16,7 +16,7 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
import { capitalize, formatBytes, formatBytesPair } from "@homarr/common"; import { capitalize, humanFileSize } from "@homarr/common";
import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types"; import type { ComputeResource, Resource, StorageResource } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -93,18 +93,16 @@ const RightSection = ({ label, value }: RightSectionProps) => {
const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => { const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail"); const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const memory = formatBytesPair(item.memory.used, item.memory.total);
const storage = formatBytesPair(item.storage.used, item.storage.total);
return ( return (
<List> <List>
<List.Item icon={<IconCpu size={16} />}> <List.Item icon={<IconCpu size={16} />}>
{t("cpu")} - {item.cpu.cores} {t("cpu")} - {item.cpu.cores}
</List.Item> </List.Item>
<List.Item icon={<IconBrain size={16} />}> <List.Item icon={<IconBrain size={16} />}>
{t("memory")} - {memory.used} / {memory.available} {t("memory")} - {humanFileSize(item.memory.used)} / {humanFileSize(item.memory.total)}
</List.Item> </List.Item>
<List.Item icon={<IconDatabase size={16} />}> <List.Item icon={<IconDatabase size={16} />}>
{t("storage")} - {storage.used} / {storage.available} {t("storage")} - {humanFileSize(item.storage.used)} / {humanFileSize(item.storage.total)}
</List.Item> </List.Item>
<List.Item icon={<IconClockHour3 size={16} />}> <List.Item icon={<IconClockHour3 size={16} />}>
{t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)} {t("uptime")} - {dayjs(dayjs().add(-item.uptime, "seconds")).fromNow(true)}
@@ -123,7 +121,6 @@ const ComputeResourceDetails = ({ item }: { item: ComputeResource }) => {
const StorageResourceDetails = ({ item }: { item: StorageResource }) => { const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail"); const t = useScopedI18n("widget.healthMonitoring.cluster.popover.detail");
const storagePercent = item.total ? (item.used / item.total) * 100 : 0; const storagePercent = item.total ? (item.used / item.total) * 100 : 0;
const storage = formatBytesPair(item.used, item.total);
return ( return (
<Stack gap={0}> <Stack gap={0}>
<Center> <Center>
@@ -136,7 +133,7 @@ const StorageResourceDetails = ({ item }: { item: StorageResource }) => {
/> />
<Group align="center" gap={0}> <Group align="center" gap={0}>
<Text> <Text>
{t("storage")} - {storage.used} / {storage.available} {t("storage")} - {humanFileSize(item.used)} / {humanFileSize(item.total)}
</Text> </Text>
</Group> </Group>
</Center> </Center>
@@ -155,11 +152,11 @@ const DiskStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconDatabase size={16} />}> <List.Item icon={<IconDatabase size={16} />}>
<Group gap="sm"> <Group gap="sm">
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.storage.write)}</Text> <Text>{humanFileSize(item.storage.write)}</Text>
<IconArrowNarrowDown size={14} /> <IconArrowNarrowDown size={14} />
</Group> </Group>
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.storage.read)}</Text> <Text>{humanFileSize(item.storage.read)}</Text>
<IconArrowNarrowUp size={14} /> <IconArrowNarrowUp size={14} />
</Group> </Group>
</Group> </Group>
@@ -175,11 +172,11 @@ const NetStats = ({ item }: { item: ComputeResource }) => {
<List.Item icon={<IconNetwork size={16} />}> <List.Item icon={<IconNetwork size={16} />}>
<Group gap="sm"> <Group gap="sm">
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.network.in)}</Text> <Text>{humanFileSize(item.network.in)}</Text>
<IconArrowNarrowDown size={14} /> <IconArrowNarrowDown size={14} />
</Group> </Group>
<Group gap={0}> <Group gap={0}>
<Text>{formatBytes(item.network.out)}</Text> <Text>{humanFileSize(item.network.out)}</Text>
<IconArrowNarrowUp size={14} /> <IconArrowNarrowUp size={14} />
</Group> </Group>
</Group> </Group>
@@ -8,7 +8,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from(
(factory) => ({ (factory) => ({
@@ -32,7 +32,7 @@ import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -277,7 +277,7 @@ export const progressColor = (percentage: number) => {
// them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched. // them (e.g. Unraid, dashdot). Format the former and pass the latter through untouched.
const formatFileSize = (value: string) => { const formatFileSize = (value: string) => {
const bytes = Number(value); const bytes = Number(value);
return Number.isFinite(bytes) ? formatBytes(Math.round(bytes)) : value; return Number.isFinite(bytes) ? humanFileSize(Math.round(bytes)) : value;
}; };
interface FileSystem { interface FileSystem {
@@ -8,8 +8,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", { export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto, icon: IconPhoto,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"], supportedIntegrations: ["immich"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -5,7 +5,7 @@ import { Group, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react"; import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { formatBytes } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
@@ -46,7 +46,7 @@ export default function ImmichServerStatsWidget({
<StatItem <StatItem
icon={<IconDatabase size={20} />} icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")} label={t("widget.immich-serverStats.storage")}
value={formatBytes(stats.totalLibraryUsageInBytes)} value={humanFileSize(stats.totalLibraryUsageInBytes)}
/> />
)} )}
</Stack> </Stack>
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", { export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled, icon: IconGraphFilled,
queryKey: [["widget", "immich"]],
refetchInterval: null,
supportedIntegrations: ["immich"], supportedIntegrations: ["immich"],
integrationsRequired: true, integrationsRequired: true,
createOptions() { createOptions() {
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch, icon: IconReportSearch,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({ openIndexerSiteInNewTab: factory.switch({
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", { export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket, icon: IconTicket,
queryKey: [["widget", "mediaRelease"]],
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
layout: factory.select({ layout: factory.select({
@@ -38,11 +38,6 @@ export default function MediaServerWidget({
}: WidgetComponentProps<"mediaRequests-requestList">) { }: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds, integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
}); });
if (!mediaRequests) return <WidgetEmptyState />; if (!mediaRequests) return <WidgetEmptyState />;
@@ -1,5 +1,4 @@
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight"; import { openMediaRequestSearch } from "@homarr/spotlight";
@@ -7,8 +6,6 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion, icon: IconZoomQuestion,
createOptions() { createOptions() {
@@ -16,17 +13,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
linksTargetNewTab: factory.switch({ linksTargetNewTab: factory.switch({
defaultValue: true, defaultValue: true,
}), }),
statusFilter: factory.multiSelect({
defaultValue: [...mediaRequestStatusValues],
options: mediaRequestStatusValues.map((value) => ({
value,
label: (t) => t(`widget.mediaRequests-requestList.status.${value}`),
})),
}),
recentDays: factory.number({
validate: z.number().min(0).max(365),
defaultValue: 0,
}),
})); }));
}, },
contextActions: ({ integrationIds }) => [ contextActions: ({ integrationIds }) => [
+26 -106
View File
@@ -2,16 +2,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; import { Avatar, Divider, Flex, Group, Stack, Text, Title } from "@mantine/core";
import { import { IconDeviceTv, IconHeadphones, IconMovie, IconVideo } from "@tabler/icons-react";
IconDeviceTv,
IconHeadphones,
IconMovie,
IconPlayerPause,
IconVideo,
IconWifi,
IconWorld,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
@@ -36,25 +28,23 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>( const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
() => [ () => [
{ {
accessorKey: "user.username", accessorKey: "sessionName",
header: t("items.user"), header: t("items.name"),
size: 160,
Cell: ({ row }) => ( Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap"> <Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<Avatar size={28} src={row.original.user.profilePictureUrl} /> {row.original.sessionName}
<Stack gap={0} style={{ minWidth: 0 }}> </Text>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> ),
{row.original.user.username} },
</Text> {
<Text accessorKey: "user.username",
size="10px" header: t("items.user"),
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} Cell: ({ row }) => (
> <Group gap="xs">
{row.original.sessionName} <Avatar size={20} src={row.original.user.profilePictureUrl} />
</Text> <Text size="xs">{row.original.user.username}</Text>
</Stack>
</Group> </Group>
), ),
}, },
@@ -63,83 +53,17 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
header: t("items.currentlyPlaying"), header: t("items.currentlyPlaying"),
Cell: ({ row }) => { Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying; if (!row.original.currentlyPlaying) return null;
if (!currentlyPlaying) return null;
const isPaused = currentlyPlaying.playback.state === "paused"; const Icon = mediaTypeIconMap[row.original.currentlyPlaying.type];
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const { positionMs, durationMs } = currentlyPlaying.playback;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
return ( return (
<Stack gap={4} style={{ minWidth: 0 }}> <Group gap="xs" align="center">
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}> <Icon size={16} />
<Icon <Text size="xs" lineClamp={1}>
size={16} {row.original.currentlyPlaying.name}
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} </Text>
style={{ flexShrink: 0 }} </Group>
/>
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused && (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
)}
{!isPaused && remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)}
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "rgba(255, 255, 255, 0.15)" }}
/>
)}
</Stack>
);
},
},
{
id: "status",
header: t("items.status"),
size: 110,
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container,
);
return (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={isTranscoding ? "orange" : "green"}>
{isTranscoding ? t("items.transcoding") : t("items.directPlay")}
</Badge>
{currentlyPlaying.location && (
<Group gap={4} align="center">
{currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />}
<Text size="10px" c="dimmed" tt="uppercase">
{currentlyPlaying.location}
</Text>
</Group>
)}
</Stack>
); );
}, },
}, },
@@ -183,7 +107,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
initialState: { initialState: {
density: "xs", density: "xs",
columnPinning: { columnPinning: {
right: ["currentlyPlaying", "status"], right: ["currentlyPlaying"],
}, },
}, },
mantineTableHeadProps: { mantineTableHeadProps: {
@@ -220,10 +144,6 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
); );
}, },
py: 4, py: 4,
style: {
overflowX: "hidden",
overflowY: "visible",
},
}), }),
}); });
@@ -7,7 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo, icon: IconVideo,
refetchInterval: 5,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }), showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
@@ -11,7 +11,6 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform, icon: IconTransform,
refetchInterval: null,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
defaultView: factory.select({ defaultView: factory.select({
@@ -14,7 +14,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { TdarrQueue } from "@homarr/integrations"; import type { TdarrQueue } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -72,7 +72,7 @@ export function QueuePanel(props: QueuePanelProps) {
</Group> </Group>
</TableTd> </TableTd>
<TableTd py={2}> <TableTd py={2}>
<Text size="xs">{formatBytes(item.fileSize)}</Text> <Text size="xs">{humanFileSize(item.fileSize)}</Text>
</TableTd> </TableTd>
</TableTr> </TableTr>
))} ))}
@@ -3,7 +3,7 @@ import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tool
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations"; import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
@@ -41,7 +41,7 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
<StatisticItem <StatisticItem
icon={IconDatabaseHeart} icon={IconDatabaseHeart}
label={t("savedSpace")} label={t("savedSpace")}
value={formatBytes(Math.floor(allLibs.totalSavedSpace))} value={humanFileSize(Math.floor(allLibs.totalSavedSpace))}
/> />
</Group> </Group>
<Group justify="center" wrap="wrap" grow> <Group justify="center" wrap="wrap" grow>
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more