chore(release): automatic release v1.38.0

This commit is contained in:
homarr-releases[bot]
2025-09-19 19:13:45 +00:00
committed by GitHub
109 changed files with 1730 additions and 1375 deletions

View File

@@ -33,6 +33,7 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.37.0
- 1.36.1 - 1.36.1
- 1.36.0 - 1.36.0
- 1.35.1 - 1.35.1

View File

@@ -3,6 +3,8 @@ on:
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
permissions: {}
jobs: jobs:
approve-automatic-prs: approve-automatic-prs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -12,10 +14,12 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write # required to approve pull request
- name: Install GitHub CLI - name: Install GitHub CLI
run: sudo apt-get install -y gh run: sudo apt-get install -y gh
- name: Approve automatic PRs - name: Approve automatic PRs

View File

@@ -5,6 +5,9 @@ on:
schedule: schedule:
- cron: "0 0 * * *" # every day at midnight - cron: "0 0 * * *" # every day at midnight
permissions:
contents: read # required for code checkout
jobs: jobs:
download-crowdin-translations: download-crowdin-translations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -15,10 +18,12 @@ jobs:
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }} private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
app_id: ${{ secrets.CROWDIN_APP_ID }} app-id: ${{ secrets.CROWDIN_APP_ID }}
permission-contents: write # required to commit to crowdin branch
permission-pull-requests: write # required to create pull request
- name: Download Crowdin translations - name: Download Crowdin translations
id: crowdin-download id: crowdin-download

View File

@@ -49,18 +49,23 @@ jobs:
- name: Obtain token - name: Obtain token
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }} app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit package.json & changelog changes, merge them to dev and publish the release
- uses: actions/checkout@v5 - uses: actions/checkout@v5
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v4 - uses: pnpm/action-setup@v4
if: env.SKIP_RELEASE == 'false'
- uses: actions/setup-node@v5
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
node-version: 22.19.0 node-version: 22.19.0
cache: "pnpm"
- run: npm i -g pnpm - run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
- name: Install dependencies - name: Install dependencies

View File

@@ -49,10 +49,11 @@ jobs:
args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})" args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})"
- name: Obtain token - name: Obtain token
id: obtainApprovalToken id: obtainApprovalToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }}
permission-pull-requests: write
- name: Approve PR - name: Approve PR
env: env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
@@ -60,10 +61,12 @@ jobs:
gh pr review --approve --body "Automatically approved by GitHub Action" gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token - name: Obtain token
id: obtainMergeToken id: obtainMergeToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }} app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # write to main branch (due to merge)
permission-pull-requests: write # merge pull request
- id: automerge - id: automerge
if: ${{ steps.semver.outputs.bump != 'major' }} if: ${{ steps.semver.outputs.bump != 'major' }}
name: automerge name: automerge

View File

@@ -11,12 +11,14 @@ jobs:
steps: steps:
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }} private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }}
app_id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }} app-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }}
installation_retrieval_mode: repository owner: homarr-labs
installation_retrieval_payload: homarr-labs/documentation repositories: |
documentation
permission-contents: write # required to dispatch repository workflow
- name: Trigger documentation release - name: Trigger documentation release
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
@@ -40,10 +42,12 @@ jobs:
steps: steps:
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }}
app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }} app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
@@ -57,6 +61,7 @@ jobs:
run: | run: |
git config --global user.email "175486441+homarr-releases[bot]@users.noreply.github.com" git config --global user.email "175486441+homarr-releases[bot]@users.noreply.github.com"
git config --global user.name "Releases Homarr" git config --global user.name "Releases Homarr"
git checkout -b update-bug-report-template
git add . git add .
git commit -m "chore: update bug report template" git commit -m "chore: update bug report template"

View File

@@ -3,11 +3,11 @@ permissions:
on: on:
pull_request: pull_request:
branches-ignore: "renovate/*"
types: [opened, synchronize] types: [opened, synchronize]
jobs: jobs:
skip-stability-days: skip-stability-days:
if: ${{ !startsWith(github.head_ref, 'renovate/') }}
name: Skip Stability Days name: Skip Stability Days
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -9,9 +9,6 @@ env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}" CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}"
permissions:
contents: write
jobs: jobs:
update-contributors: update-contributors:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -21,20 +18,24 @@ jobs:
steps: steps:
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Run update script - name: Run update script
run: node ./scripts/update-contributors.mjs run: node ./scripts/update-contributors.mjs

View File

@@ -1,7 +1,7 @@
name: Update integration list name: Update integration list
on: on:
workflow_dispatch: { } workflow_dispatch: {}
push: push:
paths: paths:
- packages/definitions/src/integration.ts - packages/definitions/src/integration.ts
@@ -20,10 +20,12 @@ jobs:
steps: steps:
- name: Obtain token - name: Obtain token
id: obtainToken id: obtainToken
uses: tibdex/github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} app-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write # required to commit to branch
permission-pull-requests: write # required to create pr & enable automerge
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
env: env:
@@ -59,4 +61,4 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: | run: |
gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash

View File

@@ -50,17 +50,17 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.2.8", "@mantine/colors-generator": "^8.3.1",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@mantine/dropzone": "^8.2.8", "@mantine/dropzone": "^8.3.1",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.1",
"@mantine/modals": "^8.2.8", "@mantine/modals": "^8.3.1",
"@mantine/tiptap": "^8.2.8", "@mantine/tiptap": "^8.3.1",
"@million/lint": "1.0.14", "@million/lint": "1.0.14",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"@tanstack/react-query": "^5.87.1", "@tanstack/react-query": "^5.87.4",
"@tanstack/react-query-devtools": "^5.87.1", "@tanstack/react-query-devtools": "^5.87.4",
"@tanstack/react-query-next-experimental": "^5.87.1", "@tanstack/react-query-next-experimental": "^5.87.4",
"@trpc/client": "^11.5.1", "@trpc/client": "^11.5.1",
"@trpc/next": "^11.5.1", "@trpc/next": "^11.5.1",
"@trpc/react-query": "^11.5.1", "@trpc/react-query": "^11.5.1",
@@ -74,9 +74,9 @@
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"jotai": "^2.13.1", "jotai": "^2.14.0",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.2", "next": "15.5.3",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "19.1.1", "react": "19.1.1",
@@ -85,18 +85,18 @@
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.92.1", "sass": "^1.92.1",
"superjson": "2.2.2", "superjson": "2.2.2",
"swagger-ui-react": "^5.28.1", "swagger-ui-react": "^5.29.0",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1", "@types/chroma-js": "3.1.1",
"@types/node": "^22.18.1", "@types/node": "^22.18.3",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.1.12", "@types/react": "19.1.13",
"@types/react-dom": "19.1.9", "@types/react-dom": "19.1.9",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",

View File

@@ -2,6 +2,7 @@ import {
IconCode, IconCode,
IconGrid3x3, IconGrid3x3,
IconKey, IconKey,
IconLink,
IconMessage, IconMessage,
IconPassword, IconPassword,
IconPasswordUser, IconPasswordUser,
@@ -21,6 +22,7 @@ export const integrationSecretIcons = {
tokenId: IconGrid3x3, tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser, personalAccessToken: IconPasswordUser,
topic: IconMessage, topic: IconMessage,
url: IconLink,
opnsenseApiKey: IconKey, opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword, opnsenseApiSecret: IconPassword,
githubAppId: IconCode, githubAppId: IconCode,

View File

@@ -33,6 +33,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
integration.secrets.every((secret) => secretKinds.includes(secret.kind)), integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind); ) ?? getDefaultSecretKinds(integration.kind);
const hasUrlSecret = secretsKinds.includes("url");
const router = useRouter(); const router = useRouter();
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), { const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
initialValues: { initialValues: {
@@ -50,10 +52,14 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
await mutateAsync( await mutateAsync(
{ {
id: integration.id, id: integration.id,
...values, ...values,
url,
secrets: values.secrets.map((secret) => ({ secrets: values.secrets.map((secret) => ({
kind: secret.kind, kind: secret.kind,
value: secret.value === "" ? null : secret.value, value: secret.value === "" ? null : secret.value,
@@ -92,7 +98,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} /> <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">

View File

@@ -55,12 +55,19 @@ const formSchema = integrationCreateSchema.omit({ kind: true }).and(
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter(); const router = useRouter();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
if (hasUrlSecret) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost";
}
const form = useZodForm(formSchema, { const form = useZodForm(formSchema, {
initialValues: { initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind), name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "", url,
secrets: secretKinds[0].map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
value: "", value: "",
@@ -83,10 +90,14 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null); const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
const url = hasUrlSecret
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url;
await createIntegrationAsync( await createIntegrationAsync(
{ {
kind: searchParams.kind, kind: searchParams.kind,
...values, ...values,
url,
}, },
{ {
async onSuccess(data) { async onSuccess(data) {
@@ -114,10 +125,10 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
await createAppAsync( await createAppAsync(
{ {
name: values.name, name: values.name,
href: hasCustomHref ? values.appHref : values.url, href: hasCustomHref ? values.appHref : url,
iconUrl: getIconUrl(searchParams.kind), iconUrl: getIconUrl(searchParams.kind),
description: null, description: null,
pingUrl: values.url, pingUrl: url,
}, },
{ {
async onSettled() { async onSettled() {
@@ -149,7 +160,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
<Stack> <Stack>
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} /> <TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">

View File

@@ -17,6 +17,10 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import { IconPowerOff } from "@homarr/ui/icons"; import { IconPowerOff } from "@homarr/ui/icons";
const cronExpressions = [ const cronExpressions = [
{
value: "*/1 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 1 }),
},
{ {
value: "*/5 * * * * *", value: "*/5 * * * * *",
label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 5 }), label: (t: TranslationFunction) => t("management.page.tool.tasks.interval.seconds", { interval: 5 }),

View File

@@ -41,13 +41,13 @@
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"fastify": "^5.6.0", "fastify": "^5.6.0",
"superjson": "2.2.2", "superjson": "2.2.2",
"undici": "7.15.0" "undici": "7.16.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.18.1", "@types/node": "^22.18.3",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^10.0.0",
"esbuild": "^0.25.9", "esbuild": "^0.25.9",
"eslint": "^9.35.0", "eslint": "^9.35.0",

View File

@@ -30,6 +30,8 @@
</a> </a>
</p> </p>
![](img/screenshot.png)
![](img/headers/features.png) ![](img/headers/features.png)
- 🖌️ Highly customizable with an extensive drag and drop grid system - 🖌️ Highly customizable with an extensive drag and drop grid system
@@ -132,6 +134,13 @@
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
<br/>
<p align="center">iCal</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/jellyfin" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/jellyfin" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg" alt="Jellyfin" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg" alt="Jellyfin" width="90" height="90" />
<br/> <br/>
@@ -144,15 +153,15 @@
<br/> <br/>
<p align="center">Jellyseerr</p> <p align="center">Jellyseerr</p>
</a> </a>
</td> </td></tr>
<td align="center"> <tr><td align="center">
<a href="https://homarr.dev/docs/integrations/lidarr" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/lidarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg" alt="Lidarr" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg" alt="Lidarr" width="90" height="90" />
<br/> <br/>
<p align="center">Lidarr</p> <p align="center">Lidarr</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
<br/> <br/>
@@ -193,15 +202,15 @@
<br/> <br/>
<p align="center">OpenMediaVault</p> <p align="center">OpenMediaVault</p>
</a> </a>
</td> </td></tr>
<td align="center"> <tr><td align="center">
<a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" />
<br/> <br/>
<p align="center">OPNsense</p> <p align="center">OPNsense</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/overseerr" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/overseerr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg" alt="Overseerr" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg" alt="Overseerr" width="90" height="90" />
<br/> <br/>
@@ -242,15 +251,15 @@
<br/> <br/>
<p align="center">qBittorrent</p> <p align="center">qBittorrent</p>
</a> </a>
</td> </td></tr>
<td align="center"> <tr><td align="center">
<a href="https://homarr.dev/docs/integrations/quay" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/quay" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png" alt="Quay" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png" alt="Quay" width="90" height="90" />
<br/> <br/>
<p align="center">Quay</p> <p align="center">Quay</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
<br/> <br/>
@@ -291,15 +300,15 @@
<br/> <br/>
<p align="center">Transmission</p> <p align="center">Transmission</p>
</a> </a>
</td> </td></tr>
<td align="center"> <tr><td align="center">
<a href="https://homarr.dev/docs/integrations/truenas" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/truenas" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg" alt="TrueNAS" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg" alt="TrueNAS" width="90" height="90" />
<br/> <br/>
<p align="center">TrueNAS</p> <p align="center">TrueNAS</p>
</a> </a>
</td></tr> </td>
<tr><td align="center"> <td align="center">
<a href="https://homarr.dev/docs/integrations/unifi-controller" target="_blank" rel="noreferrer noopener"> <a href="https://homarr.dev/docs/integrations/unifi-controller" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png" alt="Unifi Controller" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png" alt="Unifi Controller" width="90" height="90" />
<br/> <br/>

BIN
docs/img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

View File

@@ -39,7 +39,7 @@
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.5", "@semantic-release/github": "^11.0.6",
"@semantic-release/npm": "^12.0.2", "@semantic-release/npm": "^12.0.2",
"@semantic-release/release-notes-generator": "^14.1.0", "@semantic-release/release-notes-generator": "^14.1.0",
"@testcontainers/redis": "^11.5.1", "@testcontainers/redis": "^11.5.1",
@@ -49,7 +49,7 @@
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"conventional-changelog-conventionalcommits": "^9.1.0", "conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"jsdom": "^26.1.0", "jsdom": "^27.0.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"semantic-release": "^24.2.8", "semantic-release": "^24.2.8",
"testcontainers": "^11.5.1", "testcontainers": "^11.5.1",
@@ -58,7 +58,7 @@
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"packageManager": "pnpm@10.15.1", "packageManager": "pnpm@10.16.1",
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
}, },
@@ -77,16 +77,17 @@
"overrides": { "overrides": {
"@babel/helpers@<7.26.10": ">=7.28.4", "@babel/helpers@<7.26.10": ">=7.28.4",
"@babel/runtime@<7.26.10": ">=7.28.4", "@babel/runtime@<7.26.10": ">=7.28.4",
"axios@>=1.0.0 <1.8.2": ">=1.12.1", "axios@>=1.0.0 <1.8.2": ">=1.12.2",
"brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1", "brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1",
"brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1", "brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1",
"esbuild@<=0.24.2": ">=0.25.9", "esbuild@<=0.24.2": ">=0.25.9",
"form-data@>=4.0.0 <4.0.4": ">=4.0.4", "form-data@>=4.0.0 <4.0.4": ">=4.0.4",
"hono@<4.6.5": ">=4.9.6", "hono@<4.6.5": ">=4.9.7",
"linkifyjs@<4.3.2": ">=4.3.2", "linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.5", "nanoid@>=4.0.0 <5.0.9": ">=5.1.5",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.15.0", "proxmox-api>undici": "7.16.0",
"react-is": "^19.1.1",
"rollup@>=4.0.0 <4.22.4": ">=4.50.1", "rollup@>=4.0.0 <4.22.4": ">=4.50.1",
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.0", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.0",

View File

@@ -42,18 +42,18 @@
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.3.0", "@kubernetes/client-node": "^1.3.0",
"@tanstack/react-query": "^5.87.1", "@tanstack/react-query": "^5.87.4",
"@trpc/client": "^11.5.1", "@trpc/client": "^11.5.1",
"@trpc/react-query": "^11.5.1", "@trpc/react-query": "^11.5.1",
"@trpc/server": "^11.5.1", "@trpc/server": "^11.5.1",
"@trpc/tanstack-react-query": "^11.5.1", "@trpc/tanstack-react-query": "^11.5.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"superjson": "2.2.2", "superjson": "2.2.2",
"trpc-to-openapi": "^3.0.1", "trpc-to-openapi": "^3.0.1",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -35,11 +35,11 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "8.0.9", "ldapts": "8.0.9",
"next": "15.5.2", "next": "15.5.3",
"next-auth": "5.0.0-beta.29", "next-auth": "5.0.0-beta.29",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"undici": "7.15.0" "undici": "7.16.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -32,12 +32,12 @@
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"dns-caching": "^0.2.5", "dns-caching": "^0.2.5",
"next": "15.5.2", "next": "15.5.3",
"octokit": "^5.0.3", "octokit": "^5.0.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"undici": "7.15.0", "undici": "7.16.0",
"zod": "^4.1.5", "zod": "^4.1.8",
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -12,7 +12,11 @@ export class LoggingAgent extends Agent {
} }
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean { dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const url = new URL(`${options.origin as string}${options.path}`); const path = options.path
.split("/")
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
.join("/");
const url = new URL(`${options.origin as string}${path}`);
// The below code should prevent sensitive data from being logged as // The below code should prevent sensitive data from being logged as
// some integrations use query parameters for auth // some integrations use query parameters for auth

View File

@@ -66,6 +66,7 @@ describe("LoggingAgent should log all requests", () => {
["/?one=a1&two=b2&three=c3", `/?one=${REDACTED}&two=${REDACTED}&three=${REDACTED}`], ["/?one=a1&two=b2&three=c3", `/?one=${REDACTED}&two=${REDACTED}&three=${REDACTED}`],
["/?numberWith13Chars=1234567890123", `/?numberWith13Chars=${REDACTED}`], ["/?numberWith13Chars=1234567890123", `/?numberWith13Chars=${REDACTED}`],
[`/?stringWith13Chars=${"a".repeat(13)}`, `/?stringWith13Chars=${REDACTED}`], [`/?stringWith13Chars=${"a".repeat(13)}`, `/?stringWith13Chars=${REDACTED}`],
[`/${"a".repeat(32)}/?param=123`, `/${REDACTED}/?param=123`],
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => { ])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
// Arrange // Arrange
const infoLogSpy = vi.spyOn(logger, "debug"); const infoLogSpy = vi.spyOn(logger, "debug");

View File

@@ -26,7 +26,7 @@
"dependencies": { "dependencies": {
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"ioredis": "5.7.0", "ioredis": "5.7.0",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -15,6 +15,7 @@ export const createRedisClient = () =>
...defaultRedisOptions, ...defaultRedisOptions,
host: redisEnv.HOST, host: redisEnv.HOST,
port: redisEnv.PORT, port: redisEnv.PORT,
db: redisEnv.DATABASE_INDEX,
tls: redisEnv.TLS_CA tls: redisEnv.TLS_CA
? { ? {
ca: redisEnv.TLS_CA, ca: redisEnv.TLS_CA,

View File

@@ -12,6 +12,7 @@ export const redisEnv = createEnv({
TLS_CA: z.string().optional(), TLS_CA: z.string().optional(),
USERNAME: z.string().optional(), USERNAME: z.string().optional(),
PASSWORD: z.string().optional(), PASSWORD: z.string().optional(),
DATABASE_INDEX: z.coerce.number().optional(),
}, },
runtimeEnv: runtimeEnvWithPrefix("REDIS_"), runtimeEnv: runtimeEnvWithPrefix("REDIS_"),
}); });

View File

@@ -29,20 +29,20 @@
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0", "@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@tanstack/react-query": "^5.87.1", "@tanstack/react-query": "^5.87.4",
"@trpc/client": "^11.5.1", "@trpc/client": "^11.5.1",
"@trpc/server": "^11.5.1", "@trpc/server": "^11.5.1",
"@trpc/tanstack-react-query": "^11.5.1", "@trpc/tanstack-react-query": "^11.5.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"react": "19.1.1", "react": "19.1.1",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "19.1.12", "@types/react": "19.1.13",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"typescript": "^5.9.2" "typescript": "^5.9.2"
} }

View File

@@ -49,7 +49,7 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.5.1", "@testcontainers/mysql": "^11.5.1",
"@testcontainers/postgresql": "^11.5.1", "@testcontainers/postgresql": "^11.5.1",
@@ -58,7 +58,7 @@
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"mysql2": "3.14.4", "mysql2": "3.14.5",
"pg": "^8.16.3", "pg": "^8.16.3",
"superjson": "2.2.2" "superjson": "2.2.2"
}, },

View File

@@ -25,7 +25,7 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.2.5",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -183,6 +183,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/sonarr" | "/docs/integrations/sonarr"
| "/docs/integrations/tdarr" | "/docs/integrations/tdarr"
| "/docs/integrations/transmission" | "/docs/integrations/transmission"
| "/docs/integrations/truenas"
| "/docs/integrations/unifi-controller" | "/docs/integrations/unifi-controller"
| "/docs/management/api" | "/docs/management/api"
| "/docs/management/apps" | "/docs/management/apps"

View File

@@ -13,6 +13,7 @@ export const integrationSecretKindObject = {
topic: { isPublic: true, multiline: false }, topic: { isPublic: true, multiline: false },
opnsenseApiKey: { isPublic: false, multiline: false }, opnsenseApiKey: { isPublic: false, multiline: false },
opnsenseApiSecret: { isPublic: false, multiline: false }, opnsenseApiSecret: { isPublic: false, multiline: false },
url: { isPublic: false, multiline: false },
privateKey: { isPublic: false, multiline: true }, privateKey: { isPublic: false, multiline: true },
githubAppId: { isPublic: true, multiline: false }, githubAppId: { isPublic: true, multiline: false },
githubInstallationId: { isPublic: true, multiline: false }, githubInstallationId: { isPublic: true, multiline: false },
@@ -283,6 +284,13 @@ export const integrationDefs = {
category: ["notifications"], category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
}, },
ical: {
name: "iCal",
secretKinds: [["url"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
},
truenas: { truenas: {
name: "TrueNAS", name: "TrueNAS",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],

View File

@@ -26,7 +26,7 @@
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"dockerode": "^4.0.7" "dockerode": "^4.0.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -26,9 +26,9 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.2.8", "@mantine/form": "^8.3.1",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -29,9 +29,9 @@
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"react": "19.1.1", "react": "19.1.1",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -41,14 +41,15 @@
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0", "@jellyfin/sdk": "^0.11.0",
"@octokit/auth-app": "^8.1.0", "@octokit/auth-app": "^8.1.0",
"ical.js": "^2.2.1",
"maria2": "^0.4.1", "maria2": "^0.4.1",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",
"octokit": "^5.0.3", "octokit": "^5.0.3",
"proxmox-api": "1.1.1", "proxmox-api": "1.1.1",
"tsdav": "^2.1.5", "tsdav": "^2.1.5",
"undici": "7.15.0", "undici": "7.16.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -18,6 +18,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
import { GithubIntegration } from "../github/github-integration"; import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration"; import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration"; import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
@@ -112,6 +113,7 @@ export const integrationCreators = {
codeberg: CodebergIntegration, codeberg: CodebergIntegration,
linuxServerIO: LinuxServerIOIntegration, linuxServerIO: LinuxServerIOIntegration,
gitHubContainerRegistry: GitHubContainerRegistryIntegration, gitHubContainerRegistry: GitHubContainerRegistryIntegration,
ical: ICalIntegration,
quay: QuayIntegration, quay: QuayIntegration,
ntfy: NTFYIntegration, ntfy: NTFYIntegration,
mock: MockIntegration, mock: MockIntegration,

View File

@@ -0,0 +1,67 @@
import ICAL from "ical.js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
export class ICalIntegration extends Integration implements ICalendarIntegration {
async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
const result = await response.text();
const jcal = ICAL.parse(result) as unknown[];
const comp = new ICAL.Component(jcal);
return comp.getAllSubcomponents("vevent").reduce((prev, vevent) => {
const event = new ICAL.Event(vevent);
const startDate = event.startDate.toJSDate();
const endDate = event.endDate.toJSDate();
if (startDate > end) return prev;
if (endDate < start) return prev;
return prev.concat({
title: event.summary,
subTitle: null,
description: event.description,
startDate,
endDate,
image: null,
location: event.location,
indicatorColor: "red",
links: [],
});
}, [] as CalendarEvent[]);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(super.getSecretValue("url"));
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.text();
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const jcal = ICAL.parse(result);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const comp = new ICAL.Component(jcal);
return comp.getAllSubcomponents("vevent").length > 0
? { success: true }
: TestConnectionError.ParseResult({
name: "Calendar parse error",
message: "No events found",
cause: new Error("No events found"),
});
} catch (error) {
return TestConnectionError.ParseResult({
name: "Calendar parse error",
message: "Failed to parse calendar",
cause: error as Error,
});
}
}
}

View File

@@ -23,6 +23,7 @@ export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration"; export { TrueNasIntegration } from "./truenas/truenas-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration";
// Types // Types
export type { IntegrationInput } from "./base/integration"; export type { IntegrationInput } from "./base/integration";

View File

@@ -1,24 +1,41 @@
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const; export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number]; export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
export interface CalendarEvent { export interface RadarrMetadata {
name: string; type: "radarr";
subName: string; releaseType: RadarrReleaseType;
date: Date; }
dates?: { type: RadarrReleaseType; date: Date }[];
description?: string; export type CalendarMetadata = RadarrMetadata;
thumbnail?: string;
mediaInformation?: { export interface CalendarLink {
type: "audio" | "video" | "tv" | "movie"; name: string;
seasonNumber?: number; isDark: boolean;
episodeNumber?: number; href: string;
}; color?: string;
links: { logo?: string;
href: string; }
name: string;
color: string | undefined; export interface CalendarImageBadge {
notificationColor?: string | undefined; content: string;
isDark: boolean | undefined; color: string;
logo: string; }
}[];
export interface CalendarImage {
src: string;
badge?: CalendarImageBadge;
aspectRatio?: { width: number; height: number };
}
export interface CalendarEvent {
title: string;
subTitle: string | null;
description: string | null;
startDate: Date;
endDate: Date | null;
image: CalendarImage | null;
location: string | null;
metadata?: CalendarMetadata;
indicatorColor: string;
links: CalendarLink[];
} }

View File

@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types"; import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer"; import { mediaOrganizerPriorities } from "../media-organizer";
export class LidarrIntegration extends Integration implements ICalendarIntegration { export class LidarrIntegration extends Integration implements ICalendarIntegration {
@@ -44,22 +44,28 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json()); const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => { return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => {
const imageSrc = this.chooseBestImage(lidarrCalendarEvent);
return { return {
name: lidarrCalendarEvent.title, title: lidarrCalendarEvent.title,
subName: lidarrCalendarEvent.artist.artistName, subTitle: lidarrCalendarEvent.artist.artistName,
description: lidarrCalendarEvent.overview, description: lidarrCalendarEvent.overview ?? null,
thumbnail: this.chooseBestImageAsURL(lidarrCalendarEvent), startDate: lidarrCalendarEvent.releaseDate,
date: lidarrCalendarEvent.releaseDate, endDate: null,
mediaInformation: { image: imageSrc
type: "audio", ? {
}, src: imageSrc.remoteUrl,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
indicatorColor: "cyan",
links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent), links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent),
}; };
}); });
} }
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => { private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = []; const links: CalendarLink[] = [];
for (const link of event.artist.links) { for (const link of event.artist.links) {
switch (link.name) { switch (link.name) {
@@ -70,7 +76,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/vgmdb.svg", logo: "/images/apps/vgmdb.svg",
notificationColor: "cyan",
}); });
break; break;
case "imdb": case "imdb":
@@ -80,7 +85,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/imdb.png", logo: "/images/apps/imdb.png",
notificationColor: "cyan",
}); });
break; break;
case "last": case "last":
@@ -90,7 +94,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
color: "#cf222a", color: "#cf222a",
isDark: false, isDark: false,
logo: "/images/apps/lastfm.svg", logo: "/images/apps/lastfm.svg",
notificationColor: "cyan",
}); });
break; break;
} }

View File

@@ -1,15 +1,14 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration"; import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types"; import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types"; import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer"; import { mediaOrganizerPriorities } from "../media-organizer";
@@ -34,33 +33,44 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
}); });
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json()); const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
return radarrCalendarEvents.map((radarrCalendarEvent): CalendarEvent => { return radarrCalendarEvents.flatMap((radarrCalendarEvent): CalendarEvent[] => {
const dates = radarrReleaseTypes const imageSrc = this.chooseBestImageAsURL(radarrCalendarEvent);
.map((type) => (radarrCalendarEvent[type] ? { type, date: radarrCalendarEvent[type] } : undefined))
.filter((date) => date) as AtLeastOneOf<Exclude<CalendarEvent["dates"], undefined>[number]>; return radarrReleaseTypes
return { .map((releaseType) => ({ type: releaseType, date: radarrCalendarEvent[releaseType] }))
name: radarrCalendarEvent.title, .filter((item) => item.date !== undefined)
subName: radarrCalendarEvent.originalTitle, .map((item) => ({
description: radarrCalendarEvent.overview, title: radarrCalendarEvent.title,
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent), subTitle: radarrCalendarEvent.originalTitle,
date: dates[0].date, description: radarrCalendarEvent.overview ?? null,
dates, // Check is done above in the filter
mediaInformation: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: "movie", startDate: item.date!,
}, endDate: null,
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent), image: imageSrc
}; ? {
src: imageSrc,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
metadata: {
type: "radarr",
releaseType: item.type,
},
indicatorColor: "yellow",
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
}));
}); });
} }
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => { private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [ const links: CalendarLink[] = [
{ {
href: this.url(`/movie/${event.titleSlug}`).toString(), href: this.url(`/movie/${event.titleSlug}`).toString(),
name: "Radarr", name: "Radarr",
logo: "/images/apps/radarr.svg", logo: "/images/apps/radarr.svg",
color: undefined, color: undefined,
notificationColor: "yellow",
isDark: true, isDark: true,
}, },
]; ];

View File

@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types"; import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer"; import { mediaOrganizerPriorities } from "../media-organizer";
export class ReadarrIntegration extends Integration implements ICalendarIntegration { export class ReadarrIntegration extends Integration implements ICalendarIntegration {
@@ -50,15 +50,22 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json()); const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => { return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => {
const imageSrc = this.chooseBestImageAsURL(readarrCalendarEvent);
return { return {
name: readarrCalendarEvent.title, title: readarrCalendarEvent.title,
subName: readarrCalendarEvent.author.authorName, subTitle: readarrCalendarEvent.author.authorName,
description: readarrCalendarEvent.overview, description: readarrCalendarEvent.overview ?? null,
thumbnail: this.chooseBestImageAsURL(readarrCalendarEvent), startDate: readarrCalendarEvent.releaseDate,
date: readarrCalendarEvent.releaseDate, endDate: null,
mediaInformation: { image: imageSrc
type: "audio", ? {
}, src: imageSrc,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
indicatorColor: "#f5c518",
links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent), links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent),
}; };
}); });
@@ -72,9 +79,8 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
isDark: false, isDark: false,
logo: "/images/apps/readarr.svg", logo: "/images/apps/readarr.svg",
name: "Readarr", name: "Readarr",
notificationColor: "#f5c518",
}, },
] satisfies CalendarEvent["links"]; ] satisfies CalendarLink[];
}; };
private chooseBestImage = ( private chooseBestImage = (

View File

@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error"; import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration"; import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types"; import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer"; import { mediaOrganizerPriorities } from "../media-organizer";
export class SonarrIntegration extends Integration implements ICalendarIntegration { export class SonarrIntegration extends Integration implements ICalendarIntegration {
@@ -33,33 +33,36 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
"X-Api-Key": super.getSecretValue("apiKey"), "X-Api-Key": super.getSecretValue("apiKey"),
}, },
}); });
const sonarCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json()); const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
return sonarCalendarEvents.map( return sonarrCalendarEvents.map((event): CalendarEvent => {
(sonarCalendarEvent): CalendarEvent => ({ const imageSrc = this.chooseBestImageAsURL(event);
name: sonarCalendarEvent.title, return {
subName: sonarCalendarEvent.series.title, title: event.title,
description: sonarCalendarEvent.series.overview, subTitle: event.series.title,
thumbnail: this.chooseBestImageAsURL(sonarCalendarEvent), description: event.series.overview ?? null,
date: sonarCalendarEvent.airDateUtc, startDate: event.airDateUtc,
mediaInformation: { endDate: null,
type: "tv", image: imageSrc
episodeNumber: sonarCalendarEvent.episodeNumber, ? {
seasonNumber: sonarCalendarEvent.seasonNumber, src: imageSrc,
}, aspectRatio: { width: 7, height: 12 },
links: this.getLinksForSonarCalendarEvent(sonarCalendarEvent), }
}), : null,
); location: null,
indicatorColor: "blue",
links: this.getLinksForSonarrCalendarEvent(event),
};
});
} }
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => { private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [ const links: CalendarLink[] = [
{ {
href: this.url(`/series/${event.series.titleSlug}`).toString(), href: this.url(`/series/${event.series.titleSlug}`).toString(),
name: "Sonarr", name: "Sonarr",
logo: "/images/apps/sonarr.svg", logo: "/images/apps/sonarr.svg",
color: undefined, color: undefined,
notificationColor: "blue",
isDark: true, isDark: true,
}, },
]; ];

View File

@@ -8,32 +8,46 @@ export class CalendarMockService implements ICalendarIntegration {
} }
} }
const homarrMeetup = (start: Date, end: Date): CalendarEvent => ({ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
name: "Homarr Meetup", const startDate = randomDateBetween(start, end);
subName: "", const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
description: "Yearly meetup of the Homarr community", return {
date: randomDateBetween(start, end), title: "Homarr Meetup",
links: [ subTitle: "",
{ description: "Yearly meetup of the Homarr community",
href: "https://homarr.dev", startDate,
name: "Homarr", endDate,
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", image: null,
color: "#000000", location: "Mountains",
notificationColor: "#fa5252", indicatorColor: "#fa5252",
isDark: true, links: [
}, {
], href: "https://homarr.dev",
}); name: "Homarr",
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
color: "#000000",
isDark: true,
},
],
};
};
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({ const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
name: "Titanic", title: "Titanic",
subName: "A classic movie", subTitle: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.", description: "A tragic love story set on the ill-fated RMS Titanic.",
date: randomDateBetween(start, end), startDate: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg", endDate: null,
mediaInformation: { image: {
type: "movie", src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
}, },
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [ links: [
{ {
href: "https://www.imdb.com/title/tt0120338/", href: "https://www.imdb.com/title/tt0120338/",
@@ -41,22 +55,26 @@ const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/imdb.svg", logo: "/images/apps/imdb.svg",
notificationColor: "cyan",
}, },
], ],
}); });
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({ const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
name: "The Mandalorian", title: "The Mandalorian",
subName: "A Star Wars Series", subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.", description: "A lone bounty hunter in the outer reaches of the galaxy.",
date: randomDateBetween(start, end), startDate: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg", endDate: null,
mediaInformation: { image: {
type: "tv", src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
seasonNumber: 1, aspectRatio: { width: 7, height: 12 },
episodeNumber: 1, badge: {
content: "S1:E1",
color: "red",
},
}, },
location: null,
indicatorColor: "blue",
links: [ links: [
{ {
href: "https://www.imdb.com/title/tt8111088/", href: "https://www.imdb.com/title/tt8111088/",
@@ -64,7 +82,6 @@ const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
color: "#f5c518", color: "#f5c518",
isDark: false, isDark: false,
logo: "/images/apps/imdb.svg", logo: "/images/apps/imdb.svg",
notificationColor: "blue",
}, },
], ],
}); });

View File

@@ -63,17 +63,20 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
); );
return { return {
name: veventObject.summary, title: veventObject.summary,
date, subTitle: null,
subName: "",
description: veventObject.description, description: veventObject.description,
startDate: date,
endDate: veventObject.end,
image: null,
location: veventObject.location || null,
indicatorColor: "#ff8600",
links: [ links: [
{ {
href: url.toString(), href: url.toString(),
name: "Nextcloud", name: "Nextcloud",
logo: "/images/apps/nextcloud.svg", logo: "/images/apps/nextcloud.svg",
color: undefined, color: undefined,
notificationColor: "#ff8600",
isDark: true, isDark: true,
}, },
], ],

View File

@@ -27,7 +27,7 @@
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"superjson": "2.2.2", "superjson": "2.2.2",
"winston": "3.17.0", "winston": "3.17.0",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -33,13 +33,13 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -24,8 +24,8 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.1",
"react": "19.1.1" "react": "19.1.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^8.2.8", "@mantine/notifications": "^8.3.1",
"@tabler/icons-react": "^3.34.1" "@tabler/icons-react": "^3.34.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -37,14 +37,14 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.1",
"adm-zip": "0.5.16", "adm-zip": "0.5.16",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"superjson": "2.2.2", "superjson": "2.2.2",
"zod": "^4.1.5", "zod": "^4.1.8",
"zod-form-data": "^3.0.1" "zod-form-data": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -23,7 +23,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -32,7 +32,7 @@
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"octokit": "^5.0.3", "octokit": "^5.0.3",
"superjson": "2.2.2", "superjson": "2.2.2",
"undici": "7.15.0" "undici": "7.16.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -26,8 +26,8 @@
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^8.2.8", "@mantine/dates": "^8.3.1",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1" "react-dom": "19.1.1"
}, },

View File

@@ -33,12 +33,12 @@
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.1",
"@mantine/spotlight": "^8.2.8", "@mantine/spotlight": "^8.3.1",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"jotai": "^2.13.1", "jotai": "^2.14.0",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"

View File

@@ -32,8 +32,8 @@
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"deepmerge": "4.3.1", "deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.2", "next": "15.5.3",
"next-intl": "4.3.7", "next-intl": "4.3.8",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1" "react-dom": "19.1.1"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Hole 数据" "label": "DNS Hole 数据"
}, },
"sessionCleanup": {
"label": "会话清理"
},
"updateChecker": { "updateChecker": {
"label": "更新检查" "label": "更新检查"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Hole Data" "label": "DNS Hole Data"
}, },
"sessionCleanup": {
"label": "Sessions Oprydning"
},
"updateChecker": { "updateChecker": {
"label": "Opdaterings checker" "label": "Opdaterings checker"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Hole Daten" "label": "DNS Hole Daten"
}, },
"sessionCleanup": {
"label": "Sitzung bereinigen"
},
"updateChecker": { "updateChecker": {
"label": "Updateprüfer" "label": "Updateprüfer"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Hole Daten" "label": "DNS Hole Daten"
}, },
"sessionCleanup": {
"label": "Sitzung bereinigen"
},
"updateChecker": { "updateChecker": {
"label": "Updateprüfer" "label": "Updateprüfer"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -945,6 +945,10 @@
"label": "Topic", "label": "Topic",
"newLabel": "New topic" "newLabel": "New topic"
}, },
"url": {
"label": "Url",
"newLabel": "New url"
},
"opnsenseApiKey": { "opnsenseApiKey": {
"label": "API Key (Key)", "label": "API Key (Key)",
"newLabel": "New API Key (Key)" "newLabel": "New API Key (Key)"
@@ -1543,7 +1547,15 @@
"width": "Width", "width": "Width",
"height": "Height" "height": "Height"
}, },
"placeholder": "Start writing your notes" "placeholder": "Start writing your notes",
"dismiss": {
"title": "Dismiss changes?",
"message": "You have unsaved changes in your notebook. Are you sure you want to discard them?",
"action": {
"discard": "Discard changes",
"keepEditing": "Keep editing"
}
}
}, },
"iframe": { "iframe": {
"name": "iFrame", "name": "iFrame",
@@ -2490,7 +2502,29 @@
"systemResources": { "systemResources": {
"name": "System resources", "name": "System resources",
"description": "CPU, Memory, Disk and other hardware usage of your system", "description": "CPU, Memory, Disk and other hardware usage of your system",
"option": {}, "option": {
"hasShadow": {
"label": "Enable chart shading"
},
"visibleCharts": {
"label": "Visible charts",
"description": "Select the charts you want to be visible.",
"option": {
"cpu": "CPU",
"memory": "Memory",
"network": "Network"
}
},
"labelDisplayMode": {
"label": "Label display mode",
"option": {
"textWithIcon": "Show text with icon",
"text": "Show only text",
"icon": "Show only icon",
"hidden": "Hide label"
}
}
},
"card": { "card": {
"cpu": "CPU", "cpu": "CPU",
"memory": "MEM", "memory": "MEM",

View File

@@ -954,16 +954,16 @@
"newLabel": "Nueva clave API (Credencial)" "newLabel": "Nueva clave API (Credencial)"
}, },
"githubAppId": { "githubAppId": {
"label": "", "label": "ID de aplicación",
"newLabel": "" "newLabel": "Nuevo ID de aplicación"
}, },
"githubInstallationId": { "githubInstallationId": {
"label": "", "label": "ID de instalación",
"newLabel": "" "newLabel": "Nuevo ID de instalación"
}, },
"privateKey": { "privateKey": {
"label": "", "label": "Clave privada",
"newLabel": "" "newLabel": "Nueva clave privada"
} }
} }
}, },
@@ -975,7 +975,7 @@
}, },
"media": { "media": {
"plural": "Imágenes", "plural": "Imágenes",
"search": "Encuentra una imagen", "search": "Buscar una imagen",
"field": { "field": {
"name": "Nombre", "name": "Nombre",
"size": "Tamaño", "size": "Tamaño",
@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "Datos de agujero DNS" "label": "Datos de agujero DNS"
}, },
"sessionCleanup": {
"label": "Limpieza de sesión"
},
"updateChecker": { "updateChecker": {
"label": "Comprobador de actualización" "label": "Comprobador de actualización"
}, },
@@ -3893,16 +3890,16 @@
"label": "Credenciales" "label": "Credenciales"
}, },
"volumes": { "volumes": {
"label": "" "label": "Volúmenes"
} }
}, },
"logs": { "logs": {
"label": "" "label": "Registros"
}, },
"certificates": { "certificates": {
"label": "", "label": "Certificados",
"hostnames": { "hostnames": {
"label": "" "label": "Nombres de host"
} }
} }
}, },
@@ -3915,28 +3912,28 @@
} }
}, },
"search": { "search": {
"placeholder": "", "placeholder": "Busca lo que quieras",
"nothingFound": "", "nothingFound": "Ningún resultado encontrado",
"error": { "error": {
"fetch": "" "fetch": "Se produjo un error al recuperar los datos"
}, },
"mode": { "mode": {
"appIntegrationBoard": { "appIntegrationBoard": {
"help": "", "help": "Buscar aplicaciones, integraciones o tableros",
"group": { "group": {
"app": { "app": {
"title": "Aplicaciones", "title": "Aplicaciones",
"children": { "children": {
"action": { "action": {
"open": { "open": {
"label": "" "label": "Abrir URL de aplicación"
}, },
"edit": { "edit": {
"label": "" "label": "Editar aplicación"
} }
}, },
"detail": { "detail": {
"title": "" "title": "Seleccionar una acción para la aplicación"
} }
} }
}, },
@@ -3945,122 +3942,122 @@
"children": { "children": {
"action": { "action": {
"open": { "open": {
"label": "" "label": "Abrir tablero"
}, },
"homeBoard": { "homeBoard": {
"label": "" "label": "Establecer como tablero de inicio"
}, },
"mobileBoard": { "mobileBoard": {
"label": "" "label": "Establecer como tablero móvil"
}, },
"settings": { "settings": {
"label": "" "label": "Abrir ajustes"
} }
}, },
"detail": { "detail": {
"title": "" "title": "Seleccionar una acción para el tablero"
} }
} }
}, },
"integration": { "integration": {
"title": "" "title": "Integraciones"
} }
} }
}, },
"command": { "command": {
"help": "", "help": "Activar modo comando",
"group": { "group": {
"localCommand": { "localCommand": {
"title": "" "title": "Comandos locales"
}, },
"globalCommand": { "globalCommand": {
"title": "", "title": "Comandos globales",
"option": { "option": {
"colorScheme": { "colorScheme": {
"light": "", "light": "Cambiar a modo claro",
"dark": "" "dark": "Cambiar a modo oscuro"
}, },
"language": { "language": {
"label": "", "label": "Cambiar idioma",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Selecciona tu idioma preferido"
} }
} }
}, },
"newBoard": { "newBoard": {
"label": "" "label": "Crear un nuevo tablero"
}, },
"importBoard": { "importBoard": {
"label": "" "label": "Importar un tablero"
}, },
"newApp": { "newApp": {
"label": "" "label": "Crear una nueva aplicación"
}, },
"newIntegration": { "newIntegration": {
"label": "", "label": "Crear una nueva integración",
"children": { "children": {
"detail": { "detail": {
"title": "" "title": "Selecciona el tipo de integración que deseas crear"
} }
} }
}, },
"newUser": { "newUser": {
"label": "" "label": "Crear un nuevo usuario"
}, },
"newInvite": { "newInvite": {
"label": "" "label": "Crear una nueva invitación"
}, },
"newGroup": { "newGroup": {
"label": "" "label": "Crear un nuevo grupo"
} }
} }
} }
} }
}, },
"media": { "media": {
"requestMovie": "", "requestMovie": "Solicitar película",
"requestSeries": "", "requestSeries": "Solicitar serie",
"openIn": "" "openIn": "Abrir en {kind}"
}, },
"external": { "external": {
"help": "", "help": "Usar un motor de búsqueda externo",
"group": { "group": {
"searchEngine": { "searchEngine": {
"title": "", "title": "Motores de búsqueda",
"children": { "children": {
"action": { "action": {
"search": { "search": {
"label": "" "label": "Buscar con {name}"
} }
}, },
"detail": { "detail": {
"title": "" "title": "Selecciona una acción para el motor de búsqueda"
}, },
"searchResults": { "searchResults": {
"title": "" "title": "Selecciona un resultado de búsqueda para acciones"
} }
}, },
"option": { "option": {
"google": { "google": {
"name": "", "name": "Google",
"description": "" "description": "Buscar en la web con Google"
}, },
"bing": { "bing": {
"name": "", "name": "Bing",
"description": "" "description": "Buscar en la web con Bing"
}, },
"duckduckgo": { "duckduckgo": {
"name": "", "name": "DuckDuckGo",
"description": "" "description": "Buscar en la web con DuckDuckGo"
}, },
"torrent": { "torrent": {
"name": "", "name": "Torrents",
"description": "" "description": "Buscar torrents en torrentdownloads.pro"
}, },
"youTube": { "youTube": {
"name": "", "name": "YouTube",
"description": "" "description": "Buscar vídeos en YouTube"
} }
} }
} }
@@ -4069,7 +4066,7 @@
"help": { "help": {
"group": { "group": {
"mode": { "mode": {
"title": "" "title": "Modos"
}, },
"help": { "help": {
"title": "Ayuda", "title": "Ayuda",
@@ -4078,7 +4075,7 @@
"label": "Documentación" "label": "Documentación"
}, },
"submitIssue": { "submitIssue": {
"label": "" "label": "Enviar una incidencia"
}, },
"discord": { "discord": {
"label": "Comunidad de Discord" "label": "Comunidad de Discord"
@@ -4090,81 +4087,81 @@
"home": { "home": {
"group": { "group": {
"search": { "search": {
"title": "", "title": "Buscar",
"option": { "option": {
"other": { "other": {
"label": "" "label": "Buscar con otro motor de búsqueda"
}, },
"no-default": { "no-default": {
"label": "", "label": "Ningún motor de búsqueda por defecto",
"description": "" "description": "Establece un motor de búsqueda predeterminado en las preferencias"
}, },
"search": { "search": {
"label": "" "label": "Buscar \"{query}\" con {name}"
}, },
"from-integration": { "from-integration": {
"description": "" "description": "Empieza a escribir para buscar"
} }
} }
}, },
"local": { "local": {
"title": "" "title": "Resultados locales"
} }
} }
}, },
"page": { "page": {
"help": "", "help": "Buscar páginas",
"group": { "group": {
"page": { "page": {
"title": "", "title": "Páginas",
"option": { "option": {
"manageHome": { "manageHome": {
"label": "" "label": "Administrar página de inicio"
}, },
"manageBoard": { "manageBoard": {
"label": "" "label": "Administrar tableros"
}, },
"manageApp": { "manageApp": {
"label": "" "label": "Administrar aplicaciones"
}, },
"manageIntegration": { "manageIntegration": {
"label": "" "label": "Administrar integraciones"
}, },
"manageSearchEngine": { "manageSearchEngine": {
"label": "" "label": "Administrar motores de búsqueda"
}, },
"manageMedia": { "manageMedia": {
"label": "" "label": "Administrar imágenes"
}, },
"manageUser": { "manageUser": {
"label": "Administrar usuarios" "label": "Administrar usuarios"
}, },
"manageInvite": { "manageInvite": {
"label": "" "label": "Administrar invitaciones"
}, },
"manageGroup": { "manageGroup": {
"label": "" "label": "Administrar grupos"
}, },
"manageDocker": { "manageDocker": {
"label": "" "label": "Administrar docker"
}, },
"manageApi": { "manageApi": {
"label": "" "label": "Swagger API"
}, },
"manageLog": { "manageLog": {
"label": "" "label": "Ver registros"
}, },
"manageTask": { "manageTask": {
"label": "" "label": "Administrar tareas"
}, },
"manageSettings": { "manageSettings": {
"label": "" "label": "Ajustes globales"
}, },
"about": { "about": {
"label": "Acerca de" "label": "Acerca de"
}, },
"homeBoard": { "homeBoard": {
"label": "" "label": "Tablero de inicio"
}, },
"preferences": { "preferences": {
"label": "Tus preferencias" "label": "Tus preferencias"
@@ -4174,37 +4171,37 @@
} }
}, },
"userGroup": { "userGroup": {
"help": "", "help": "Buscar usuarios o grupos",
"group": { "group": {
"user": { "user": {
"title": "Usuarios", "title": "Usuarios",
"children": { "children": {
"action": { "action": {
"detail": { "detail": {
"label": "" "label": "Mostrar detalles de usuario"
} }
}, },
"detail": { "detail": {
"title": "" "title": "Selecciona una acción para el usuario"
} }
} }
}, },
"group": { "group": {
"title": "", "title": "Grupos",
"children": { "children": {
"action": { "action": {
"detail": { "detail": {
"label": "" "label": "Mostrar detalles del grupo"
}, },
"manageMember": { "manageMember": {
"label": "" "label": "Administrar usuarios"
}, },
"managePermission": { "managePermission": {
"label": "" "label": "Administrar permisos"
} }
}, },
"detail": { "detail": {
"title": "" "title": "Selecciona una acción para el grupo"
} }
} }
} }
@@ -4212,72 +4209,72 @@
} }
}, },
"engine": { "engine": {
"search": "", "search": "Buscar un motor de búsqueda",
"field": { "field": {
"name": { "name": {
"label": "Nombre" "label": "Nombre"
}, },
"short": { "short": {
"label": "" "label": "Abreviatura"
}, },
"urlTemplate": { "urlTemplate": {
"label": "" "label": "Plantilla de búsqueda de URL"
}, },
"description": { "description": {
"label": "" "label": "Descripción"
} }
}, },
"page": { "page": {
"list": { "list": {
"title": "", "title": "Motores de búsqueda",
"noResults": { "noResults": {
"title": "", "title": "Todavía no hay motores de búsqueda",
"action": "" "action": "Crea tu primer motor de búsqueda"
}, },
"interactive": "" "interactive": "Interactivo, utiliza una integración"
}, },
"create": { "create": {
"title": "", "title": "Nuevo motor de búsqueda",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Motor de búsqueda creado",
"message": "" "message": "El motor de búsqueda fue creado con éxito"
}, },
"error": { "error": {
"title": "", "title": "No se creó el motor de búsqueda",
"message": "" "message": "El motor de búsqueda no pudo ser creado"
} }
} }
}, },
"edit": { "edit": {
"title": "", "title": "Editar motor de búsqueda",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Cambios aplicados con éxito",
"message": "" "message": "El motor de búsqueda se guardó con éxito"
}, },
"error": { "error": {
"title": "", "title": "No se pudieron aplicar los cambios",
"message": "" "message": "No se pudo guardar el motor de búsqueda"
} }
}, },
"configControl": "", "configControl": "Configuración",
"searchEngineType": { "searchEngineType": {
"generic": "", "generic": "Genérico",
"fromIntegration": "" "fromIntegration": "Desde integración"
} }
}, },
"delete": { "delete": {
"title": "", "title": "Eliminar motor de búsqueda",
"message": "", "message": "¿Estás seguro de que quieres eliminar el motor de búsqueda {name}?",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Motor de búsqueda eliminado",
"message": "" "message": "El motor de búsqueda fue eliminado con éxito"
}, },
"error": { "error": {
"title": "", "title": "Motor de búsqueda no eliminado",
"message": "" "message": "No se ha podido eliminar el motor de búsqueda"
} }
} }
} }
@@ -4285,15 +4282,15 @@
"media": { "media": {
"request": { "request": {
"modal": { "modal": {
"title": "", "title": "Solicitar \"{name}\"",
"table": { "table": {
"header": { "header": {
"season": "", "season": "Temporada",
"episodes": "" "episodes": "Episodios"
} }
}, },
"button": { "button": {
"send": "" "send": "Enviar solicitud"
} }
} }
} }
@@ -4303,89 +4300,89 @@
"certificate": { "certificate": {
"field": { "field": {
"hostname": { "hostname": {
"label": "" "label": "Nombre de host"
}, },
"subject": { "subject": {
"label": "" "label": "Sujeto"
}, },
"issuer": { "issuer": {
"label": "" "label": "Emisor"
}, },
"validFrom": { "validFrom": {
"label": "" "label": "Válido desde"
}, },
"validTo": { "validTo": {
"label": "" "label": "Válido hasta"
}, },
"serialNumber": { "serialNumber": {
"label": "" "label": "Número de serie"
}, },
"fingerprint": { "fingerprint": {
"label": "" "label": "Huella digital"
} }
}, },
"page": { "page": {
"list": { "list": {
"title": "", "title": "Certificados de confianza",
"description": "", "description": "Utilizado por Homarr para solicitar datos de las integraciones.",
"noResults": { "noResults": {
"title": "" "title": "Aún no hay certificados"
}, },
"invalid": { "invalid": {
"title": "Certificado no válido", "title": "Certificado no válido",
"description": "Error al analizar el certificado" "description": "Error al analizar el certificado"
}, },
"expires": "", "expires": "Expira {when}",
"toHostnames": "" "toHostnames": "Nombres de host de confianza"
}, },
"hostnames": { "hostnames": {
"title": "", "title": "Nombres de host de certificado de confianza",
"description": "", "description": "Algunos certificados no permiten que el dominio específico que utiliza Homarr los solicite, debido a esto todos los nombres de host de confianza con sus huellas digitales de certificado se utilizan para eludir estas restricciones.",
"noResults": { "noResults": {
"title": "" "title": "Aún no hay nombres de host"
}, },
"toCertificates": "" "toCertificates": "Certificados"
} }
}, },
"action": { "action": {
"create": { "create": {
"label": "", "label": "Añadir certificado",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Certificado añadido",
"message": "" "message": "El certificado fue añadido con éxito"
}, },
"error": { "error": {
"title": "", "title": "Error al añadir el certificado",
"message": "" "message": "No se ha podido añadir el certificado"
} }
} }
}, },
"remove": { "remove": {
"label": "", "label": "Eliminar certificado",
"confirm": "", "confirm": "¿Estás seguro de que deseas eliminar el certificado?",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Certificado eliminado",
"message": "" "message": "El certificado fue eliminado con éxito"
}, },
"error": { "error": {
"title": "", "title": "Certificado no eliminado",
"message": "" "message": "No se ha podido eliminar el certificado"
} }
} }
}, },
"removeHostname": { "removeHostname": {
"label": "", "label": "Eliminar nombre de host de confianza",
"confirm": "", "confirm": "¿Estás seguro de que quieres eliminar este nombre de host de confianza? Esto puede causar que algunas integraciones dejen de funcionar.",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "Nombre de host eliminado",
"message": "" "message": "El nombre de host fue eliminado con éxito"
}, },
"error": { "error": {
"title": "", "title": "Nombre de host no eliminado",
"message": "" "message": "El nombre de host no pudo ser eliminado"
} }
} }
} }
@@ -4394,10 +4391,10 @@
"log": { "log": {
"level": { "level": {
"option": { "option": {
"debug": "", "debug": "Depuración",
"info": "", "info": "Información",
"warn": "", "warn": "Advertencia",
"error": "" "error": "Error"
} }
} }
} }

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "Données du puit DNS" "label": "Données du puit DNS"
}, },
"sessionCleanup": {
"label": "Nettoyage de session"
},
"updateChecker": { "updateChecker": {
"label": "Vérificateur de mise à jour" "label": "Vérificateur de mise à jour"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "נתוני חור DNS" "label": "נתוני חור DNS"
}, },
"sessionCleanup": {
"label": "ניקוי סשן"
},
"updateChecker": { "updateChecker": {
"label": "בודק עדכונים" "label": "בודק עדכונים"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Holeデータ" "label": "DNS Holeデータ"
}, },
"sessionCleanup": {
"label": "セッションのクリーンアップ"
},
"updateChecker": { "updateChecker": {
"label": "アップデートチェッカー" "label": "アップデートチェッカー"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS-hole gegevens" "label": "DNS-hole gegevens"
}, },
"sessionCleanup": {
"label": "Sessie opruimen"
},
"updateChecker": { "updateChecker": {
"label": "Update checker" "label": "Update checker"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Hole Data" "label": "DNS Hole Data"
}, },
"sessionCleanup": {
"label": "Økt opprydding"
},
"updateChecker": { "updateChecker": {
"label": "Oppdateringssjekk" "label": "Oppdateringssjekk"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "Dane DNS Hole" "label": "Dane DNS Hole"
}, },
"sessionCleanup": {
"label": "Czyszczenie sesji"
},
"updateChecker": { "updateChecker": {
"label": "Sprawdzanie aktualizacji" "label": "Sprawdzanie aktualizacji"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "Данные DNS-фильтра" "label": "Данные DNS-фильтра"
}, },
"sessionCleanup": {
"label": "Очистка сессий"
},
"updateChecker": { "updateChecker": {
"label": "Проверка обновлений" "label": "Проверка обновлений"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "Údaje o dierach DNS" "label": "Údaje o dierach DNS"
}, },
"sessionCleanup": {
"label": "Čistenie relácie"
},
"updateChecker": { "updateChecker": {
"label": "Kontrola aktualizácií" "label": "Kontrola aktualizácií"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -954,16 +954,16 @@
"newLabel": "Yeni API Anahtarı (Gizli Anahtar)" "newLabel": "Yeni API Anahtarı (Gizli Anahtar)"
}, },
"githubAppId": { "githubAppId": {
"label": "", "label": "Uygulama Kimliği",
"newLabel": "" "newLabel": "Yeni Uygulama Kimliği"
}, },
"githubInstallationId": { "githubInstallationId": {
"label": "", "label": "Kurulum Kimliği",
"newLabel": "" "newLabel": "Yeni Kurulum Kimliği"
}, },
"privateKey": { "privateKey": {
"label": "", "label": "Özel Anahtar",
"newLabel": "" "newLabel": "Yeni özel anahtar"
} }
} }
}, },
@@ -2273,7 +2273,7 @@
"label": "Gönderi sayısı sınırı" "label": "Gönderi sayısı sınırı"
}, },
"hideDescription": { "hideDescription": {
"label": "" "label": "ıklamayı gizle"
} }
} }
}, },
@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Çözümleyici Verileri" "label": "DNS Çözümleyici Verileri"
}, },
"sessionCleanup": {
"label": "Oturum Temizleme"
},
"updateChecker": { "updateChecker": {
"label": "Güncelleme denetleyicisi" "label": "Güncelleme denetleyicisi"
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "" "label": ""
}, },
"sessionCleanup": {
"label": ""
},
"updateChecker": { "updateChecker": {
"label": "" "label": ""
}, },

View File

@@ -3255,9 +3255,6 @@
"dnsHole": { "dnsHole": {
"label": "DNS Hole 數據" "label": "DNS Hole 數據"
}, },
"sessionCleanup": {
"label": "會話清理"
},
"updateChecker": { "updateChecker": {
"label": "更新檢查" "label": "更新檢查"
}, },

View File

@@ -30,12 +30,12 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@mantine/dates": "^8.2.8", "@mantine/dates": "^8.3.1",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.1",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"svgson": "^5.3.1" "svgson": "^5.3.1"

View File

@@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"zod": "^4.1.5", "zod": "^4.1.8",
"zod-form-data": "^3.0.1" "zod-form-data": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -48,9 +48,9 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/charts": "^8.2.8", "@mantine/charts": "^8.3.1",
"@mantine/core": "^8.2.8", "@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.2.8", "@mantine/hooks": "^8.3.1",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"@tiptap/extension-color": "2.26.1", "@tiptap/extension-color": "2.26.1",
"@tiptap/extension-highlight": "2.26.1", "@tiptap/extension-highlight": "2.26.1",
@@ -72,13 +72,13 @@
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.2", "next": "15.5.3",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"video.js": "^8.23.4", "video.js": "^8.23.4",
"zod": "^4.1.5" "zod": "^4.1.8"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -11,9 +11,10 @@ import {
Text, Text,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { IconClock } from "@tabler/icons-react"; import { IconClock, IconPin } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types"; import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -40,85 +41,108 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
<Stack> <Stack>
{events.map((event, eventIndex) => ( {events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap"> <Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
<Box pos={"relative"} w={70} h={120}> {event.image !== null && (
<Image <Box pos="relative">
src={event.thumbnail} <Image
w={70} src={event.image.src}
h={120} w={70}
radius={"sm"} mah={150}
fallbackSrc={"https://placehold.co/400x600?text=No%20image"} style={{
/> aspectRatio: event.image.aspectRatio
{event.mediaInformation?.type === "tv" && ( ? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
<Badge : "1/1",
pos={"absolute"} }}
bottom={-6} radius="sm"
left={"50%"} fallbackSrc="https://placehold.co/400x400?text=No%20image"
w={"inherit"} />
className={classes.badge} {event.image.badge !== undefined && (
>{`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`}</Badge> <Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
)} {event.image.badge.content}
</Box> </Badge>
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}> <Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify={"space-between"} align={"start"} mb={"xs"} wrap="nowrap"> <Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}> <Stack gap={0}>
{event.subName && ( {event.subTitle !== null && (
<Text lineClamp={1} size="sm"> <Text lineClamp={1} size="sm">
{event.subName} {event.subTitle}
</Text> </Text>
)} )}
<Text fw={"bold"} lineClamp={1} size="sm"> <Text fw={"bold"} lineClamp={1} size="sm">
{event.name} {event.title}
</Text> </Text>
</Stack> </Stack>
{event.dates ? ( {event.metadata?.type === "radarr" && (
<Group wrap="nowrap"> <Group wrap="nowrap">
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t( {t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
`widget.calendar.option.releaseType.options.${event.dates.find(({ date }) => event.date === date)?.type ?? "inCinemas"}`,
)}
</Text>
</Group>
) : (
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.date).format("HH:mm")}
</Text> </Text>
</Group> </Group>
)} )}
<Group gap={3} wrap="nowrap" align={"center"}>
<IconClock opacity={0.7} size={"1rem"} />
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
</Text>
</>
)}
</Group>
</Group> </Group>
{event.description && (
{event.location !== null && (
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
<IconPin opacity={0.7} size={"1rem"} />
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
{event.location}
</Text>
</Group>
)}
{!isNullOrWhitespace(event.description) && (
<Text size={"xs"} c={"dimmed"} lineClamp={2}> <Text size={"xs"} c={"dimmed"} lineClamp={2}>
{event.description} {event.description}
</Text> </Text>
)} )}
{event.links.length > 0 && ( {event.links.length > 0 && (
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap"> <Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links.map((link) => ( {event.links
<Button .filter((link) => link.href)
key={link.href} .map((link) => (
component={"a"} <Button
href={link.href.toString()} key={link.href}
target={"_blank"} component={"a"}
size={"xs"} href={link.href.toString()}
radius={"xl"} target={"_blank"}
variant={link.color ? undefined : "default"} size={"xs"}
styles={{ radius={"xl"}
root: { variant={link.color ? undefined : "default"}
backgroundColor: link.color, styles={{
color: link.isDark && colorScheme === "dark" ? "white" : "black", root: {
"&:hover": link.color backgroundColor: link.color,
? { color: link.isDark && colorScheme === "dark" ? "white" : "black",
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1), "&:hover": link.color
} ? {
: undefined, backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}, }
}} : undefined,
leftSection={link.logo ? <Image src={link.logo} w={20} h={20} /> : undefined} },
> }}
<Text>{link.name}</Text> leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
</Button> >
))} <Text>{link.name}</Text>
</Button>
))}
</Group> </Group>
)} )}
</Stack> </Stack>

View File

@@ -79,7 +79,7 @@ interface NotificationIndicatorProps {
} }
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => { const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String); const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
/* position bottom is lower when small to not be on top of number*/ /* position bottom is lower when small to not be on top of number*/
return ( return (
<Flex w="75%" pos={"absolute"} bottom={isSmall ? 4 : 10} left={"12.5%"} p={0} direction={"row"} justify={"center"}> <Flex w="75%" pos={"absolute"} bottom={isSmall ? 4 : 10} left={"12.5%"} p={0} direction={"row"} justify={"center"}>

View File

@@ -10,7 +10,6 @@ import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
@@ -124,13 +123,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
}} }}
renderDay={(tileDate) => { renderDay={(tileDate) => {
const eventsForDate = events const eventsForDate = events
.map((event) => ({ .filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
...event, .filter(
date: (event.dates?.filter(({ type }) => options.releaseType.includes(type)) ?? [event]).find(({ date }) => (event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
dayjs(date).isSame(tileDate, "day"), )
)?.date, .sort((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
}))
.filter((event): event is CalendarEvent => Boolean(event.date));
return ( return (
<CalendarDay <CalendarDay

View File

@@ -72,6 +72,7 @@ import "./notebook.css";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal } from "@homarr/modals";
const iconProps = { const iconProps = {
size: 30, size: 30,
@@ -240,9 +241,20 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
return false; return false;
}, [editor]); }, [editor]);
const { openConfirmModal } = useConfirmModal();
const handleEditCancel = useCallback(() => { const handleEditCancel = useCallback(() => {
setIsEditing(handleEditCancelCallback); openConfirmModal({
}, [setIsEditing, handleEditCancelCallback]); title: t("widget.notebook.dismiss.title"),
children: t("widget.notebook.dismiss.message"),
labels: {
confirm: t("widget.notebook.dismiss.action.discard"),
cancel: t("widget.notebook.dismiss.action.keepEditing"),
},
onConfirm: () => {
setIsEditing(handleEditCancelCallback);
},
});
}, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]);
const handleEditToggle = useCallback(() => { const handleEditToggle = useCallback(() => {
setIsEditing(handleEditToggleCallback); setIsEditing(handleEditToggleCallback);

View File

@@ -1,17 +1,23 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core"; import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
import { CommonChart } from "./common-chart"; import { CommonChart } from "./common-chart";
export const CombinedNetworkTrafficChart = ({ export const CombinedNetworkTrafficChart = ({
usageOverTime, usageOverTime,
hasShadow,
labelDisplayMode,
}: { }: {
usageOverTime: { usageOverTime: {
up: number; up: number;
down: number; down: number;
}[]; }[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
}) => { }) => {
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down })); const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
const t = useScopedI18n("widget.systemResources.card"); const t = useScopedI18n("widget.systemResources.card");
@@ -25,7 +31,10 @@ export const CombinedNetworkTrafficChart = ({
{ name: "down", color: "yellow.5" }, { name: "down", color: "yellow.5" },
]} ]}
title={t("network")} title={t("network")}
icon={IconNetwork}
yAxisProps={{ domain: [0, "dataMax"] }} yAxisProps={{ domain: [0, "dataMax"] }}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
if (!payload) { if (!payload) {

View File

@@ -1,28 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { LineChartSeries } from "@mantine/charts"; import type { ReactNode } from "react";
import { LineChart } from "@mantine/charts"; import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core"; import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts"; import type { TooltipProps, YAxisProps } from "recharts";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { TablerIcon } from "@homarr/ui";
import type { LabelDisplayModeOption } from "..";
export const CommonChart = ({ export const CommonChart = ({
data, data,
dataKey, dataKey,
series, series,
title, title,
icon: Icon,
labelDisplayMode,
tooltipProps, tooltipProps,
yAxisProps, yAxisProps,
lastValue, lastValue,
chartType = "line",
}: { }: {
data: Record<string, any>[]; data: Record<string, any>[];
dataKey: string; dataKey: string;
series: LineChartSeries[]; series: AreaChartSeries[];
title: string; title: ReactNode;
icon: TablerIcon;
labelDisplayMode: LabelDisplayModeOption;
tooltipProps?: TooltipProps<number, any>; tooltipProps?: TooltipProps<number, any>;
yAxisProps?: Omit<YAxisProps, "ref">; yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string; lastValue?: string;
chartType?: "line" | "area";
}) => { }) => {
const { ref: elementSizeRef, height } = useElementSize(); const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme(); const theme = useMantineTheme();
@@ -35,6 +45,10 @@ export const CommonChart = ({
const backgroundColor = const backgroundColor =
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
return ( return (
<Card <Card
ref={ref} ref={ref}
@@ -55,10 +69,14 @@ export const CommonChart = ({
gap={5} gap={5}
wrap={"nowrap"} wrap={"nowrap"}
style={{ zIndex: 2, pointerEvents: "none" }} style={{ zIndex: 2, pointerEvents: "none" }}
align="center"
> >
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}> {showIcon && <Icon color={"var(--mantine-color-dimmed)"} size={height > 100 ? 20 : 14} stroke={1.5} />}
{title} {showText && (
</Text> <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
{title}
</Text>
)}
{lastValue && ( {lastValue && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}> <Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
{lastValue} {lastValue}
@@ -73,7 +91,7 @@ export const CommonChart = ({
</Stack> </Stack>
</Center> </Center>
) : ( ) : (
<LineChart <ChartComponent
data={data} data={data}
dataKey={dataKey} dataKey={dataKey}
h={"100%"} h={"100%"}
@@ -90,6 +108,7 @@ export const CommonChart = ({
tooltipProps={tooltipProps} tooltipProps={tooltipProps}
withTooltip={height >= 64} withTooltip={height >= 64}
yAxisProps={yAxisProps} yAxisProps={yAxisProps}
fillOpacity={chartType === "area" ? 0.3 : undefined}
/> />
)} )}
</Card> </Card>

View File

@@ -1,10 +1,20 @@
import { Paper, Text } from "@mantine/core"; import { Paper, Text } from "@mantine/core";
import { IconCpu } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
import { CommonChart } from "./common-chart"; import { CommonChart } from "./common-chart";
export const SystemResourceCPUChart = ({ cpuUsageOverTime }: { cpuUsageOverTime: number[] }) => { export const SystemResourceCPUChart = ({
cpuUsageOverTime,
hasShadow,
labelDisplayMode,
}: {
cpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
}) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
const t = useScopedI18n("widget.systemResources.card"); const t = useScopedI18n("widget.systemResources.card");
@@ -14,11 +24,14 @@ export const SystemResourceCPUChart = ({ cpuUsageOverTime }: { cpuUsageOverTime:
dataKey={"index"} dataKey={"index"}
series={[{ name: "usage", color: "blue.5" }]} series={[{ name: "usage", color: "blue.5" }]}
title={t("cpu")} title={t("cpu")}
icon={IconCpu}
lastValue={ lastValue={
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
} }
chartType={hasShadow ? "area" : "line"}
yAxisProps={{ domain: [0, 100] }} yAxisProps={{ domain: [0, 100] }}
labelDisplayMode={labelDisplayMode}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
if (!payload) { if (!payload) {

View File

@@ -1,16 +1,22 @@
import { Paper, Text } from "@mantine/core"; import { Paper, Text } from "@mantine/core";
import { IconBrain } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { LabelDisplayModeOption } from "..";
import { CommonChart } from "./common-chart"; import { CommonChart } from "./common-chart";
export const SystemResourceMemoryChart = ({ export const SystemResourceMemoryChart = ({
memoryUsageOverTime, memoryUsageOverTime,
totalCapacityInBytes, totalCapacityInBytes,
hasShadow,
labelDisplayMode,
}: { }: {
memoryUsageOverTime: number[]; memoryUsageOverTime: number[];
totalCapacityInBytes: number; totalCapacityInBytes: number;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
}) => { }) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage })); const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
const t = useScopedI18n("widget.systemResources.card"); const t = useScopedI18n("widget.systemResources.card");
@@ -27,8 +33,11 @@ export const SystemResourceMemoryChart = ({
dataKey={"index"} dataKey={"index"}
series={[{ name: "usage", color: "red.6" }]} series={[{ name: "usage", color: "red.6" }]}
title={t("memory")} title={t("memory")}
icon={IconBrain}
labelDisplayMode={labelDisplayMode}
yAxisProps={{ domain: [0, totalCapacityInBytes] }} yAxisProps={{ domain: [0, totalCapacityInBytes] }}
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined} lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
chartType={hasShadow ? "area" : "line"}
tooltipProps={{ tooltipProps={{
content: ({ payload }) => { content: ({ payload }) => {
if (!payload) { if (!payload) {

Some files were not shown because too many files have changed in this diff Show More