From d3256596d84ee1cd5ef987d8f1ce2eeba2ce7c41 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 16:19:33 -0700 Subject: [PATCH 1/6] Enhance release-drafter autolabeler rules --- .github/release-drafter.yml | 44 +++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index eac6e295a..1e67b0b92 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -47,15 +47,34 @@ categories: - 'documentation' autolabeler: + - label: 'bug' + title: + - '/^fix(\(.+\))?:/i' + body: + - '/- \[[xX]\] Bug fix \(non-breaking change which fixes an issue\)/' + + - label: 'enhancement' + title: + - '/^(feature|enhancement)(\(.+\))?:/i' + body: + - '/- \[[xX]\] New service widget/' + - '/- \[[xX]\] New feature or enhancement \(non-breaking change which adds functionality\)/' + - label: 'documentation' - files: - - 'docs/**' - - '*.md' - - '.github/**/*.md' + title: + - '/^(documentation|docs)(\(.+\))?:/i' + body: + - '/- \[[xX]\] Documentation only/' + + - label: 'chore' + body: + - '/- \[[xX]\] Other \(please explain\)/' - label: 'ci' - files: - - '.github/workflows/**' + branch: + - '/^(ci|workflow|actions)\//' + title: + - '/^(ci|workflow|actions)(\(.+\))?:/i' - label: 'dependencies' files: @@ -64,19 +83,6 @@ autolabeler: - 'pyproject.toml' - 'uv.lock' - - label: 'feature' - files: - - 'src/components/**' - - 'src/widgets/**' - - 'src/pages/**' - - 'src/utils/**' - - - label: 'chore' - files: - - 'Dockerfile*' - - 'docker-entrypoint.sh' - - 'k3d/**' - - label: 'translation' files: - 'public/locales/**' From d65e5447e4876e0700e078df3e7d1de084b799d9 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 8 May 2026 16:21:48 -0700 Subject: [PATCH 2/6] Add Issue Triage workflow --- .github/workflows/issue-triage.yml | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/issue-triage.yml diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 000000000..b3fe6effd --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,43 @@ +name: Issue Triage + +on: + issues: + types: [labeled] + +permissions: + issues: write + +jobs: + close-needs-discussion: + name: Issues Need Discussion + if: github.event.label.name == 'needs-discussion' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const issueNumber = context.payload.issue.number; + const owner = context.repo.owner; + const repo = context.repo.repo; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: 'This issue is being closed because it was opened before a maintainer asked for an issue to be created. Please start with a discussion and follow the issue template; only open an issue when a maintainer asks you to do so.', + }); + + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: 'closed', + state_reason: 'not_planned', + }); + + await github.rest.issues.lock({ + owner, + repo, + issue_number: issueNumber, + lock_reason: 'off-topic', + }); From 1cc4608d729c04b86b41da97916d8c244c836352 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 10 May 2026 06:51:22 -0700 Subject: [PATCH 3/6] Enhancement: support qBittorrent v5.2.0 api changes (#6652) --- src/widgets/qbittorrent/proxy.js | 4 ++-- src/widgets/qbittorrent/proxy.test.js | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/widgets/qbittorrent/proxy.js b/src/widgets/qbittorrent/proxy.js index 4bb883023..9b789c885 100644 --- a/src/widgets/qbittorrent/proxy.js +++ b/src/widgets/qbittorrent/proxy.js @@ -41,12 +41,12 @@ export default async function qbittorrentProxyHandler(req, res) { if (status === 403) { [status, data] = await login(widget); - if (status !== 200) { + if (![200, 204].includes(status)) { logger.error("HTTP %d logging in to qBittorrent. Data: %s", status, data); return res.status(status).end(data); } - if (data.toString() !== "Ok.") { + if (status === 200 && data.toString() !== "Ok.") { logger.error("Error logging in to qBittorrent: Data: %s", data); return res.status(401).end(data); } diff --git a/src/widgets/qbittorrent/proxy.test.js b/src/widgets/qbittorrent/proxy.test.js index 8c0754e75..caea6e6d0 100644 --- a/src/widgets/qbittorrent/proxy.test.js +++ b/src/widgets/qbittorrent/proxy.test.js @@ -49,6 +49,25 @@ describe("widgets/qbittorrent/proxy", () => { expect(res.body).toEqual(Buffer.from("data")); }); + it("accepts qBittorrent 5.2.0 no-content login responses", async () => { + getServiceWidget.mockResolvedValue({ url: "http://qb", username: "u", password: "p" }); + + httpProxy + .mockResolvedValueOnce([403, "application/json", Buffer.from("nope")]) + .mockResolvedValueOnce([204, null, Buffer.from("")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); + + const req = { query: { group: "g", service: "svc", endpoint: "torrents/info", index: "0" } }; + const res = createMockRes(); + + await qbittorrentProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[1][0]).toBe("http://qb/api/v2/auth/login"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("data")); + }); + it("returns 401 when login succeeds but response body is not Ok.", async () => { getServiceWidget.mockResolvedValue({ url: "http://qb", username: "u", password: "p" }); From 5d71c3aa6504a7de033011026692a904becbea9c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 10 May 2026 07:28:31 -0700 Subject: [PATCH 4/6] Fix: allow empty data for ntfy widget (#6653) --- src/utils/proxy/validate-widget-data.js | 21 +++++++++++--------- src/utils/proxy/validate-widget-data.test.js | 15 +++++++++++++- src/widgets/ntfy/widget.js | 17 ++++++++++++++++ src/widgets/ntfy/widget.test.js | 16 ++++++++++++++- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/utils/proxy/validate-widget-data.js b/src/utils/proxy/validate-widget-data.js index b38ef4f79..24c8e8287 100644 --- a/src/utils/proxy/validate-widget-data.js +++ b/src/utils/proxy/validate-widget-data.js @@ -8,6 +8,13 @@ export default function validateWidgetData(widget, endpoint, data) { let dataParsed = data; let error; let mapping; + const mappings = widgets[widget.type]?.mappings; + if (mappings) { + mapping = Object.values(mappings).find((m) => m.endpoint === endpoint); + } + + if (mapping?.allowEmpty && Buffer.isBuffer(data) && data.length === 0) return true; + if (Buffer.isBuffer(data)) { try { dataParsed = JSON.parse(data); @@ -23,15 +30,11 @@ export default function validateWidgetData(widget, endpoint, data) { } if (dataParsed && Object.entries(dataParsed).length) { - const mappings = widgets[widget.type]?.mappings; - if (mappings) { - mapping = Object.values(mappings).find((m) => m.endpoint === endpoint); - mapping?.validate?.forEach((key) => { - if (dataParsed[key] === undefined) { - valid = false; - } - }); - } + mapping?.validate?.forEach((key) => { + if (dataParsed[key] === undefined) { + valid = false; + } + }); } if (!valid) { diff --git a/src/utils/proxy/validate-widget-data.test.js b/src/utils/proxy/validate-widget-data.test.js index 307b5fd5b..c2fb139e1 100644 --- a/src/utils/proxy/validate-widget-data.test.js +++ b/src/utils/proxy/validate-widget-data.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const { loggerError } = vi.hoisted(() => ({ loggerError: vi.fn(), @@ -18,6 +18,10 @@ vi.mock("widgets/widgets", () => ({ endpoint: "foo", validate: ["a", "b"], }, + empty: { + endpoint: "empty", + allowEmpty: true, + }, }, }, }, @@ -26,6 +30,10 @@ vi.mock("widgets/widgets", () => ({ import validateWidgetData from "./validate-widget-data"; describe("utils/proxy/validate-widget-data", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("returns false when buffer JSON cannot be parsed", () => { expect(validateWidgetData({ type: "test" }, "foo", Buffer.from("not json"))).toBe(false); expect(loggerError).toHaveBeenCalled(); @@ -41,4 +49,9 @@ describe("utils/proxy/validate-widget-data", () => { expect(validateWidgetData({ type: "test" }, "foo", Buffer.from(JSON.stringify({ a: 1 })))).toBe(false); expect(loggerError).toHaveBeenCalled(); }); + + it("allows empty buffer responses for mappings that explicitly allow them", () => { + expect(validateWidgetData({ type: "test" }, "empty", Buffer.from(""))).toBe(true); + expect(loggerError).not.toHaveBeenCalled(); + }); }); diff --git a/src/widgets/ntfy/widget.js b/src/widgets/ntfy/widget.js index 577e124a7..a11f379d6 100644 --- a/src/widgets/ntfy/widget.js +++ b/src/widgets/ntfy/widget.js @@ -1,5 +1,14 @@ +import { asJson } from "utils/proxy/api-helpers"; import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; +const noMessages = { + title: null, + message: null, + priority: 3, + time: null, + tags: [], +}; + const widget = { api: "{url}/{endpoint}", proxyHandler: credentialedProxyHandler, @@ -7,6 +16,14 @@ const widget = { mappings: { messages: { endpoint: "{topic}/json?poll=1&since=latest", + allowEmpty: true, + map: (data) => { + if (Buffer.isBuffer(data) && data.length === 0) { + return noMessages; + } + + return asJson(data); + }, }, }, }; diff --git a/src/widgets/ntfy/widget.test.js b/src/widgets/ntfy/widget.test.js index 4827c60b2..4025ce741 100644 --- a/src/widgets/ntfy/widget.test.js +++ b/src/widgets/ntfy/widget.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { expectWidgetConfigShape } from "test-utils/widget-config"; @@ -8,4 +8,18 @@ describe("ntfy widget config", () => { it("exports a valid widget config", () => { expectWidgetConfigShape(widget); }); + + it("maps an empty latest message response to the no messages state", () => { + expect(widget.mappings.messages.map(Buffer.from(""))).toEqual({ + title: null, + message: null, + priority: 3, + time: null, + tags: [], + }); + }); + + it("parses latest message responses", () => { + expect(widget.mappings.messages.map(Buffer.from('{"message":"hello"}'))).toEqual({ message: "hello" }); + }); }); From a6c753c8aa025bb4aaecac21781f1fce25d5becf Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 11 May 2026 00:04:33 -0700 Subject: [PATCH 5/6] Fix: include tasks params for PBS widget (#6655) --- src/widgets/proxmoxbackupserver/widget.js | 1 + src/widgets/proxmoxbackupserver/widget.test.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/widgets/proxmoxbackupserver/widget.js b/src/widgets/proxmoxbackupserver/widget.js index 719849185..46598fd11 100644 --- a/src/widgets/proxmoxbackupserver/widget.js +++ b/src/widgets/proxmoxbackupserver/widget.js @@ -10,6 +10,7 @@ const widget = { }, "nodes/localhost/tasks": { endpoint: "nodes/localhost/tasks", + params: ["errors", "limit", "since"], }, "nodes/localhost/status": { endpoint: "nodes/localhost/status", diff --git a/src/widgets/proxmoxbackupserver/widget.test.js b/src/widgets/proxmoxbackupserver/widget.test.js index 0d2fd5f1b..024cc0fd9 100644 --- a/src/widgets/proxmoxbackupserver/widget.test.js +++ b/src/widgets/proxmoxbackupserver/widget.test.js @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { expectWidgetConfigShape } from "test-utils/widget-config"; @@ -8,4 +8,8 @@ describe("proxmoxbackupserver widget config", () => { it("exports a valid widget config", () => { expectWidgetConfigShape(widget); }); + + it("requires failed task query params for the tasks endpoint", () => { + expect(widget.mappings["nodes/localhost/tasks"].params).toEqual(["errors", "limit", "since"]); + }); }); From 0ff9af4c3cf8a1c02586dbdf0a611b12dfdfd595 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 08:11:24 -0700 Subject: [PATCH 6/6] New Crowdin translations by GitHub Action (#6647) Co-authored-by: Crowdin Bot --- public/locales/fr/common.json | 34 ++++---- public/locales/ru/common.json | 152 +++++++++++++++++----------------- 2 files changed, 93 insertions(+), 93 deletions(-) diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index 532ecf268..b01c78a98 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -344,15 +344,15 @@ "address": "Adresse", "expires": "Expire", "never": "Jamais", - "user": "User", - "hostname": "Hostname", - "name": "Name", - "client_version": "Client Version", + "user": "Utilisateur", + "hostname": "Nom d'hôte", + "name": "Nom", + "client_version": "Version client", "os": "OS", - "created": "Created", - "authorized": "Authorized", - "is_external": "Is External", - "update_available": "Update Available", + "created": "Créé", + "authorized": "Autorisée", + "is_external": "Externe", + "update_available": "Mise à jour disponible", "tags": "Tags", "last_seen": "Vu pour la dernière fois", "now": "Maintenant", @@ -363,8 +363,8 @@ "minutes": "{{number}}m", "seconds": "{{number}}s", "ago": "Il y a {{value}}", - "true": "Yes", - "false": "No" + "true": "Oui", + "false": "Non" }, "technitium": { "totalQueries": "Requêtes", @@ -937,16 +937,16 @@ "criticals": "Urgent" }, "ntfy": { - "title": "Title", - "priority": "Priority", - "lastReceived": "Last Received", + "title": "Titre", + "priority": "Priorité", + "lastReceived": "Dernière réception", "message": "Message", "tags": "Tags", - "none": "None", + "none": "Aucun", "min": "Min", - "low": "Low", - "default": "Default", - "high": "High", + "low": "Bas", + "default": "Défaut", + "high": "Haut", "urgent": "Urgent" }, "plantit": { diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json index 039597ed0..07367fdd5 100644 --- a/public/locales/ru/common.json +++ b/public/locales/ru/common.json @@ -67,14 +67,14 @@ "empty_data": "Статус подсистемы неизвестен" }, "unifi_drive": { - "healthy": "Healthy", - "degraded": "Degraded", - "no_data": "No storage data available" + "healthy": "Здоров", + "degraded": "Деградация", + "no_data": "Нет доступных данных о хранилище" }, "docker": { "rx": "RX", "tx": "TX", - "mem": "Память", + "mem": "ОЗУ", "cpu": "ЦП", "running": "Запущено", "offline": "Не в сети", @@ -184,15 +184,15 @@ }, "tautulli": { "playing": "Играет", - "transcoding": "Транскодируется", + "transcoding": "Перекодируется", "bitrate": "Битрейт", - "no_active": "Нет активных стримов", + "no_active": "Нет активных потоков", "plex_connection_error": "Проверка соединения Plex" }, "tracearr": { "no_active": "Нет активных потоков", "streams": "Потоки", - "transcodes": "Transcodes", + "transcodes": "Перекодирования", "directplay": "Прямое воспроизведение", "bitrate": "Битрейт" }, @@ -215,18 +215,18 @@ "tv": "Сериалы" }, "sabnzbd": { - "rate": "", + "rate": "Скорость", "queue": "Очередь", "timeleft": "Осталось" }, "rutorrent": { "active": "Активно", - "upload": "Загрузка", + "upload": "Отдача", "download": "Скачивание" }, "transmission": { "download": "Скачивание", - "upload": "Загрузка", + "upload": "Отдача", "leech": "Лич", "seed": "Сид" }, @@ -295,7 +295,7 @@ "available": "Доступно" }, "seerr": { - "pending": "Pending", + "pending": "Ожидают", "approved": "Одобрено", "available": "Доступно", "completed": "Завершено", @@ -344,16 +344,16 @@ "address": "Адрес", "expires": "Истекает", "never": "Никогда", - "user": "User", - "hostname": "Hostname", - "name": "Name", - "client_version": "Client Version", - "os": "OS", - "created": "Created", - "authorized": "Authorized", - "is_external": "Is External", - "update_available": "Update Available", - "tags": "Tags", + "user": "Пользователь", + "hostname": "Имя хоста", + "name": "Имя", + "client_version": "Версия клиента", + "os": "ОС", + "created": "Создано", + "authorized": "Авторизовано", + "is_external": "Внешний", + "update_available": "Доступно обновление", + "tags": "Теги", "last_seen": "Последнее посещение", "now": "Только что", "years": "{{number}}г", @@ -363,8 +363,8 @@ "minutes": "{{number}}м", "seconds": "{{number}}с", "ago": "{{value}} назад", - "true": "Yes", - "false": "No" + "true": "Да", + "false": "Нет" }, "technitium": { "totalQueries": "Запросы", @@ -632,12 +632,12 @@ }, "pangolin": { "orgs": "Orgs", - "sites": "Sites", - "resources": "Resources", - "targets": "Targets", - "traffic": "Traffic", - "in": "In", - "out": "Out" + "sites": "Сайты", + "resources": "Ресурсы", + "targets": "Цели", + "traffic": "Трафик", + "in": "Входящий", + "out": "Исходящий" }, "peanut": { "battery_charge": "Заряд батареи", @@ -736,8 +736,8 @@ "volumeAvailable": "Доступно" }, "dispatcharr": { - "channels": "Channels", - "streams": "Streams" + "channels": "Каналы", + "streams": "Потоки" }, "mylar": { "series": "Серии", @@ -828,10 +828,10 @@ "series": "Серии" }, "booklore": { - "libraries": "Libraries", - "books": "Books", - "reading": "Reading", - "finished": "Finished" + "libraries": "Библиотеки", + "books": "Книги", + "reading": "Читаю", + "finished": "Завершено" }, "jdownloader": { "downloadCount": "Очередь", @@ -937,17 +937,17 @@ "criticals": "Критические" }, "ntfy": { - "title": "Title", - "priority": "Priority", + "title": "Название", + "priority": "Приоритет", "lastReceived": "Last Received", - "message": "Message", - "tags": "Tags", - "none": "None", - "min": "Min", - "low": "Low", - "default": "Default", - "high": "High", - "urgent": "Urgent" + "message": "Сообщение", + "tags": "Теги", + "none": "Отсутствует", + "min": "Минимальный", + "low": "Низкий", + "default": "По-умолчанию", + "high": "Высокий", + "urgent": "Срочный" }, "plantit": { "events": "События", @@ -1087,7 +1087,7 @@ }, "gitlab": { "groups": "Группы", - "issues": "Issues", + "issues": "Задачи", "merges": "Мердж-реквесты", "projects": "Проекты" }, @@ -1150,26 +1150,26 @@ "nextRenewingSubscription": "Следующая оплата" }, "unraid": { - "STARTED": "Started", - "STOPPED": "Stopped", + "STARTED": "Запущено", + "STOPPED": "Остановлено", "NEW_ARRAY": "Новый массив", - "RECON_DISK": "Reconstructing Disk", - "DISABLE_DISK": "Disk Disabled", - "SWAP_DSBL": "Swap Disable", - "INVALID_EXPANSION": "Invalid Expansion", + "RECON_DISK": "Восстановление Диска", + "DISABLE_DISK": "Диск отключен", + "SWAP_DSBL": "Swap отключён", + "INVALID_EXPANSION": "Неверное Расширение", "PARITY_NOT_BIGGEST": "Parity Not Biggest", - "TOO_MANY_MISSING_DISKS": "Too Many Missing Disks", - "NEW_DISK_TOO_SMALL": "New Disk Too Small", - "NO_DATA_DISKS": "No Data Disks", + "TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков", + "NEW_DISK_TOO_SMALL": "Новый диск слишком мал", + "NO_DATA_DISKS": "Нет дисков данных", "notifications": "Уведомления", "status": "Статус", "cpu": "ЦП", "memoryUsed": "Использовано ОЗУ", - "memoryAvailable": "Memory Available", + "memoryAvailable": "Доступная память", "arrayUsed": "Array Used", "arrayFree": "Array Free", - "poolUsed": "{{pool}} Used", - "poolFree": "{{pool}} Free" + "poolUsed": "{{pool}} Использовано", + "poolFree": "{{pool}} Свободно" }, "backrest": { "num_plans": "Plans", @@ -1180,29 +1180,29 @@ "bytes_added_30": "Bytes Added" }, "yourspotify": { - "songs": "Songs", + "songs": "Треков", "time": "Время", - "artists": "Artists" + "artists": "Исполнителей" }, "arcane": { - "containers": "Containers", - "images": "Images", - "image_updates": "Image Updates", - "images_unused": "Unused", - "environment_required": "Environment ID Required" + "containers": "Контейнеров", + "images": "Образов", + "image_updates": "Обновлений", + "images_unused": "Не используется", + "environment_required": "Требуется ID окружения" }, "dockhand": { - "running": "Running", - "stopped": "Stopped", - "cpu": "CPU", - "memory": "Memory", - "images": "Images", - "volumes": "Volumes", - "events_today": "Events Today", - "pending_updates": "Pending Updates", - "stacks": "Stacks", - "paused": "Paused", - "total": "Total", + "running": "Запущено", + "stopped": "Остановлено", + "cpu": "ЦП", + "memory": "ОЗУ", + "images": "Образов", + "volumes": "Томов", + "events_today": "Событий сегодня", + "pending_updates": "Обновлений", + "stacks": "Стеков", + "paused": "На паузе", + "total": "Всего", "environment_not_found": "Среда не найдена" }, "sparkyfitness": {