Compare commits

...

8 Commits

Author SHA1 Message Date
shamoon
233721cc90 1.13.1
Some checks failed
Docs / Test Build Docs (push) Has been cancelled
Tests / vitest (4) (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Docs / Build & Deploy Docs (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Tests / vitest (1) (push) Has been cancelled
Tests / vitest (2) (push) Has been cancelled
Tests / vitest (3) (push) Has been cancelled
2026-05-11 08:42:02 -07:00
shamoon
d294a25145 Merge branch 'dev' 2026-05-11 08:13:31 -07:00
github-actions[bot]
0ff9af4c3c New Crowdin translations by GitHub Action (#6647)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-11 08:11:24 -07:00
shamoon
a6c753c8aa Fix: include tasks params for PBS widget (#6655) 2026-05-11 00:04:33 -07:00
shamoon
5d71c3aa65 Fix: allow empty data for ntfy widget (#6653) 2026-05-10 07:28:31 -07:00
shamoon
1cc4608d72 Enhancement: support qBittorrent v5.2.0 api changes (#6652) 2026-05-10 06:51:22 -07:00
shamoon
d65e5447e4 Add Issue Triage workflow 2026-05-08 16:21:48 -07:00
shamoon
d3256596d8 Enhance release-drafter autolabeler rules 2026-05-08 16:19:33 -07:00
13 changed files with 247 additions and 127 deletions

View File

@@ -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/**'

43
.github/workflows/issue-triage.yml vendored Normal file
View File

@@ -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',
});

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.13.0",
"version": "1.13.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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,16 +30,12 @@ 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;
}
});
}
}
if (!valid) {
logger.error(

View File

@@ -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();
});
});

View File

@@ -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);
},
},
},
};

View File

@@ -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" });
});
});

View File

@@ -10,6 +10,7 @@ const widget = {
},
"nodes/localhost/tasks": {
endpoint: "nodes/localhost/tasks",
params: ["errors", "limit", "since"],
},
"nodes/localhost/status": {
endpoint: "nodes/localhost/status",

View File

@@ -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"]);
});
});

View File

@@ -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);
}

View File

@@ -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" });