Compare commits

..
Author SHA1 Message Date
Thomas Camlong 68ce7e73c0 fix(open-webui): use current request-handler API to fix the build
The request handlers were written against an older caching API that dev has
since refactored ('TanStack Query as single source of truth'), breaking the
Turbopack build with a module-not-found on ./lib/cached-integration-request-handler.

- use createIntegrationRequestHandler from ./lib/integration-request-handler
- replace cacheDuration(dayjs)/queryKey with cacheTtlMs (ms)
- update the router consumers: getCachedOrUpdatedDataAsync -> getDataAsync and
  per-call invalidateAsync() -> handler-level invalidateCache()
2026-07-11 19:33:42 +02:00
ajnart 87f1f329da style: auto-fix lint and format 2026-07-11 17:12:44 +00:00
Thomas Camlong 9301e224ec feat(open-webui): tag chats per user, harden error/voice states, add tests
- persistence: tag created/updated Open WebUI chats with a homarr-user:<id> tag
  (via chat meta.tags) so conversations can be found back per Homarr user even
  when an integration/account is shared
- voice recording: distinguish a denied/dismissed microphone permission from a
  generic mic failure and show an actionable message
- widget: surface the models query loading and connection-error states in the
  chat empty state instead of silently showing 'no models'
- tests: cover the final-SSE-frame flush, the default request timeout (and the
  streaming opt-out), and multimodal chat parsing round-trip
2026-07-11 19:11:13 +02:00
Thomas Camlong e648abf78e feat(open-webui): header entry, side panel, keybind and spotlight chat
Replace the bottom-right floating chat button with app-wide launchers, as
requested in review:

- side panel: a right-side Drawer (OpenWebUiChat) reusing the chat widget,
  mounted app-wide and shown only to authed users who own an Open WebUI
  integration
- header entry: an 'AI Chat' item in the user-avatar menu (like the Docker item)
- keybind: mod+shift+a toggles the panel (hotkeys.toggleAiChat)
- spotlight: a '?' 'Ask AI' mode that starts a conversation from the typed query
  and opens the panel pre-filled; also surfaced in the Cmd+K help/mode list when
  the integration is present
- the panel and spotlight are bridged to the app via an openWebUiChatEvent
  window event (mirrors the existing media-request search event)

The Chat widget gains an optional initialQuery that auto-sends once on open.
2026-07-11 19:11:12 +02:00
Thomas Camlong 3d2a2d7b55 fix(open-webui): drop MCP exposure and address remaining review comments
Per ajnart's review (and CodeRabbit withdrawing its own MCP comment on the
predecessor PR), the Open WebUI chat isn't a meaningful MCP surface:
- remove the .meta({ mcp }) metadata from every openWebUi procedure

Remaining CodeRabbit comments from the predecessor PR that weren't carried over:
- processWeb: constrain the URL to http(s) and reject loopback/metadata hosts
  (private LAN ranges stay allowed for self-hosters); it is also creator-gated
- uploadFile/transcribe: bound the base64 payload and validate filename/type
- docs: align the example URL with the integration default port (8080)
- new chat: clear the composer draft so it doesn't leak into the fresh chat
- web ingestion: keep the typed URL when ingestion fails
- persistence: fall back to the default title for image-only opening turns, and
  don't persist a partial reply from a failed turn as finished history
- multimodal round-trip: make the native chat/history contracts accept the same
  text-or-parts content union as completions, and flatten (not drop) non-text
  nodes when rebuilding transcripts so attachment-backed chats aren't lost.
  Image data URLs are still kept out of persisted history by design.
2026-07-11 19:11:12 +02:00
ajnart c076a36fec style: auto-fix lint and format 2026-07-11 16:29:31 +00:00
Thomas Camlong 951e707438 fix: address CodeRabbit review comments
API / router (packages/api):
- open-webui: expose serializable procedures as MCP tools via .meta({ mcp })
  and gate knowledge/files/notes reads behind protectedProcedure + interact so
  anonymous board viewers can't enumerate private source content
- integration create: wrap integration + creator-permission + secret writes in
  a single cross-driver transaction so a partial failure can't leave an
  unusable integration
- integration test: scope the permission lookup to the created integration

Integration (packages/integrations):
- open-webui: log only safe metadata (not raw completion chunks) on parse
  failure, flush the final buffered SSE frame before treating the stream as
  truncated, and apply a default request timeout (streaming opts out)

Widget (packages/widgets):
- collapse knowledge rows by default to avoid an N+1 getKnowledgeFiles burst
- strip markdown images from assistant output to block untrusted fetches
- reconcile the selected model when the model list changes
- clear draft attachments when switching to a saved chat
- dedupe async note/chat attaches after the fetch resolves
- discard stale persist responses via a session token so a cleared chat can't
  be rebound to an old Open WebUI chat id
- release the mic stream on mid-start failures and mark the recording cancelled
  on unmount so partial audio isn't transcribed
2026-07-11 18:28:04 +02:00
Thomas Camlong e1ba193ea3 db: regenerate integration.creator_id migrations for all dialects
The PR only shipped a SQLite creator_id migration; MySQL and PostgreSQL were
missing (CodeRabbit) and the SQLite one lacked ON DELETE SET NULL. Regenerate
on top of dev's widget_secret base:

- sqlite 0041: ADD creator_id ... REFERENCES user(id) ON DELETE SET NULL
- mysql 0043: ADD creator_id + FK ON DELETE set null (hand-written snapshot
  from dev's 0042 to avoid touching the pre-existing custom_widget_secret FK
  that must stay under MySQL's 64-char identifier limit)
- postgresql 0009: ADD creator_id + FK ON DELETE set null

Schema already declares onDelete: set null on integration.creatorId.
2026-07-11 18:19:08 +02:00
Thomas Camlong 411c809c55 Merge branch 'dev' into feat/openwebui-integration
Resolve conflicts by keeping both openWebUi and dev-side registry entries
(integration/widget kinds, creators, docs sitemap). Take dev's next.config
turbopack settings. Reset SQLite migration base to dev's 0040_add_widget_secret;
the integration.creator_id migrations are regenerated for all dialects in a
follow-up commit.
2026-07-11 18:11:03 +02:00
Lok3sh-kumar 1b3eb1ccc8 style: auto-fix lint and format 2026-07-01 15:27:12 +00:00
Lok3sh-Kumar 6f6a989ba6 openwebui: enable user scoped integrations to avoid cross user data leakage 2026-07-01 20:55:58 +05:30
Lok3sh-Kumar 3bb6037d35 db: fix sqlite migrations 2026-07-01 20:55:58 +05:30
Lok3sh-kumar c5e31513e2 style: auto-fix lint and format 2026-06-27 09:25:33 +00:00
Lok3sh-Kumar dae1cae894 Update docs sitemap 2026-06-27 14:53:20 +05:30
Lok3sh-Kumar 41b75ef5a0 feat(open-webui): searchable model picker in floating header
Replace the inline Select with a Combobox-based ModelSelect that filters
models via an in-dropdown search field, and move the model picker, new
chat and history controls into a floating header above the messages.
Assistant avatar now uses the Open WebUI brand logo.
2026-06-27 14:18:15 +05:30
Lok3sh-Kumar 8e0f8cd46a refactor(open-webui): split chat widget into hooks and components
Break the monolithic chat.tsx into focused hooks (use-attachments,
use-chat-stream, use-voice-recording, use-camera-capture) and
presentational components (chat-components), with shared types and
utils extracted to chat-types/chat-utils.

Review fixes applied on top:
- require auth for the streaming completion (protectedProcedure) so
  anonymous board viewers cannot spend the owner's LLM credits
- rename image-only state to images and the picker query options object
  to pickerQueryOptions for clarity
- extract AttachPopover, clearPending(), composerEmpty and
  releaseAudioResources() to remove duplication
2026-06-27 14:17:59 +05:30
Lok3sh-Kumar 84275bd19a fix(open-webui): show knowledge-base documents by default
Expand each knowledge base's file list by default in the picker (with a
"no files" hint when empty) so a base's documents are visible without an
extra click and can be selected individually.
2026-06-27 14:17:27 +05:30
Lok3sh-Kumar b232cc5bb0 feat(open-webui): redesign voice recording with live waveform
Move the mic to the right of the input (shown when the box is empty).
While recording, the input becomes a recording bar with a cancel button,
a live microphone waveform (Web Audio analyser), an elapsed timer and a
confirm button that stops and transcribes into the message box.
2026-06-27 14:17:10 +05:30
Lok3sh-Kumar 18afb866b7 fix(open-webui): list knowledge-base files for the sub-document picker
The knowledge detail endpoint returns `files: null` on recent Open WebUI
versions, so the expandable picker showed nothing to select. List the
files endpoint and filter by the base's collection id instead; each file
grounds via its own `file-{id}` collection.
2026-06-27 14:16:57 +05:30
Lok3sh-Kumar 46454cc0f0 feat(open-webui): voice messages, fix camera capture and full notes
- Voice messages: record from the microphone and transcribe to the input
  via Open WebUI's /api/v1/audio/transcriptions (mic button in composer).
- Camera capture: attach the stream via a ref callback so the preview is
  no longer a black frame (the ref could be read before the video mounted).
- Notes: fetch the full note body on attach. The list endpoint truncates
  content to 1000 chars, so attached notes were cut off; getNote returns
  the complete markdown.
2026-06-27 14:16:42 +05:30
Lok3sh-Kumar d74728c28d feat(open-webui): animated typing indicator while awaiting reply
Replace the static "…" placeholder with a three-dot wave animation shown
in the assistant bubble until the first token streams in. Falls back to a
fade for users who prefer reduced motion.
2026-06-27 14:16:29 +05:30
Lok3sh-Kumar 78bd66e803 feat(open-webui): full attachment menu with all source types
Replace the single attach popover with an Open WebUI-style multi-level
menu covering every attachment source:

- Upload Files: images stay inline (vision); other files are uploaded and
  grounded via retrieval (`file-{id}` collection).
- Capture: take a photo from the webcam as an inline image.
- Attach Webpage: ingest a URL (existing).
- Attach Files: pick files already stored in Open WebUI.
- Attach Notes: inject the contents of a note as context.
- Attach Knowledge: nested picker with checkboxes, expandable to a base's
  files (replaces the flat checkbox list).
- Reference Chats: bring a previous conversation in as context.

Knowledge bases, web pages and files are grounded by retrieval; notes and
referenced chats are injected verbatim as context blocks. Adds the
supporting integration methods (list files/notes, knowledge files, upload
file), request handlers and tRPC procedures.
2026-06-27 14:16:13 +05:30
Lok3sh-Kumar 800caf6e5e fix(open-webui): tidy model selector label and width
Trim the ":latest" tag from model labels, widen the selector and use
ellipsis truncation so long model names no longer clip mid-word.
2026-06-27 14:15:50 +05:30
Lok3sh-Kumar bc26231d13 fix(open-webui): always show Knowledge section in attach menu
The Knowledge picker was hidden entirely when the account had no knowledge
bases, making the feature look missing. Always render the section with an
empty-state hint so it stays discoverable.
2026-06-27 14:15:31 +05:30
Lok3sh-Kumar f61e7539d7 feat(open-webui): add image, knowledge and webpage attachments with retry
Extend the AI Chat widget with three ways to ground a conversation:

- Images: attach images to a message, sent inline as OpenAI multimodal
  content parts for vision-capable models. A hint warns when the selected
  model likely has no vision support.
- Knowledge bases: pick RAG collections exposed by /api/v1/knowledge/.
- Web pages: ingest a URL via /api/v1/retrieval/process/web.

Open WebUI does not run RAG from the chat-completions `files` parameter,
so attached collections (web pages and knowledge bases) are grounded by
retrieving the most relevant chunks at send time
(/api/v1/retrieval/query/collection) and injecting them as system context.

Also add a Retry action to re-run the last turn after a model failure, and
handle the paginated `{ items, total }` shape of the knowledge endpoint on
newer Open WebUI versions.
2026-06-27 14:15:08 +05:30
Lok3sh-Kumar 005c2e4cd1 feat: add Open WebUI AI chat integration and widget
Adds a new "openWebUi" integration kind (under a new "ai" category) and an
AI Chat widget for chatting with models served by Open WebUI.

- Integration: Bearer API key auth, model list, native chat history CRUD, and
  token-by-token streaming chat completions (SSE).
- Widget: in-input model picker, streaming responses, and an in-widget history
  panel. Reconstructs conversations from Open WebUI's message tree with a flat
  fallback.
- Streaming is delivered over the WebSocket subscription link.
- Adds integration and widget documentation.
2026-06-27 14:12:48 +05:30
187 changed files with 7123 additions and 3726 deletions

No files matched your search

-7
View File
@@ -6,14 +6,7 @@ npm-debug.log
README.md
.next
.turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git
.github
dev
.build
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/)
- [ ] 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/).
- [ ] 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
labels: ${{ steps.meta.outputs.labels }}
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:
SKIP_ENV_VALIDATION: true
build-arm64:
@@ -192,11 +187,6 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
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:
SKIP_ENV_VALIDATION: true
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.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 base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash && 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
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
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 CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--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
RUN corepack enable pnpm && pnpm build
FROM base AS runner
WORKDIR /app
+3
View File
@@ -23,6 +23,9 @@
"react-hooks/exhaustive-deps": "warn",
"eslint/no-unused-expressions": "warn"
},
"options": {
"reportUnusedDisableDirectives": "off"
},
"ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", ".docusaurus/**", "build/**"],
"settings": {
"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">
![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)
![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
@@ -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 |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| 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 |
| ingress.annotations | object | `{}` | Ingress annotations |
| 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.update</code>,{" "}
<code>asset.read</code>,{" "}
<code>asset.view</code>,{" "}
<code>asset.download</code>,{" "}
<code>asset.statistics</code>,{" "}
<code>server.statistics</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>
),
@@ -0,0 +1,39 @@
---
title: "Open WebUI"
description: "Self-hosted AI chat interface for local and remote LLM providers"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { openWebUiIntegration } from ".";
import { openWebUiWidget } from "../../widgets/open-webui";
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["apiKey"],
steps: [],
},
]}
/>
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const openWebUiIntegration: IntegrationDefinition = {
name: "Open WebUI",
description: "Self-hosted AI chat interface for local and remote LLM providers",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
path: "../../integrations/open-webui",
};
@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
description: 'Shows a slideshow of your album pictures from Immich'
hide_title: true
---
@@ -37,3 +37,4 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,16 +4,15 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of pictures from an Immich album or your full library",
description: "Shows a slideshow of your album pictures from Immich",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
description: "Select the Immich album to show in the slideshow.",
values: { type: "string" },
defaultValue: "All photos",
defaultValue: "",
},
{
name: "Interval in seconds",
@@ -1,44 +1,41 @@
---
title: "Media Request List"
description: "See a list of all media requests from your integration"
title: 'Media Request List'
description: 'See a list of all media requests from your integration'
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import { mediaRequestListWidget } from '.';
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
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.
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
import mediaRequestList from "./img/list.png";
import mediaRequestList from './img/list.png';
<img src={mediaRequestList} alt="media-request-list" width={256} height={256} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: jellyseerrIntegration,
},
{
integration: overseerrIntegration,
},
]}
/>
<WidgetIntegrations items={[{
integration: jellyseerrIntegration,
}, {
integration: overseerrIntegration,
}]} />
### Interactions
@@ -50,5 +47,4 @@ import mediaRequestList from "./img/list.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaRequestListWidget.configuration} />
@@ -14,18 +14,6 @@ export const mediaRequestListWidget: WidgetDefinition = {
values: { type: "boolean" },
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"
description: "A simple notebook widget that supports markdown."
title: 'Notebook'
description: 'A simple notebook widget that supports markdown.'
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { notebookWidget } from ".";
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { 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.
### Screenshots
import editOff from "./img/normal.png";
import editOn from "./img/edit.png";
import editOff from './img/normal.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={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
@@ -29,27 +33,24 @@ import editOn from "./img/edit.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={notebookWidget.configuration} />
### 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.
![](./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.
![](./img/notebook-exit-editing.webp)
### Notebook toolbar functions
The notebook offers a variety of functions to edit the text and more.
The notebook offers a variaty of functions to edit the text and more.
![](./img/notebook-toolbar.webp)
#### Common text formatting
| Function | Description |
| ---------------------- | ------------------------------------------------------------------- |
| 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 |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignment
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.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes
![](./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.
#### Insert functions
##### Link controls
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.
@@ -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.
##### Image controls
![](./img/notebook-image-modal.webp)
##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
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.
#### Table
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)
@@ -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.
##### Table controls
![](./img/notebook-toolbar-table.webp)
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
- Add row on top
- Add row below
- Delete Row
- Delete Row
@@ -0,0 +1,56 @@
---
title: "AI Chat"
description: "Chat with AI models through Open WebUI, with model picking and history."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { openWebUiWidget } from ".";
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: openWebUiIntegration,
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={openWebUiWidget.configuration} />
### Features
| Feature | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
| **Streaming responses** | Assistant replies stream in live as they are generated. |
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
| **Notes** | Attach the contents of your Open WebUI notes. |
| **Reference chats** | Bring a previous conversation in as context for the current one. |
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
### Notes
- The widget uses a single Open WebUI integration.
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
@@ -0,0 +1,25 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRobot } from "@tabler/icons-react";
export const openWebUiWidget: WidgetDefinition = {
icon: IconRobot,
name: "AI Chat",
description: "Chat with AI models through Open WebUI, with model picking and history.",
path: "../../widgets/open-webui",
configuration: {
items: [
{
name: "System prompt",
description: "Optional instruction prepended to every conversation.",
values: { type: "string" },
defaultValue: "empty",
},
{
name: "Show chat history",
description: "Show a button to browse and reopen previous Open WebUI chats.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+1 -1
View File
@@ -43,7 +43,7 @@
"random-word-slugs": "^0.1.7",
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.5",
"swiper": "^14.0.1",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
@@ -0,0 +1,11 @@
"use client";
import dynamic from "next/dynamic";
const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), {
ssr: false,
});
export const OpenWebUiChatProvider = () => {
return <OpenWebUiChat />;
};
@@ -1,29 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const createQueryCacheErrorCleanup = (queryClient: QueryClient): (() => void) => {
const wasSuccessful = new Set<string>();
return queryClient.getQueryCache().subscribe((event) => {
const query = event.query;
const key = query.queryHash;
if (event.type === "removed") {
wasSuccessful.delete(key);
return;
}
if (event.type !== "updated") return;
if (query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey)) {
wasSuccessful.add(key);
return;
}
if (query.state.status === "error" && wasSuccessful.has(key)) {
queryClient.resetQueries({ queryKey: query.queryKey, exact: true });
wasSuccessful.delete(key);
}
});
};
@@ -1,8 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useState } from "react";
import type { QueryKey } from "@tanstack/react-query";
import { useState } from "react";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -34,9 +33,7 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets";
import { createQueryCacheErrorCleanup } from "./query-cache-error-cleanup";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => {
@@ -93,20 +90,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
},
});
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;
});
useEffect(() => {
return createQueryCacheErrorCleanup(queryClient);
}, [queryClient]);
const [trpcClient] = useState(() => {
return clientApi.createClient({
links: [
@@ -167,9 +153,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
},
}}
onSuccess={() => {
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
}}
>
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)}
{humanFileSize(file.size)}
</Text>
</Group>
<Button
+2
View File
@@ -32,6 +32,7 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
import { JotaiProvider } from "./_client-providers/jotai";
import { CustomMantineProvider } from "./_client-providers/mantine";
import { AuthProvider } from "./_client-providers/session";
@@ -147,6 +148,7 @@ export default async function Layout(props: {
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
<OpenWebUiChatProvider />
{props.children}
</StackedProvider>
</body>
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { formatBytes } from "@homarr/common";
import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
/>
</TableTd>
<TableTd>{media.name}</TableTd>
<TableTd>{formatBytes(media.size)}</TableTd>
<TableTd>{humanFileSize(media.size)}</TableTd>
<TableTd>
{media.creator ? (
<Group gap="sm">
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
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 { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{formatBytes(bytesUsage)}
{humanFileSize(bytesUsage)}
</Text>
);
},
@@ -193,10 +193,6 @@ export function DockerTable({ initialData }: DockerTableProps) {
positionToolbarAlertBanner: "top",
enableTableFooter: 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",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(containers.length) }),
@@ -31,7 +31,6 @@ import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
@@ -52,7 +51,6 @@ const userGeneralSettingsSchema = z.object({
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -80,7 +78,6 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
});
export const UserGeneralSettingsForm = ({
@@ -98,7 +95,6 @@ export const UserGeneralSettingsForm = ({
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
@@ -118,7 +114,6 @@ export const UserGeneralSettingsForm = ({
changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
];
const isPending = mutations.some((m) => m.isPending);
@@ -174,14 +169,6 @@ export const UserGeneralSettingsForm = ({
action: () =>
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());
@@ -318,11 +305,6 @@ export const UserGeneralSettingsForm = ({
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={t("user.field.enableRightClickOnWidgets.label")}
description={t("user.field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
@@ -1,25 +1,19 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params;
const imageProxy = new ImageProxy();
const result = await imageProxy.forwardImageAsync(id);
if ("error" in result) {
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,
},
});
const image = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound();
}
return new Response(result.image, {
return new Response(image, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
},
});
};
@@ -19,7 +19,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
@@ -113,7 +112,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
{({ reset }) => (
<ErrorBoundary
onReset={() => {
removePersistedWidgetQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
reset();
}}
fallbackRender={({ resetErrorBoundary, error }) => (
@@ -1,17 +1,9 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient, QueryKey } 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 { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -48,7 +38,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const isRightClickEnabled = settings.enableRightClickOnWidgets;
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
@@ -169,7 +158,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -244,14 +232,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group justify="space-between" wrap="nowrap">
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
isFetching={isWidgetFetching}
t={t}
/>
<Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
</Text>
</Group>
</Menu.Item>
<Menu.Item
@@ -288,65 +273,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
);
};
interface WidgetQueryStatusProps {
const WidgetCacheAge = ({
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: QueryKey;
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) => {
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) return <Loader size={12} />;
if (isFetching) {
return (
<Group gap={4} wrap="nowrap">
<Loader size={12} />
<Text size="xs" c="dimmed">
{t("item.menu.status.loading")}
</Text>
</Group>
);
}
const timestamps = queryClient
.getQueryCache()
.findAll({ queryKey })
.map((q) => q.state.dataUpdatedAt)
.filter(Boolean);
if (timestamps.length === 0) return null;
if (queries.length === 0) {
return (
<Text size="xs" c="dimmed">
{t("item.menu.status.idle")}
</Text>
);
}
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>
);
const latest = Math.max(...timestamps);
const seconds = Math.floor((Date.now() - latest) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
return `${Math.floor(seconds / 60)}m ago`;
};
@@ -1,157 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
import { createQueryCacheErrorCleanup } from "~/app/[locale]/_client-providers/query-cache-error-cleanup";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
describe("removePersistedWidgetQueries", () => {
test("removes stale widget queries and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
test("removes persisted queries outside the widget router", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(dockerQueryKey, "stale");
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
test("removes persisted widget queries when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
});
describe("query cache error cleanup", () => {
test("resets data when a persistable query transitions from success to error", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const userKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(widgetKey, { old: true });
queryClient.setQueryData(userKey, { user: true });
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
expect(queryClient.getQueryData(userKey)).toEqual({ user: true });
unsubscribe();
});
test("preserves stale data for non-persistable query errors", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const boardKey = [["board", "getBoardByName"], { input: {} }];
queryClient.setQueryData(boardKey, { boards: [] });
await queryClient
.fetchQuery({ queryKey: boardKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(boardKey)).toEqual({ boards: [] });
unsubscribe();
});
test("handles success→error→success→error cycling without getting stuck", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
queryClient.setQueryData(widgetKey, "first-success");
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("second-success") });
expect(queryClient.getQueryData(widgetKey)).toBe("second-success");
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("third-success") });
expect(queryClient.getQueryData(widgetKey)).toBe("third-success");
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom-2")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
unsubscribe();
});
test("unsubscribe stops processing further events", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
queryClient.setQueryData(widgetKey, "stale");
unsubscribe();
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBe("stale");
});
});
describe("rehydration marks persistable data as stale", () => {
test("rehydrated queries are invalidated so they refetch on mount", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0 } },
});
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const userKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(widgetKey, { rehydrated: true });
queryClient.setQueryData(userKey, { rehydrated: true });
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
expect(queryClient.getQueryState(widgetKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(userKey)?.isInvalidated).toBe(false);
});
});
@@ -1,7 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
};
@@ -0,0 +1,108 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Drawer } from "@mantine/core";
import { useHotkeys, useMediaQuery } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { openWebUiChatEvent } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Chat } from "@homarr/widgets/_open-webui/chat";
/**
* App-wide launcher for the Open WebUI chat. Renders a right-side panel that can
* be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind,
* or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window
* event). Renders nothing unless the signed-in user owns an Open WebUI
* integration.
*/
export const OpenWebUiChat = () => {
const t = useI18n();
const { data: session } = useSession();
const isMobile = useMediaQuery("(max-width: 767px)");
const [opened, setOpened] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
// Bumped on every open so the chat remounts and a fresh initial query is sent.
const [openNonce, setOpenNonce] = useState(0);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const myOpenWebUiIntegration = useMemo(() => {
if (!session?.user?.id) return null;
return integrations.find(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id,
);
}, [integrations, session?.user?.id]);
const open = useCallback((query?: string) => {
setInitialQuery(query?.trim() ? query.trim() : undefined);
setOpenNonce((nonce) => nonce + 1);
setOpened(true);
}, []);
const toggle = useCallback(() => {
setOpened((previous) => {
if (previous) return false;
setInitialQuery(undefined);
setOpenNonce((nonce) => nonce + 1);
return true;
});
}, []);
// Open (with an optional query) when the spotlight "ask AI" mode dispatches.
useEffect(() => {
const handler = (event: Event) => {
if (!myOpenWebUiIntegration) return;
open((event as CustomEvent<{ query?: string }>).detail?.query);
};
window.addEventListener(openWebUiChatEvent, handler);
return () => window.removeEventListener(openWebUiChatEvent, handler);
}, [open, myOpenWebUiIntegration]);
useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]);
if (!myOpenWebUiIntegration) return null;
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size={isMobile ? "100%" : 480}
title={t("widget.openWebUi.name")}
keepMounted={false}
styles={{
body: {
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
padding: 0,
},
}}
>
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<Chat
key={openNonce}
options={{ systemPrompt: "", showHistory: true }}
integrationIds={[myOpenWebUiIntegration.id]}
itemId={undefined}
boardId={undefined}
isEditMode={false}
setOptions={() => {
// No-op: options are fixed for the launcher chat.
}}
width={480}
height={600}
initialQuery={initialQuery}
/>
</Box>
</Drawer>
);
};
@@ -11,14 +11,17 @@ import {
IconHome,
IconLogin,
IconLogout,
IconRobot,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
import { openOpenWebUiChat } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -46,6 +49,15 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session.data?.user?.id),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
});
const hasOpenWebUiIntegration = integrations.some(
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
);
const handleSignout = useCallback(async () => {
await signOut({
redirect: false,
@@ -96,6 +108,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
{t("docker")}
</Menu.Item>
)}
{hasOpenWebUiIntegration && (
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
{t("aiChat")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"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",
"version": "1.71.0",
"version": "1.70.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
"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",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
@@ -37,9 +33,6 @@
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
"dependencies": {
"jsonpath-plus": "catalog:"
},
"devDependencies": {
"@playwright/test": "catalog:",
"@semantic-release/changelog": "catalog:",
@@ -69,7 +62,10 @@
},
"engines": {
"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:"
}
}
@@ -99,6 +99,7 @@ export const integrationRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
url: integration.url,
creatorId: integration.creatorId,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
@@ -344,23 +345,66 @@ export const integrationRouter = createTRPCRouter({
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
const userId = ctx.session.user.id;
const secretValues = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
// Create the integration, its creator permission and secrets atomically so
// a failure part-way through can't leave a committed integration the
// creator can neither use nor manage.
await handleTransactionsAsync(ctx.db, {
handleAsync: async (db, schema) => {
await db.transaction(async (transaction) => {
await transaction.insert(schema.integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
});
// Auto-grant the creator "full" permission so they can immediately
// use and manage the integration they just created.
await transaction.insert(schema.integrationUserPermissions).values({
integrationId,
userId,
permission: "full",
});
if (secretValues.length >= 1) {
await transaction.insert(schema.integrationSecrets).values(secretValues);
}
});
},
handleSync: (db) => {
db.transaction((transaction) => {
transaction
.insert(integrations)
.values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
creatorId: userId,
})
.run();
transaction
.insert(integrationUserPermissions)
.values({
integrationId,
userId,
permission: "full",
})
.run();
if (secretValues.length >= 1) {
transaction.insert(integrationSecrets).values(secretValues).run();
}
});
},
});
logger.info("Created integration", {
id: integrationId,
@@ -3,7 +3,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
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 { searchEngines, users } from "@homarr/db/schema";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
@@ -130,15 +130,9 @@ export const searchEngineRouter = createTRPCRouter({
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({
// Public dashboards have no session: restrict anonymous users to generic
// (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"),
),
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),
with: {
integration: {
columns: {
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -301,6 +302,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -317,10 +322,23 @@ describe("create should create a new integration", () => {
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbPermission = await db.query.integrationUserPermissions.findFirst({
where: and(
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
eq(integrationUserPermissions.userId, defaultUserId),
),
});
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
// Creator should be auto-granted "full" permission
expect(dbPermission).toBeDefined();
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
expect(dbPermission!.userId).toBe(defaultUserId);
expect(dbPermission!.permission).toBe("full");
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -336,6 +354,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
@@ -357,6 +379,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
@@ -374,6 +397,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -405,6 +432,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbIntegration!.app!.name).toBe(input.app.name);
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
expect(dbIntegration!.app!.href).toBe(input.app.href);
@@ -431,6 +459,10 @@ describe("create should create a new integration", () => {
deviceType: undefined,
session: defaultSessionWithPermissions(["integration-create"]),
});
// The creator must exist in the DB for the creatorId foreign key
await db.insert(users).values({ id: defaultUserId });
const input = {
name: "Jellyfin",
kind: "jellyfin" as const,
@@ -455,6 +487,7 @@ describe("create should create a new integration", () => {
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbIntegration!.creatorId).toBe(defaultUserId);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
-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) => {
await db.insert(onboarding).values({
id: createId(),
-29
View File
@@ -23,7 +23,6 @@ import {
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userEnableRightClickOnWidgetsSchema,
userFirstDayOfWeekSchema,
userInitSchema,
userPingIconsEnabledSchema,
@@ -346,7 +345,6 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -383,7 +381,6 @@ export const userRouter = createTRPCRouter({
mobileHomeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -654,32 +651,6 @@ export const userRouter = createTRPCRouter({
})
.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
.input(userPingIconsEnabledSchema.and(byIdSchema))
.mutation(async ({ input, ctx }) => {
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string().optional(),
albumId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
+1
View File
@@ -43,5 +43,6 @@ export const widgetRouter = createTRPCRouter({
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -3,10 +3,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
mediaRequestListInputSchema,
mediaRequestListRequestHandler,
} from "@homarr/request-handler/media-request-list";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -19,14 +16,13 @@ export const mediaRequestsRouter = createTRPCRouter({
mcp: {
enabled: true,
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")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
.query(async ({ ctx }) => {
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 results
@@ -0,0 +1,379 @@
import { observable } from "@trpc/server/observable";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import {
openWebUiChatRequestHandler,
openWebUiChatsRequestHandler,
openWebUiFilesRequestHandler,
openWebUiKnowledgeFilesRequestHandler,
openWebUiKnowledgeRequestHandler,
openWebUiModelsRequestHandler,
openWebUiNoteRequestHandler,
openWebUiNotesRequestHandler,
} from "@homarr/request-handler/open-webui";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "openWebUiRouter" });
/**
* Open WebUI integrations hold a personal API key that grants access to a
* single user's data (chat history, files, notes, knowledge bases). To prevent
* other Homarr users from reading that data, every procedure that touches the
* integration verifies that the caller is the integration's creator.
*
* The check is intentionally placed **after** the integration middleware (which
* already validates the caller's permission level) so the error message does
* not leak whether the integration exists.
*/
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
// Existing integrations that predate the creatorId column have a null
// creatorId; we skip the check for backward compatibility.
if (integration.creatorId && integration.creatorId !== userId) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
}
};
// Hosts that should never be fetched server-side. processWeb is already
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
const isBlockedWebHost = (hostname: string): boolean => {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "0.0.0.0" ||
host === "::" ||
host === "::1" ||
host.startsWith("127.") ||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
host === "metadata.google.internal"
);
};
// A user-provided URL for web-page ingestion: must be http(s) and not target a
// loopback/metadata host.
const webUrlSchema = z
.string()
.url()
.refine((value) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
// Bounds the upload/transcription payloads so a huge request can't pressure
// memory here or upstream before we even start decoding.
const MAX_BASE64_LENGTH = 25_000_000;
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
const uploadFilenameSchema = z.string().min(1).max(255);
const contentTypeSchema = z.string().min(1).max(255);
// Tag persisted conversations with the owning Homarr user so they can be found
// back in Open WebUI even when an integration/account is shared between users.
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
const messageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
});
// Outbound completion messages may carry multimodal parts (text + images),
// so users can attach images to a message.
const contentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
const completionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.union([z.string(), z.array(contentPartSchema)]),
});
const chatPayloadSchema = z.object({
title: z.string(),
models: z.array(z.string()),
messages: z.array(messageSchema),
});
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
type CompletionMessage = z.infer<typeof completionMessageSchema>;
// Extract the plain text of a message, flattening multimodal content parts.
const messageText = (message: CompletionMessage): string =>
typeof message.content === "string"
? message.content
: message.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
// Number of chunks to retrieve per collection when grounding a reply.
const RETRIEVAL_K = 4;
// Instruction prepended to the retrieved context so the model knows how to use
// it. Model-facing, so intentionally not localized.
const RETRIEVAL_INSTRUCTION =
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
"files, notes and chats) to answer. If the answer is not in the context, say so.";
// Retrieve relevant chunks from the attached collections and prepend them as a
// system context message so the model can ground its reply on them.
const groundMessagesAsync = async (
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
messages: CompletionMessage[],
collections: string[],
contextTexts: string[],
): Promise<CompletionMessage[]> => {
if (collections.length === 0 && contextTexts.length === 0) return messages;
// Use the most recent user message that has text. The latest turn can be
// image-only (no text), in which case we fall back to the prior question so
// attached collections still get a meaningful retrieval query.
const query =
messages
.filter((message) => message.role === "user")
.map(messageText)
.findLast((text) => text.trim() !== "") ?? "";
let documents: string[] = [];
if (collections.length > 0) {
try {
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
} catch (error) {
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
}
}
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
if (blocks.length === 0) return messages;
const contextMessage: CompletionMessage = {
role: "system",
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
};
return [contextMessage, ...messages];
};
export const openWebUiRouter = createTRPCRouter({
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Chat history exposes private user data, so we require "interact" access
// (not just "query") and verify the caller is the integration's creator.
getChats: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
// Knowledge bases, files and notes expose private source content/metadata, so
// gate them like the interactive chat paths (protected + "interact" access,
// creator-only) instead of leaving them reachable by anonymous board viewers.
getKnowledge: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getFiles: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNotes: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
const { data } = await handler.getDataAsync();
return data;
}),
getNote: protectedProcedure
.input(z.object({ noteId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getDataAsync();
return data;
}),
getKnowledgeFiles: protectedProcedure
.input(z.object({ knowledgeId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
knowledgeId: input.knowledgeId,
});
const { data } = await handler.getDataAsync();
return data;
}),
// Ingest a web page so its content can ground later messages. Returns the
// collection to retrieve from plus a display title.
processWeb: protectedProcedure
.input(z.object({ url: webUrlSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.processWebAsync(input.url);
}),
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
// the file id; its vector collection is `file-{id}`.
uploadFile: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
}),
// Transcribe recorded audio to text for voice messages.
transcribe: protectedProcedure
.input(
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
return { text };
}),
// Full chat messages contain private user data; restrict to the creator.
getChat: protectedProcedure
.input(z.object({ chatId: z.string() }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.query(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
const { data } = await handler.getDataAsync();
return data;
}),
createChat: protectedProcedure
.input(chatPayloadSchema)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.createChatAsync({
title: input.title,
models: input.models,
messages: input.messages,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
return chat;
}),
updateChat: protectedProcedure
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.mutation(async ({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session.user.id);
const client = await createIntegrationAsync(ctx.integration);
const chat = await client.updateChatAsync(input.chatId, {
...input.chat,
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
});
openWebUiChatsRequestHandler.invalidateCache();
openWebUiChatRequestHandler.invalidateCache();
return chat;
}),
// Streaming chat completion. Delivered over the WebSocket subscription link so
// tokens can be emitted as they arrive. A completion spends the owner's upstream
// LLM credits, so it must not be reachable by anonymous viewers of a public
// board: protectedProcedure requires a session (the WS context resolves it from
// the session-token cookie) and the "interact" middleware checks integration
// access, matching processWeb/uploadFile/transcribe above.
sendMessage: protectedProcedure
.input(
z.object({
model: z.string(),
messages: z.array(completionMessageSchema),
// Collections to ground the reply on (web pages, knowledge bases, files).
// We retrieve relevant chunks and inject them before streaming.
collections: z.array(z.string()).optional(),
// Verbatim context blocks (notes, referenced chats) injected as-is.
contextTexts: z.array(z.string()).optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
.subscription(({ ctx, input }) => {
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
return observable<StreamEvent>((emit) => {
const controller = new AbortController();
void (async () => {
try {
const client = await createIntegrationAsync(ctx.integration);
const messages = await groundMessagesAsync(
client,
input.messages,
input.collections ?? [],
input.contextTexts ?? [],
);
await client.streamChatCompletionAsync(
{ model: input.model, messages },
(delta) => emit.next({ type: "delta", content: delta }),
controller.signal,
);
emit.next({ type: "done" });
emit.complete();
} catch (error) {
if (!controller.signal.aborted) {
logger.error("Open WebUI chat completion failed", {
integrationId: ctx.integration.id,
error,
});
emit.next({
type: "error",
message: error instanceof Error ? error.message : "Failed to stream chat completion",
});
}
emit.complete();
}
})();
return () => {
controller.abort();
};
});
}),
});
+4 -7
View File
@@ -3,7 +3,6 @@ import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
@@ -12,8 +11,6 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -74,13 +71,13 @@ export const releasesRouter = createTRPCRouter({
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch (error) {
logger.warn("Failed to decrypt widget secret", { itemId: input.itemId, kind: secret.kind, error });
} catch {
// Skip corrupt secrets
}
}
}
} catch (error) {
logger.warn("Failed to load release widget tokens", { itemId: input.itemId, error });
} catch {
// Access denied or table not yet migrated -- proceed without tokens
}
}
@@ -11,6 +11,7 @@ interface IntegrationContextProps {
name: string;
url: string;
kind: IntegrationKind;
creatorId: string | null;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
@@ -15,6 +15,7 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
name: true,
url: true,
kind: true,
creatorId: true,
},
with: {
userPermissions: {
+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.
* Does not accept floats; size in bytes should be an integer.
* Will return "NaI" and log a warning if a float is passed.
* `concat` is appended after the unit so it is omitted when the returned
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
* 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.
* Number of bytes to si format. (Division by 1024)
* Does not accept floats, size in bytes should be an integer.
* Will return "NaI" and logs a warning if a float is passed.
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
* Returns "∞" if the size is too large to be represented in the current format.
*/
export const humanFileSize = (size: number, concat = ""): string => {
//64bit limit for Number stops at EiB
@@ -54,99 +48,6 @@ export const humanFileSize = (size: number, concat = ""): string => {
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;
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");
});
});
@@ -0,0 +1,2 @@
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
@@ -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
@@ -306,9 +306,9 @@
{
"idx": 43,
"version": "5",
"when": 1783852800000,
"tag": "0043_add_right_click_preference",
"when": 1783786572320,
"tag": "0043_add_integration_creator_id",
"breakpoints": true
}
]
}
}
@@ -0,0 +1,2 @@
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "enable_right_click_on_widgets" boolean DEFAULT true NOT NULL;
@@ -1,5 +1,5 @@
{
"id": "97bda61f-0a71-4e41-8e60-874aafe17115",
"id": "b426483e-2bc9-4d6a-a031-e18abd0ab81c",
"prevId": "39cf38d0-0503-4c79-b7f4-632fa062fa2a",
"version": "7",
"dialect": "postgresql",
@@ -97,8 +97,12 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -106,7 +110,10 @@
"compositePrimaryKeys": {
"account_provider_provider_account_id_pk": {
"name": "account_provider_provider_account_id_pk",
"columns": ["provider", "provider_account_id"]
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {},
@@ -143,8 +150,12 @@
"name": "apiKey_user_id_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -233,8 +244,12 @@
"name": "boardGroupPermission_board_id_board_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -242,8 +257,12 @@
"name": "boardGroupPermission_group_id_group_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -251,7 +270,11 @@
"compositePrimaryKeys": {
"boardGroupPermission_board_id_group_id_permission_pk": {
"name": "boardGroupPermission_board_id_group_id_permission_pk",
"columns": ["board_id", "group_id", "permission"]
"columns": [
"board_id",
"group_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -288,8 +311,12 @@
"name": "boardUserPermission_board_id_board_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -297,8 +324,12 @@
"name": "boardUserPermission_user_id_user_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -306,7 +337,11 @@
"compositePrimaryKeys": {
"boardUserPermission_board_id_user_id_permission_pk": {
"name": "boardUserPermission_board_id_user_id_permission_pk",
"columns": ["board_id", "user_id", "permission"]
"columns": [
"board_id",
"user_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -448,8 +483,12 @@
"name": "board_creator_id_user_id_fk",
"tableFrom": "board",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -459,7 +498,9 @@
"board_name_unique": {
"name": "board_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
"columns": [
"name"
]
}
},
"policies": {},
@@ -606,8 +647,12 @@
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -653,8 +698,12 @@
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"columnsFrom": [
"definition_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -662,7 +711,10 @@
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_kind_pk": {
"name": "custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
"columns": [
"definition_id",
"kind"
]
}
},
"uniqueConstraints": {},
@@ -693,8 +745,12 @@
"name": "groupMember_group_id_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -702,8 +758,12 @@
"name": "groupMember_user_id_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -711,7 +771,10 @@
"compositePrimaryKeys": {
"groupMember_group_id_user_id_pk": {
"name": "groupMember_group_id_user_id_pk",
"columns": ["group_id", "user_id"]
"columns": [
"group_id",
"user_id"
]
}
},
"uniqueConstraints": {},
@@ -742,8 +805,12 @@
"name": "groupPermission_group_id_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -801,8 +868,12 @@
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -810,8 +881,12 @@
"name": "group_home_board_id_board_id_fk",
"tableFrom": "group",
"tableTo": "board",
"columnsFrom": ["home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -819,8 +894,12 @@
"name": "group_mobile_home_board_id_board_id_fk",
"tableFrom": "group",
"tableTo": "board",
"columnsFrom": ["mobile_home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"mobile_home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -830,7 +909,9 @@
"group_name_unique": {
"name": "group_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
"columns": [
"name"
]
}
},
"policies": {},
@@ -903,8 +984,12 @@
"name": "icon_icon_repository_id_iconRepository_id_fk",
"tableFrom": "icon",
"tableTo": "iconRepository",
"columnsFrom": ["icon_repository_id"],
"columnsTo": ["id"],
"columnsFrom": [
"icon_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -944,8 +1029,12 @@
"name": "integrationGroupPermissions_integration_id_integration_id_fk",
"tableFrom": "integrationGroupPermissions",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -953,8 +1042,12 @@
"name": "integrationGroupPermissions_group_id_group_id_fk",
"tableFrom": "integrationGroupPermissions",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -962,7 +1055,11 @@
"compositePrimaryKeys": {
"integration_group_permission__pk": {
"name": "integration_group_permission__pk",
"columns": ["integration_id", "group_id", "permission"]
"columns": [
"integration_id",
"group_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -993,8 +1090,12 @@
"name": "integration_item_item_id_item_id_fk",
"tableFrom": "integration_item",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1002,8 +1103,12 @@
"name": "integration_item_integration_id_integration_id_fk",
"tableFrom": "integration_item",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1011,7 +1116,10 @@
"compositePrimaryKeys": {
"integration_item_item_id_integration_id_pk": {
"name": "integration_item_item_id_integration_id_pk",
"columns": ["item_id", "integration_id"]
"columns": [
"item_id",
"integration_id"
]
}
},
"uniqueConstraints": {},
@@ -1085,8 +1193,12 @@
"name": "integrationSecret_integration_id_integration_id_fk",
"tableFrom": "integrationSecret",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1094,7 +1206,10 @@
"compositePrimaryKeys": {
"integrationSecret_integration_id_kind_pk": {
"name": "integrationSecret_integration_id_kind_pk",
"columns": ["integration_id", "kind"]
"columns": [
"integration_id",
"kind"
]
}
},
"uniqueConstraints": {},
@@ -1131,8 +1246,12 @@
"name": "integrationUserPermission_integration_id_integration_id_fk",
"tableFrom": "integrationUserPermission",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1140,8 +1259,12 @@
"name": "integrationUserPermission_user_id_user_id_fk",
"tableFrom": "integrationUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1149,7 +1272,11 @@
"compositePrimaryKeys": {
"integrationUserPermission_integration_id_user_id_permission_pk": {
"name": "integrationUserPermission_integration_id_user_id_permission_pk",
"columns": ["integration_id", "user_id", "permission"]
"columns": [
"integration_id",
"user_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -1190,6 +1317,12 @@
"type": "varchar(128)",
"primaryKey": false,
"notNull": false
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
@@ -1214,8 +1347,25 @@
"name": "integration_app_id_app_id_fk",
"tableFrom": "integration",
"tableTo": "app",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"integration_creator_id_user_id_fk": {
"name": "integration_creator_id_user_id_fk",
"tableFrom": "integration",
"tableTo": "user",
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1261,8 +1411,12 @@
"name": "invite_creator_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1272,7 +1426,9 @@
"invite_token_unique": {
"name": "invite_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
"columns": [
"token"
]
}
},
"policies": {},
@@ -1332,8 +1488,12 @@
"name": "item_layout_item_id_item_id_fk",
"tableFrom": "item_layout",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1341,8 +1501,12 @@
"name": "item_layout_section_id_section_id_fk",
"tableFrom": "item_layout",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1350,8 +1514,12 @@
"name": "item_layout_layout_id_layout_id_fk",
"tableFrom": "item_layout",
"tableTo": "layout",
"columnsFrom": ["layout_id"],
"columnsTo": ["id"],
"columnsFrom": [
"layout_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1359,7 +1527,11 @@
"compositePrimaryKeys": {
"item_layout_item_id_section_id_layout_id_pk": {
"name": "item_layout_item_id_section_id_layout_id_pk",
"columns": ["item_id", "section_id", "layout_id"]
"columns": [
"item_id",
"section_id",
"layout_id"
]
}
},
"uniqueConstraints": {},
@@ -1410,8 +1582,12 @@
"name": "item_board_id_board_id_fk",
"tableFrom": "item",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1464,8 +1640,12 @@
"name": "layout_board_id_board_id_fk",
"tableFrom": "layout",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1530,8 +1710,12 @@
"name": "media_creator_id_user_id_fk",
"tableFrom": "media",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1633,8 +1817,12 @@
"name": "search_engine_integration_id_integration_id_fk",
"tableFrom": "search_engine",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1644,7 +1832,9 @@
"search_engine_short_unique": {
"name": "search_engine_short_unique",
"nullsNotDistinct": false,
"columns": ["short"]
"columns": [
"short"
]
}
},
"policies": {},
@@ -1681,8 +1871,12 @@
"name": "section_collapse_state_user_id_user_id_fk",
"tableFrom": "section_collapse_state",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1690,8 +1884,12 @@
"name": "section_collapse_state_section_id_section_id_fk",
"tableFrom": "section_collapse_state",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1699,7 +1897,10 @@
"compositePrimaryKeys": {
"section_collapse_state_user_id_section_id_pk": {
"name": "section_collapse_state_user_id_section_id_pk",
"columns": ["user_id", "section_id"]
"columns": [
"user_id",
"section_id"
]
}
},
"uniqueConstraints": {},
@@ -1760,8 +1961,12 @@
"name": "section_layout_section_id_section_id_fk",
"tableFrom": "section_layout",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1769,8 +1974,12 @@
"name": "section_layout_layout_id_layout_id_fk",
"tableFrom": "section_layout",
"tableTo": "layout",
"columnsFrom": ["layout_id"],
"columnsTo": ["id"],
"columnsFrom": [
"layout_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1778,8 +1987,12 @@
"name": "section_layout_parent_section_id_section_id_fk",
"tableFrom": "section_layout",
"tableTo": "section",
"columnsFrom": ["parent_section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"parent_section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1787,7 +2000,10 @@
"compositePrimaryKeys": {
"section_layout_section_id_layout_id_pk": {
"name": "section_layout_section_id_layout_id_pk",
"columns": ["section_id", "layout_id"]
"columns": [
"section_id",
"layout_id"
]
}
},
"uniqueConstraints": {},
@@ -1849,8 +2065,12 @@
"name": "section_board_id_board_id_fk",
"tableFrom": "section",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1886,7 +2106,9 @@
"serverSetting_settingKey_unique": {
"name": "serverSetting_settingKey_unique",
"nullsNotDistinct": false,
"columns": ["setting_key"]
"columns": [
"setting_key"
]
}
},
"policies": {},
@@ -1938,8 +2160,12 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1950,6 +2176,45 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.trusted_certificate_hostname": {
"name": "trusted_certificate_hostname",
"schema": "",
"columns": {
"hostname": {
"name": "hostname",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true
},
"thumbprint": {
"name": "thumbprint",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"certificate": {
"name": "certificate",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"trusted_certificate_hostname_hostname_thumbprint_pk": {
"name": "trusted_certificate_hostname_hostname_thumbprint_pk",
"columns": [
"hostname",
"thumbprint"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -2050,13 +2315,6 @@
"notNull": true,
"default": false
},
"enable_right_click_on_widgets": {
"name": "enable_right_click_on_widgets",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"completed_manage_tour": {
"name": "completed_manage_tour",
"type": "boolean",
@@ -2078,8 +2336,12 @@
"name": "user_home_board_id_board_id_fk",
"tableFrom": "user",
"tableTo": "board",
"columnsFrom": ["home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -2087,8 +2349,12 @@
"name": "user_mobile_home_board_id_board_id_fk",
"tableFrom": "user",
"tableTo": "board",
"columnsFrom": ["mobile_home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"mobile_home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -2096,8 +2362,12 @@
"name": "user_default_search_engine_id_search_engine_id_fk",
"tableFrom": "user",
"tableTo": "search_engine",
"columnsFrom": ["default_search_engine_id"],
"columnsTo": ["id"],
"columnsFrom": [
"default_search_engine_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -2136,7 +2406,10 @@
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": ["identifier", "token"]
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
@@ -2179,8 +2452,12 @@
"name": "widget_secret_item_id_item_id_fk",
"tableFrom": "widget_secret",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -2188,43 +2465,10 @@
"compositePrimaryKeys": {
"widget_secret_item_id_kind_pk": {
"name": "widget_secret_item_id_kind_pk",
"columns": ["item_id", "kind"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.trusted_certificate_hostname": {
"name": "trusted_certificate_hostname",
"schema": "",
"columns": {
"hostname": {
"name": "hostname",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true
},
"thumbprint": {
"name": "thumbprint",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"certificate": {
"name": "certificate",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"trusted_certificate_hostname_hostname_thumbprint_pk": {
"name": "trusted_certificate_hostname_hostname_thumbprint_pk",
"columns": ["hostname", "thumbprint"]
"columns": [
"item_id",
"kind"
]
}
},
"uniqueConstraints": {},
@@ -2244,4 +2488,4 @@
"schemas": {},
"tables": {}
}
}
}
@@ -68,9 +68,9 @@
{
"idx": 9,
"version": "7",
"when": 1783786677702,
"tag": "0009_add_right_click_preference",
"when": 1783786378808,
"tag": "0009_add_integration_creator_id",
"breakpoints": true
}
]
}
}
@@ -0,0 +1 @@
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
@@ -1 +0,0 @@
ALTER TABLE `user` ADD `enable_right_click_on_widgets` integer DEFAULT true NOT NULL;
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "852e049b-72cc-4d7f-8be9-931c042fe019",
"id": "8e6041c6-1cca-4eb2-b935-2e8ceb1b036d",
"prevId": "d76abdce-ba49-450b-81f0-621bcf581b90",
"tables": {
"account": {
@@ -88,7 +88,9 @@
"indexes": {
"userId_idx": {
"name": "userId_idx",
"columns": ["user_id"],
"columns": [
"user_id"
],
"isUnique": false
}
},
@@ -97,15 +99,22 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_provider_account_id_pk": {
"columns": ["provider", "provider_account_id"],
"columns": [
"provider",
"provider_account_id"
],
"name": "account_provider_provider_account_id_pk"
}
},
@@ -143,8 +152,12 @@
"name": "apiKey_user_id_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -236,8 +249,12 @@
"name": "boardGroupPermission_board_id_board_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -245,15 +262,23 @@
"name": "boardGroupPermission_group_id_group_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"boardGroupPermission_board_id_group_id_permission_pk": {
"columns": ["board_id", "group_id", "permission"],
"columns": [
"board_id",
"group_id",
"permission"
],
"name": "boardGroupPermission_board_id_group_id_permission_pk"
}
},
@@ -291,8 +316,12 @@
"name": "boardUserPermission_board_id_board_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -300,15 +329,23 @@
"name": "boardUserPermission_user_id_user_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"boardUserPermission_board_id_user_id_permission_pk": {
"columns": ["board_id", "user_id", "permission"],
"columns": [
"board_id",
"user_id",
"permission"
],
"name": "boardUserPermission_board_id_user_id_permission_pk"
}
},
@@ -464,7 +501,9 @@
"indexes": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"],
"columns": [
"name"
],
"isUnique": true
}
},
@@ -473,8 +512,12 @@
"name": "board_creator_id_user_id_fk",
"tableFrom": "board",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -637,8 +680,12 @@
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -685,15 +732,22 @@
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"columnsFrom": [
"definition_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_kind_pk": {
"columns": ["definition_id", "kind"],
"columns": [
"definition_id",
"kind"
],
"name": "custom_widget_secret_definition_id_kind_pk"
}
},
@@ -724,8 +778,12 @@
"name": "groupMember_group_id_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -733,15 +791,22 @@
"name": "groupMember_user_id_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"groupMember_group_id_user_id_pk": {
"columns": ["group_id", "user_id"],
"columns": [
"group_id",
"user_id"
],
"name": "groupMember_group_id_user_id_pk"
}
},
@@ -772,8 +837,12 @@
"name": "groupPermission_group_id_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -831,7 +900,9 @@
"indexes": {
"group_name_unique": {
"name": "group_name_unique",
"columns": ["name"],
"columns": [
"name"
],
"isUnique": true
}
},
@@ -840,8 +911,12 @@
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -849,8 +924,12 @@
"name": "group_home_board_id_board_id_fk",
"tableFrom": "group",
"tableTo": "board",
"columnsFrom": ["home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -858,8 +937,12 @@
"name": "group_mobile_home_board_id_board_id_fk",
"tableFrom": "group",
"tableTo": "board",
"columnsFrom": ["mobile_home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"mobile_home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -937,8 +1020,12 @@
"name": "icon_icon_repository_id_iconRepository_id_fk",
"tableFrom": "icon",
"tableTo": "iconRepository",
"columnsFrom": ["icon_repository_id"],
"columnsTo": ["id"],
"columnsFrom": [
"icon_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -978,8 +1065,12 @@
"name": "integrationGroupPermissions_integration_id_integration_id_fk",
"tableFrom": "integrationGroupPermissions",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -987,15 +1078,23 @@
"name": "integrationGroupPermissions_group_id_group_id_fk",
"tableFrom": "integrationGroupPermissions",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"integrationGroupPermissions_integration_id_group_id_permission_pk": {
"columns": ["integration_id", "group_id", "permission"],
"columns": [
"integration_id",
"group_id",
"permission"
],
"name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
}
},
@@ -1026,8 +1125,12 @@
"name": "integration_item_item_id_item_id_fk",
"tableFrom": "integration_item",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1035,15 +1138,22 @@
"name": "integration_item_integration_id_integration_id_fk",
"tableFrom": "integration_item",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"integration_item_item_id_integration_id_pk": {
"columns": ["item_id", "integration_id"],
"columns": [
"item_id",
"integration_id"
],
"name": "integration_item_item_id_integration_id_pk"
}
},
@@ -1085,12 +1195,16 @@
"indexes": {
"integration_secret__kind_idx": {
"name": "integration_secret__kind_idx",
"columns": ["kind"],
"columns": [
"kind"
],
"isUnique": false
},
"integration_secret__updated_at_idx": {
"name": "integration_secret__updated_at_idx",
"columns": ["updated_at"],
"columns": [
"updated_at"
],
"isUnique": false
}
},
@@ -1099,15 +1213,22 @@
"name": "integrationSecret_integration_id_integration_id_fk",
"tableFrom": "integrationSecret",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"integrationSecret_integration_id_kind_pk": {
"columns": ["integration_id", "kind"],
"columns": [
"integration_id",
"kind"
],
"name": "integrationSecret_integration_id_kind_pk"
}
},
@@ -1145,8 +1266,12 @@
"name": "integrationUserPermission_integration_id_integration_id_fk",
"tableFrom": "integrationUserPermission",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1154,15 +1279,23 @@
"name": "integrationUserPermission_user_id_user_id_fk",
"tableFrom": "integrationUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"integrationUserPermission_integration_id_user_id_permission_pk": {
"columns": ["integration_id", "user_id", "permission"],
"columns": [
"integration_id",
"user_id",
"permission"
],
"name": "integrationUserPermission_integration_id_user_id_permission_pk"
}
},
@@ -1206,12 +1339,21 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"creator_id": {
"name": "creator_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"integration__kind_idx": {
"name": "integration__kind_idx",
"columns": ["kind"],
"columns": [
"kind"
],
"isUnique": false
}
},
@@ -1220,8 +1362,25 @@
"name": "integration_app_id_app_id_fk",
"tableFrom": "integration",
"tableTo": "app",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"integration_creator_id_user_id_fk": {
"name": "integration_creator_id_user_id_fk",
"tableFrom": "integration",
"tableTo": "user",
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1265,7 +1424,9 @@
"indexes": {
"invite_token_unique": {
"name": "invite_token_unique",
"columns": ["token"],
"columns": [
"token"
],
"isUnique": true
}
},
@@ -1274,8 +1435,12 @@
"name": "invite_creator_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1343,8 +1508,12 @@
"name": "item_layout_item_id_item_id_fk",
"tableFrom": "item_layout",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1352,8 +1521,12 @@
"name": "item_layout_section_id_section_id_fk",
"tableFrom": "item_layout",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1361,15 +1534,23 @@
"name": "item_layout_layout_id_layout_id_fk",
"tableFrom": "item_layout",
"tableTo": "layout",
"columnsFrom": ["layout_id"],
"columnsTo": ["id"],
"columnsFrom": [
"layout_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"item_layout_item_id_section_id_layout_id_pk": {
"columns": ["item_id", "section_id", "layout_id"],
"columns": [
"item_id",
"section_id",
"layout_id"
],
"name": "item_layout_item_id_section_id_layout_id_pk"
}
},
@@ -1423,8 +1604,12 @@
"name": "item_board_id_board_id_fk",
"tableFrom": "item",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1479,8 +1664,12 @@
"name": "layout_board_id_board_id_fk",
"tableFrom": "layout",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1549,8 +1738,12 @@
"name": "media_creator_id_user_id_fk",
"tableFrom": "media",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1654,7 +1847,9 @@
"indexes": {
"search_engine_short_unique": {
"name": "search_engine_short_unique",
"columns": ["short"],
"columns": [
"short"
],
"isUnique": true
}
},
@@ -1663,8 +1858,12 @@
"name": "search_engine_integration_id_integration_id_fk",
"tableFrom": "search_engine",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1705,8 +1904,12 @@
"name": "section_collapse_state_user_id_user_id_fk",
"tableFrom": "section_collapse_state",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1714,15 +1917,22 @@
"name": "section_collapse_state_section_id_section_id_fk",
"tableFrom": "section_collapse_state",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"section_collapse_state_user_id_section_id_pk": {
"columns": ["user_id", "section_id"],
"columns": [
"user_id",
"section_id"
],
"name": "section_collapse_state_user_id_section_id_pk"
}
},
@@ -1788,8 +1998,12 @@
"name": "section_layout_section_id_section_id_fk",
"tableFrom": "section_layout",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1797,8 +2011,12 @@
"name": "section_layout_layout_id_layout_id_fk",
"tableFrom": "section_layout",
"tableTo": "layout",
"columnsFrom": ["layout_id"],
"columnsTo": ["id"],
"columnsFrom": [
"layout_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1806,15 +2024,22 @@
"name": "section_layout_parent_section_id_section_id_fk",
"tableFrom": "section_layout",
"tableTo": "section",
"columnsFrom": ["parent_section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"parent_section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"section_layout_section_id_layout_id_pk": {
"columns": ["section_id", "layout_id"],
"columns": [
"section_id",
"layout_id"
],
"name": "section_layout_section_id_layout_id_pk"
}
},
@@ -1881,8 +2106,12 @@
"name": "section_board_id_board_id_fk",
"tableFrom": "section",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1913,7 +2142,9 @@
"indexes": {
"serverSetting_settingKey_unique": {
"name": "serverSetting_settingKey_unique",
"columns": ["setting_key"],
"columns": [
"setting_key"
],
"isUnique": true
}
},
@@ -1950,7 +2181,9 @@
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": ["user_id"],
"columns": [
"user_id"
],
"isUnique": false
}
},
@@ -1959,8 +2192,12 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1998,7 +2235,10 @@
"foreignKeys": {},
"compositePrimaryKeys": {
"trusted_certificate_hostname_hostname_thumbprint_pk": {
"columns": ["hostname", "thumbprint"],
"columns": [
"hostname",
"thumbprint"
],
"name": "trusted_certificate_hostname_hostname_thumbprint_pk"
}
},
@@ -2119,14 +2359,6 @@
"autoincrement": false,
"default": false
},
"enable_right_click_on_widgets": {
"name": "enable_right_click_on_widgets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"completed_manage_tour": {
"name": "completed_manage_tour",
"type": "integer",
@@ -2150,8 +2382,12 @@
"name": "user_home_board_id_board_id_fk",
"tableFrom": "user",
"tableTo": "board",
"columnsFrom": ["home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -2159,8 +2395,12 @@
"name": "user_mobile_home_board_id_board_id_fk",
"tableFrom": "user",
"tableTo": "board",
"columnsFrom": ["mobile_home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"mobile_home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -2168,8 +2408,12 @@
"name": "user_default_search_engine_id_search_engine_id_fk",
"tableFrom": "user",
"tableTo": "search_engine",
"columnsFrom": ["default_search_engine_id"],
"columnsTo": ["id"],
"columnsFrom": [
"default_search_engine_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -2207,7 +2451,10 @@
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": ["identifier", "token"],
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
@@ -2252,15 +2499,22 @@
"name": "widget_secret_item_id_item_id_fk",
"tableFrom": "widget_secret",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"widget_secret_item_id_kind_pk": {
"columns": ["item_id", "kind"],
"columns": [
"item_id",
"kind"
],
"name": "widget_secret_item_id_kind_pk"
}
},
@@ -2278,4 +2532,4 @@
"internal": {
"indexes": {}
}
}
}
@@ -292,9 +292,9 @@
{
"idx": 41,
"version": "6",
"when": 1783786658943,
"tag": "0041_add_right_click_preference",
"when": 1783786326756,
"tag": "0041_add_integration_creator_id",
"breakpoints": true
}
]
}
}
+5 -1
View File
@@ -86,7 +86,6 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
@@ -210,6 +209,7 @@ export const integrations = mysqlTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -689,6 +689,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5 -1
View File
@@ -85,7 +85,6 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
@@ -209,6 +208,7 @@ export const integrations = pgTable(
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -688,6 +688,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+5 -1
View File
@@ -68,7 +68,6 @@ export const users = sqliteTable("user", {
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
});
@@ -196,6 +195,7 @@ export const integrations = sqliteTable(
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
@@ -678,6 +678,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
fields: [integrations.appId],
references: [apps.id],
}),
creator: one(users, {
fields: [integrations.creatorId],
references: [users.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
+1 -1
View File
@@ -37,5 +37,5 @@ describe("Mysql Migration", () => {
connection.end();
await mysqlContainer.stop();
}, 120_000);
}, 40_000);
});
@@ -329,6 +329,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/open-webui"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -381,4 +382,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api";
| "/docs/widgets/custom-api"
| "/docs/widgets/open-webui";
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
openWebUi: "open-webui",
mock: null,
};
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
openWebUi: "open-webui",
};
+1
View File
@@ -3,4 +3,5 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
toggleAiChat: "mod+shift+a",
};
+12 -1
View File
@@ -379,7 +379,7 @@ export const integrationDefs = {
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
defaultPort: 3413,
apiKeySettingsPath: "/settings/integrations",
apiKeySettingsPath: "/settings/api-keys",
},
tracearr: {
name: "Tracearr",
@@ -467,6 +467,16 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: null,
defaultUrl: "http://localhost:8001",
},
openWebUi: {
name: "Open WebUI",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
category: ["ai"],
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
defaultPort: 8080,
apiKeySettingsPath: "/?settings=open#account",
},
// 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: {
@@ -582,6 +592,7 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"ai",
"subtitleManager",
"reverseProxy",
] as const;
@@ -15,12 +15,6 @@ describe("getIntegrationApiKeyUrl", () => {
);
});
it("builds URL for patchmon", () => {
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
"http://patchmon.local:3413/settings/integrations",
);
});
it("returns null for integrations without apiKeySettingsPath", () => {
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
});
@@ -43,6 +43,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
openWebUi: ["openWebUi"],
traefik: ["traefik"],
};
+1
View File
@@ -53,6 +53,7 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"openWebUi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+8 -31
View File
@@ -19,12 +19,6 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`;
}>(`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 {
private static hmacKey: Buffer<ArrayBuffer> | null = null;
@@ -53,34 +47,17 @@ export class ImageProxy {
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);
if (!urlAndHeaders) {
return { error: "not-found" };
return null;
}
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
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) {
logger.error(
new ErrorWithMetadata("Failed to fetch image", {
@@ -91,7 +68,7 @@ export class ImageProxy {
statusCode: response.status,
}),
);
return { error: "upstream-error", statusCode: response.status };
return null;
}
const arrayBuffer = await response.arrayBuffer();
@@ -103,7 +80,7 @@ export class ImageProxy {
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
});
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
return arrayBuffer;
}
private createImageUrl(id: string): string {
@@ -32,6 +32,7 @@ import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
@@ -131,6 +132,7 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
openWebUi: OpenWebUiIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
@@ -1,3 +1,5 @@
import { humanFileSize } from "@homarr/common";
import "@homarr/redis";
import dayjs from "dayjs";
@@ -46,7 +48,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
.map((storage, index) => ({
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
// 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}`,
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> {
const type = "torrent";
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(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
@@ -117,12 +117,6 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
},
location: null,
metadata: null,
};
}
@@ -1,14 +1,10 @@
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo,
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -46,7 +42,6 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -67,36 +62,36 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await this.createAssetsAsync(albumAssets),
assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
this.url(`/api/assets/${asset.id}/original`).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
id: album.id,
};
}
@@ -138,32 +133,6 @@ export class ImmichIntegration extends Integration {
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// 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"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
+1
View File
@@ -42,6 +42,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
@@ -13,12 +13,6 @@ export interface StreamSession {
episodeName?: string | null;
albumName?: string | null;
episodeCount?: number | null;
playback: {
state: "playing" | "paused" | "buffering" | null;
positionMs: number | null;
durationMs: number | null;
};
location: "lan" | "wan" | null;
metadata: {
video: {
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 { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -54,9 +50,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
@@ -64,12 +57,6 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: null,
metadata: {
video: {
resolution:
@@ -13,8 +13,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -29,8 +27,6 @@ const sessions: StreamSession[] = [
episodeName: "Ozymandias",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -45,8 +41,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -61,8 +55,6 @@ const sessions: StreamSession[] = [
episodeName: "The We We Are",
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -77,8 +69,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: "Random Access Memories",
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -93,8 +83,6 @@ const sessions: StreamSession[] = [
episodeName: null,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
location: null,
metadata: null,
},
},
@@ -0,0 +1,372 @@
import { FormData } from "undici";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
OpenWebUiChat,
OpenWebUiChatListItem,
OpenWebUiChatPayload,
OpenWebUiCompletionMessage,
OpenWebUiFileSummary,
OpenWebUiKnowledge,
OpenWebUiModel,
OpenWebUiNote,
OpenWebUiWebDocument,
} from "./open-webui-types";
import {
openWebUiChatListItemSchema,
openWebUiChatSchema,
openWebUiCollectionQueryResponseSchema,
openWebUiCompletionChunkSchema,
openWebUiFileListSchema,
openWebUiKnowledgeListSchema,
openWebUiModelsResponseSchema,
openWebUiNoteListSchema,
openWebUiNoteSchema,
openWebUiProcessWebResponseSchema,
openWebUiTranscriptionSchema,
openWebUiUploadedFileSchema,
} from "./open-webui-types";
const logger = createLogger({ module: "open-webui-integration" });
// Default per-request timeout so a stalled upstream can't hang model/file/note/
// chat calls indefinitely. Streaming completions opt out (they are long-lived).
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
// The (undici) request options accepted by the shared fetch helper. Derived so
// the request helpers stay in sync with the underlying fetch types.
type RequestOptions = NonNullable<Parameters<typeof fetchWithTrustedCertificatesAsync>[1]>;
export class OpenWebUiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/models"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new ResponseError(response);
}
return { success: true };
}
/**
* List the models served by Open WebUI (aggregates all configured providers).
*/
public async getModelsAsync(): Promise<OpenWebUiModel[]> {
const parsed = await this.getJsonAsync("/api/models", openWebUiModelsResponseSchema);
return parsed.data;
}
/**
* List the knowledge bases (RAG collections) the user can ground chats on.
*/
public async getKnowledgeAsync(): Promise<OpenWebUiKnowledge[]> {
const parsed = await this.getJsonAsync("/api/v1/knowledge/", openWebUiKnowledgeListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's uploaded files (for the "Attach Files" picker).
*/
public async listFilesAsync(): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/**
* List the files contained in a knowledge base (for the expandable picker).
* The knowledge detail endpoint returns `files: null` on recent versions, so
* we read the files list and filter by the base's collection id instead.
*/
public async getKnowledgeFilesAsync(knowledgeId: string): Promise<OpenWebUiFileSummary[]> {
const files = await this.fetchFilesAsync();
return files
.filter((file) => file.meta?.collection_name === knowledgeId)
.map((file) => ({ id: file.id, name: file.meta?.name ?? file.filename ?? file.id }));
}
/** Fetch the raw file list, normalizing the paginated and bare-array shapes. */
private async fetchFilesAsync() {
const parsed = await this.getJsonAsync("/api/v1/files/", openWebUiFileListSchema);
return Array.isArray(parsed) ? parsed : parsed.items;
}
/**
* List the user's notes (for the "Attach Notes" picker).
*/
public async listNotesAsync(): Promise<OpenWebUiNote[]> {
const parsed = await this.getJsonAsync("/api/v1/notes/", openWebUiNoteListSchema);
const notes = Array.isArray(parsed) ? parsed : parsed.items;
return notes.map((note) => ({
id: note.id,
title: note.title ?? "Untitled note",
content: note.data?.content?.md ?? "",
}));
}
/**
* Get a single note including its full markdown content. The list endpoint
* truncates the body, so we fetch the note directly when attaching it.
*/
public async getNoteAsync(noteId: string): Promise<OpenWebUiNote> {
const parsed = await this.getJsonAsync(`/api/v1/notes/${encodeURIComponent(noteId)}`, openWebUiNoteSchema);
return { id: parsed.id, title: parsed.title ?? "Untitled note", content: parsed.data?.content?.md ?? "" };
}
/**
* Transcribe recorded audio to text (speech-to-text) for voice messages.
*/
public async transcribeAudioAsync(filename: string, base64Content: string, contentType: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/audio/transcriptions", form, openWebUiTranscriptionSchema);
return parsed.text;
}
/**
* Upload a file to Open WebUI so it can be retrieved from later. Returns the
* new file id (its vector collection is `file-{id}`).
*/
public async uploadFileAsync(
filename: string,
base64Content: string,
contentType: string,
): Promise<OpenWebUiFileSummary> {
const form = new FormData();
form.append("file", new Blob([Buffer.from(base64Content, "base64")], { type: contentType }), filename);
const parsed = await this.postFormAsync("/api/v1/files/", form, openWebUiUploadedFileSchema);
return { id: parsed.id, name: parsed.meta?.name ?? parsed.filename ?? filename };
}
/**
* Ingest a web page into a temporary vector collection and return a handle to
* retrieve from it. Open WebUI does not crawl URLs at completion time, so we
* embed the page here and inject retrieved chunks ourselves at send time.
*/
public async processWebAsync(url: string): Promise<OpenWebUiWebDocument> {
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/process/web",
{ url, collection_name: "" },
openWebUiProcessWebResponseSchema,
);
return { collectionName: parsed.collection_name, title: parsed.file?.meta?.name ?? url };
}
/**
* Retrieve the most relevant chunks for a query across the given collections
* (web pages and/or knowledge bases). Returns the chunk texts, best-effort.
*/
public async queryCollectionsAsync(collectionNames: string[], query: string, k = 4): Promise<string[]> {
if (collectionNames.length === 0 || query.trim() === "") {
return [];
}
const parsed = await this.postJsonAsync(
"/api/v1/retrieval/query/collection",
{ collection_names: collectionNames, query, k },
openWebUiCollectionQueryResponseSchema,
);
return (parsed.documents ?? []).flat();
}
/**
* List the user's chats (native Open WebUI history).
*/
public async listChatsAsync(): Promise<OpenWebUiChatListItem[]> {
return await this.getJsonAsync("/api/v1/chats/", openWebUiChatListItemSchema.array());
}
/**
* Get a single chat including its messages.
*/
public async getChatAsync(chatId: string): Promise<OpenWebUiChat> {
return await this.getJsonAsync(`/api/v1/chats/${encodeURIComponent(chatId)}`, openWebUiChatSchema);
}
/**
* Create a new chat in the native history.
*/
public async createChatAsync(payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync("/api/v1/chats/new", { chat: payload }, openWebUiChatSchema);
}
/**
* Update an existing chat in the native history.
*/
public async updateChatAsync(chatId: string, payload: OpenWebUiChatPayload): Promise<OpenWebUiChat> {
return await this.postJsonAsync(
`/api/v1/chats/${encodeURIComponent(chatId)}`,
{ chat: payload },
openWebUiChatSchema,
);
}
/**
* Stream an assistant completion token-by-token.
* Reads the OpenAI-compatible SSE response and invokes `onDelta` for each
* content delta. Resolves when the stream completes ([DONE]) or is aborted.
*/
public async streamChatCompletionAsync(
params: { model: string; messages: OpenWebUiCompletionMessage[] },
onDelta: (delta: string) => void,
signal: AbortSignal,
): Promise<void> {
const response = await this.requestAsync("/api/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: params.model, messages: params.messages, stream: true }),
// Long-lived stream: opt out of the default request timeout and rely on the
// caller's abort signal instead (the timeout path would also replace this
// signal, breaking cancellation).
timeout: undefined,
signal,
});
if (!response.body) {
throw new ResponseError(response);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
const payload = trimmed.slice(5).trim();
if (payload === "") return false;
if (payload === "[DONE]") return true;
try {
const parsed = openWebUiCompletionChunkSchema.parse(JSON.parse(payload));
const choice = parsed.choices?.[0];
if (choice?.delta?.content) {
onDelta(choice.delta.content);
}
// Some providers end the stream with a finish_reason chunk and no
// explicit [DONE] sentinel; treat that as a clean completion too.
if (choice?.finish_reason) return true;
} catch (error) {
// The chunk can contain model output or grounded user data, so log only
// safe metadata rather than the raw payload.
logger.debug("Failed to parse Open WebUI completion chunk", {
error: error instanceof Error ? error.message : "unknown",
payloadLength: payload.length,
});
}
return false;
};
let completed = false;
try {
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) {
// The server may close after the final data: event without a trailing
// newline; parse whatever is left so a valid completion isn't treated
// as a truncated stream.
if (buffer.trim() !== "" && processLine(buffer)) {
completed = true;
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processLine(line)) {
completed = true;
break;
}
}
if (completed) break;
}
} finally {
reader.cancel().catch(() => {
// ignore cancellation errors during teardown
});
}
// The reader ended without a [DONE]/finish_reason marker: the upstream
// connection was cut mid-stream. Surface it instead of presenting the
// partial reply as a complete answer.
if (!signal.aborted && !completed) {
throw new Error("Open WebUI stream ended before completion");
}
}
private getAuthHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
};
}
/**
* Issue an authenticated request against the Open WebUI API and throw a
* {@link ResponseError} on a non-2xx status. Centralizes the auth header,
* trusted-certificate fetch and error handling shared by every endpoint.
*/
private async requestAsync(
path: `/${string}`,
init?: Omit<RequestOptions, "headers"> & { headers?: Record<string, string> },
) {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
// Apply a default timeout unless the caller opts out (e.g. streaming
// completions pass `timeout: undefined` to stay open indefinitely). A
// spread after this key lets an explicit caller value win.
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
...init,
headers: { ...this.getAuthHeaders(), ...init?.headers },
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
/** GET an endpoint and validate the JSON body against `schema`. */
private async getJsonAsync<TOutput>(
path: `/${string}`,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path);
return schema.parse(await response.json());
}
/** POST a JSON body and validate the JSON response against `schema`. */
private async postJsonAsync<TOutput>(
path: `/${string}`,
body: unknown,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return schema.parse(await response.json());
}
/** POST a multipart form (file upload) and validate the JSON response. */
private async postFormAsync<TOutput>(
path: `/${string}`,
form: FormData,
schema: { parse: (data: unknown) => TOutput },
): Promise<TOutput> {
const response = await this.requestAsync(path, { method: "POST", body: form });
return schema.parse(await response.json());
}
}
@@ -0,0 +1,238 @@
// Open WebUI API types and schemas.
// Open WebUI exposes an OpenAI-compatible API plus its own native chat-history API.
// Schemas are intentionally defensive (`.nullish()`) because the native chat
// endpoints are undocumented and vary between Open WebUI versions.
import { z } from "zod/v4";
// GET /api/models — OpenAI-compatible. Open WebUI returns `{ data: [...] }`.
export const openWebUiModelSchema = z.object({
id: z.string(),
name: z.string().nullish(),
object: z.string().nullish(),
owned_by: z.string().nullish(),
// Open WebUI exposes per-model capabilities here when configured; `vision`
// tells us whether the model can accept image attachments.
info: z
.object({
meta: z
.object({
capabilities: z.object({ vision: z.boolean().nullish() }).nullish(),
})
.nullish(),
})
.nullish(),
});
export const openWebUiModelsResponseSchema = z.object({
data: z.array(openWebUiModelSchema),
});
export type OpenWebUiModel = z.infer<typeof openWebUiModelSchema>;
// POST /api/chat/completions (stream: true) — OpenAI-compatible SSE delta chunk.
export const openWebUiCompletionChunkSchema = z.object({
id: z.string().nullish(),
choices: z
.array(
z.object({
delta: z
.object({
role: z.string().nullish(),
content: z.string().nullish(),
})
.nullish(),
finish_reason: z.string().nullish(),
}),
)
.nullish(),
});
// Multimodal content part (OpenAI vision format). Lets a message carry text
// and/or images.
export const openWebUiContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), text: z.string() }),
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
]);
// Message content is either plain text or multimodal parts. Both the completion
// and the native chat/history contracts use this so an attachment-backed message
// round-trips through create/update/get without parse failures or lossy drops.
export const openWebUiMessageContentSchema = z.union([z.string(), z.array(openWebUiContentPartSchema)]);
// A single chat message exchanged with the model / persisted in a chat.
export const openWebUiChatMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiChatMessage = z.infer<typeof openWebUiChatMessageSchema>;
// A completion message can carry plain text or multimodal parts (text + images).
export const openWebUiCompletionMessageSchema = z.object({
role: z.enum(["system", "user", "assistant"]),
content: openWebUiMessageContentSchema,
});
export type OpenWebUiCompletionMessage = z.infer<typeof openWebUiCompletionMessageSchema>;
// GET /api/v1/knowledge/ — knowledge bases (RAG collections owned by the user).
// Newer Open WebUI versions paginate the list as `{ items, total }`; older ones
// return a bare array. Accept both and normalise in the integration.
export const openWebUiKnowledgeSchema = z.object({
id: z.string(),
name: z.string().nullish(),
description: z.string().nullish(),
});
export type OpenWebUiKnowledge = z.infer<typeof openWebUiKnowledgeSchema>;
export const openWebUiKnowledgeListSchema = z.union([
z.array(openWebUiKnowledgeSchema),
z.object({ items: z.array(openWebUiKnowledgeSchema) }),
]);
// A file stored in Open WebUI (uploaded directly or part of a knowledge base).
// `meta.collection_name` is the knowledge base id when the file belongs to one.
export const openWebUiFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z
.object({
name: z.string().nullish(),
content_type: z.string().nullish(),
collection_name: z.string().nullish(),
})
.nullish(),
});
export type OpenWebUiFile = z.infer<typeof openWebUiFileSchema>;
export const openWebUiFileListSchema = z.union([
z.array(openWebUiFileSchema),
z.object({ items: z.array(openWebUiFileSchema) }),
]);
// POST /api/v1/files/ — upload response (we only need the new file id + name).
export const openWebUiUploadedFileSchema = z.object({
id: z.string(),
filename: z.string().nullish(),
meta: z.object({ name: z.string().nullish() }).nullish(),
});
// GET /api/v1/knowledge/{id} — knowledge base with its files.
export const openWebUiKnowledgeDetailSchema = z.object({
id: z.string(),
name: z.string().nullish(),
files: z.array(openWebUiFileSchema).nullish(),
});
export type OpenWebUiKnowledgeDetail = z.infer<typeof openWebUiKnowledgeDetailSchema>;
// GET /api/v1/notes/ — user notes (markdown content under data.content.md).
export const openWebUiNoteSchema = z.object({
id: z.string(),
title: z.string().nullish(),
data: z.object({ content: z.object({ md: z.string().nullish() }).nullish() }).nullish(),
});
export const openWebUiNoteListSchema = z.union([
z.array(openWebUiNoteSchema),
z.object({ items: z.array(openWebUiNoteSchema) }),
]);
// A note flattened for the widget: id, title and its markdown body.
export interface OpenWebUiNote {
id: string;
title: string;
content: string;
}
// POST /api/v1/audio/transcriptions — speech-to-text result.
export const openWebUiTranscriptionSchema = z.object({ text: z.string() });
// A file flattened for the widget: id and a display name.
export interface OpenWebUiFileSummary {
id: string;
name: string;
}
// POST /api/v1/retrieval/process/web — ingests a URL into a temporary vector
// collection. We retrieve from that collection at send time and inject the
// results as context (Open WebUI does not fetch URLs at completion time).
export const openWebUiProcessWebResponseSchema = z.object({
collection_name: z.string(),
filename: z.string().nullish(),
file: z.object({ meta: z.object({ name: z.string().nullish() }).nullish() }).nullish(),
});
// POST /api/v1/retrieval/query/collection — top-k chunks per collection.
export const openWebUiCollectionQueryResponseSchema = z.object({
documents: z.array(z.array(z.string())).nullish(),
});
// A web page ingested for grounding: the collection to retrieve from + a label.
export interface OpenWebUiWebDocument {
collectionName: string;
title: string;
}
// GET /api/v1/chats/ — list of the user's chats (native Open WebUI shape).
export const openWebUiChatListItemSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
});
export type OpenWebUiChatListItem = z.infer<typeof openWebUiChatListItemSchema>;
// GET /api/v1/chats/{id} — full chat.
// Two message representations exist: a flat `chat.messages` list (used by chats
// we create) and a `chat.history` tree keyed by message id with a `currentId`
// leaf (used by chats created in the Open WebUI UI). The history tree is
// authoritative when present, since `chat.messages` can be stale/partial.
export const openWebUiHistoryMessageSchema = z.object({
id: z.string().nullish(),
parentId: z.string().nullish(),
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
});
export const openWebUiChatSchema = z.object({
id: z.string(),
title: z.string().nullish(),
updated_at: z.number().nullish(),
created_at: z.number().nullish(),
chat: z
.object({
title: z.string().nullish(),
models: z.array(z.string()).nullish(),
messages: z
.array(
z.object({
role: z.string().nullish(),
content: openWebUiMessageContentSchema.nullish(),
}),
)
.nullish(),
history: z
.object({
currentId: z.string().nullish(),
messages: z.record(z.string(), openWebUiHistoryMessageSchema).nullish(),
})
.nullish(),
})
.nullish(),
});
export type OpenWebUiChat = z.infer<typeof openWebUiChatSchema>;
// Body used when creating/updating a chat through the native API. `meta.tags`
// lets Homarr tag a conversation (e.g. with the owning Homarr user id) so it can
// be found back in Open WebUI even when an integration/account is shared.
export interface OpenWebUiChatPayload {
title: string;
models: string[];
messages: OpenWebUiChatMessage[];
meta?: { tags?: string[] };
}
@@ -0,0 +1,230 @@
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OpenWebUiIntegration } from "../open-webui-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_API_KEY = "test-api-key-12345";
const TEST_URL = "https://openwebui.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const asFetchResult = (response: Response) =>
response as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const setupMockFetch = (responses: Record<string, Record<string, unknown> | unknown[]>) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const path = new URL(urlString).pathname;
if (path in responses) {
return Promise.resolve(
asFetchResult(
new Response(JSON.stringify(responses[path]), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
}
return Promise.resolve(asFetchResult(new Response(JSON.stringify({ error: "Not Found" }), { status: 404 })));
});
};
const streamFrom = (chunks: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const createIntegration = () =>
new OpenWebUiIntegration({
id: "test-open-webui",
name: "Test Open WebUI",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
describe("OpenWebUiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getModelsAsync", () => {
test("parses the OpenAI-compatible model list", async () => {
setupMockFetch({
"/api/models": { data: [{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }] },
});
const result = await createIntegration().getModelsAsync();
expect(result).toEqual([{ id: "llama3" }, { id: "gpt-4o", name: "GPT-4o" }]);
expect(mockFetch).toHaveBeenCalled();
});
test("sends the bearer token", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const headers = mockFetch.mock.calls.at(0)?.[1]?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBe(`Bearer ${TEST_API_KEY}`);
});
test("throws when the API returns an error", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Unauthorized", { status: 401 })));
await expect(createIntegration().getModelsAsync()).rejects.toThrow();
});
});
describe("streamChatCompletionAsync", () => {
test("emits each content delta and resolves on [DONE]", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hel" } }] })}\n`,
`data: ${JSON.stringify({ choices: [{ delta: { content: "lo" } }] })}\n`,
"data: [DONE]\n",
]),
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas).toEqual(["Hel", "lo"]);
});
test("handles a delta split across stream chunks", async () => {
const payload = JSON.stringify({ choices: [{ delta: { content: "world" } }] });
const half = Math.floor(payload.length / 2);
mockFetch.mockResolvedValue(
asFetchResult(
new Response(streamFrom([`data: ${payload.slice(0, half)}`, `${payload.slice(half)}\n`, "data: [DONE]\n"]), {
status: 200,
}),
),
);
const deltas: string[] = [];
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
);
expect(deltas.join("")).toBe("world");
});
test("throws when the completion endpoint errors", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response("Bad Gateway", { status: 502 })));
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
),
).rejects.toThrow();
});
test("flushes the final SSE frame when it has no trailing newline", async () => {
mockFetch.mockResolvedValue(
asFetchResult(
new Response(
streamFrom([
`data: ${JSON.stringify({ choices: [{ delta: { content: "done" } }] })}\n`,
// Final event: no trailing newline and ends via finish_reason (no [DONE]).
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`,
]),
{ status: 200 },
),
),
);
const deltas: string[] = [];
await expect(
createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
(delta) => deltas.push(delta),
new AbortController().signal,
),
).resolves.toBeUndefined();
expect(deltas.join("")).toBe("done");
});
});
describe("requestAsync timeouts", () => {
test("applies a default timeout to non-streaming requests", async () => {
setupMockFetch({ "/api/models": { data: [] } });
await createIntegration().getModelsAsync();
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeGreaterThan(0);
});
test("does not time out the long-lived streaming completion", async () => {
mockFetch.mockResolvedValue(asFetchResult(new Response(streamFrom(["data: [DONE]\n"]), { status: 200 })));
await createIntegration().streamChatCompletionAsync(
{ model: "llama3", messages: [{ role: "user", content: "Hi" }] },
() => undefined,
new AbortController().signal,
);
const options = mockFetch.mock.calls.at(0)?.[1] as { timeout?: number } | undefined;
expect(options?.timeout).toBeUndefined();
});
});
describe("getChatAsync", () => {
test("parses a chat whose message content is multimodal (text + image parts)", async () => {
setupMockFetch({
"/api/v1/chats/abc": {
id: "abc",
chat: {
title: "Vision",
models: ["llava"],
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is this?" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
{ role: "assistant", content: "A cat." },
],
},
},
});
const chat = await createIntegration().getChatAsync("abc");
expect(chat.id).toBe("abc");
expect(chat.chat?.messages?.[0]?.content).toBeDefined();
});
});
});
@@ -153,9 +153,9 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
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> => {
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 {
@@ -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") {
const series = (await response.json()) as TvInformation;
return {
@@ -3,54 +3,6 @@ interface MediaContainer {
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 {
User?: {
$: {
@@ -63,20 +15,13 @@ interface Session {
$: {
product: string;
title: string;
address?: string;
state?: string;
local?: string;
};
}[];
Session?: {
$: {
id: string;
bandwidth?: string;
location?: string;
};
}[];
TranscodeSession?: TranscodeSession[];
Media?: Media[];
$: {
grandparentTitle?: string;
parentTitle?: string;
@@ -84,8 +29,6 @@ interface Session {
index?: number;
type: string;
live?: string;
viewOffset?: string;
duration?: string;
};
}
@@ -17,32 +17,6 @@ import type { PlexResponse } from "./interface";
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 {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
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 playerElement = mediaElement.Player ? mediaElement.Player[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) {
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 {
sessionId: sessionElement?.$.id ?? "unknown",
sessionName: `${playerElement.$.product} (${playerElement.$.title})`,
@@ -102,30 +63,7 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
episodeName: mediaElement.$.title ?? null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null,
playback: {
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,
},
},
},
metadata: null,
},
};
})
+1
View File
@@ -26,4 +26,5 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./open-webui/open-webui-types";
export * from "./traefik/traefik-types";
@@ -1,6 +1,7 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici/types/fetch";
import { humanFileSize } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
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
fileSystem: systemInfo.array.disks.map((disk) => ({
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)
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 = {
"mediaRequests-requestList": {
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
statusFilter: () => undefined,
recentDays: () => undefined,
},
"mediaRequests-requestStats": {},
bookmarks: {
+17 -4
View File
@@ -300,7 +300,10 @@ async function getContainersWithStatsAsync() {
const stats = await instance
.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(
() =>
({
@@ -336,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
}
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 (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
return (cpuDelta / systemDelta) * numberOfCpus * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId?: string }
{ albumId: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more