From c7153c99dc70938e647255ae13d94a2aa417877f Mon Sep 17 00:00:00 2001 From: Kat9_123 <56130489+Kat9-123@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:12:21 +0200 Subject: [PATCH] Feature: Syncthing service widget (#6865) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/syncthing.md | 15 ++++ mkdocs.yml | 1 + public/locales/en/common.json | 6 ++ src/widgets/components.js | 1 + src/widgets/syncthing/component.jsx | 50 +++++++++++++ src/widgets/syncthing/component.test.jsx | 93 ++++++++++++++++++++++++ src/widgets/syncthing/widget.js | 23 ++++++ src/widgets/syncthing/widget.test.js | 11 +++ src/widgets/widgets.js | 2 + 9 files changed, 202 insertions(+) create mode 100644 docs/widgets/services/syncthing.md create mode 100644 src/widgets/syncthing/component.jsx create mode 100644 src/widgets/syncthing/component.test.jsx create mode 100644 src/widgets/syncthing/widget.js create mode 100644 src/widgets/syncthing/widget.test.js diff --git a/docs/widgets/services/syncthing.md b/docs/widgets/services/syncthing.md new file mode 100644 index 000000000..f4d041951 --- /dev/null +++ b/docs/widgets/services/syncthing.md @@ -0,0 +1,15 @@ +--- +title: Syncthing +description: Syncthing Widget Configuration +--- + +Learn more about [Syncthing](https://syncthing.net/). + +Allowed fields: `["connected", "synced", "errors", "storage"]`. + +```yaml +widget: + type: syncthing + url: http://syncthing.host.or.ip + key: syncthingapikey +``` diff --git a/mkdocs.yml b/mkdocs.yml index adedc8824..5b4a53686 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -164,6 +164,7 @@ nav: - widgets/services/suwayomi.md - widgets/services/swagdashboard.md - widgets/services/syncthing-relay-server.md + - widgets/services/syncthing.md - widgets/services/tailscale.md - widgets/services/tandoor.md - widgets/services/technitium.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index f38444cc9..27d0b4c48 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1224,5 +1224,11 @@ "burned": "Burned", "remaining": "Remaining", "steps": "Steps" + }, + "syncthing": { + "connected": "Connected", + "synced": "Synced", + "errors": "Errors", + "storage": "Storage" } } diff --git a/src/widgets/components.js b/src/widgets/components.js index 5ea052af7..657077391 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -138,6 +138,7 @@ const components = { strelaysrv: dynamic(() => import("./strelaysrv/component")), swagdashboard: dynamic(() => import("./swagdashboard/component")), suwayomi: dynamic(() => import("./suwayomi/component")), + syncthing: dynamic(() => import("./syncthing/component")), tailscale: dynamic(() => import("./tailscale/component")), tandoor: dynamic(() => import("./tandoor/component")), tautulli: dynamic(() => import("./tautulli/component")), diff --git a/src/widgets/syncthing/component.jsx b/src/widgets/syncthing/component.jsx new file mode 100644 index 000000000..e83c8a246 --- /dev/null +++ b/src/widgets/syncthing/component.jsx @@ -0,0 +1,50 @@ +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; +import { useTranslation } from "next-i18next/pages"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { data: connectionData, error: connectionError } = useWidgetAPI(widget, "connections"); + const { data: completionData, error: completionError } = useWidgetAPI(widget, "completion"); + const { data: errorData, error: errorError } = useWidgetAPI(widget, "error"); + + if (connectionError || completionError || errorError) { + return ; + } + + if (!connectionData || !completionData || !errorData) { + return ( + + + + + + + ); + } + + const connections = Object.values(connectionData.connections); + + return ( + + c.connected).length })} / ${t("common.number", { value: connections.length })}`} + /> + + + + + ); +} diff --git a/src/widgets/syncthing/component.test.jsx b/src/widgets/syncthing/component.test.jsx new file mode 100644 index 000000000..43cfcabf8 --- /dev/null +++ b/src/widgets/syncthing/component.test.jsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom + +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "test-utils/render-with-providers"; +import { expectBlockValue } from "test-utils/widget-assertions"; + +const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); +vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); + +import Component from "./component"; + +describe("widgets/syncthing/component", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders placeholders while loading", () => { + useWidgetAPI.mockReturnValue({ data: undefined, error: undefined }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(container.querySelectorAll(".service-block")).toHaveLength(4); + expect(screen.getByText("syncthing.connected")).toBeInTheDocument(); + expect(screen.getByText("syncthing.synced")).toBeInTheDocument(); + expect(screen.getByText("syncthing.storage")).toBeInTheDocument(); + expect(screen.getByText("syncthing.errors")).toBeInTheDocument(); + }); + + it("renders metrics when loaded", () => { + useWidgetAPI + .mockReturnValueOnce({ + data: { + connections: { + "test-1": { connected: true }, + "test-2": { connected: false }, + }, + }, + error: undefined, + }) + .mockReturnValueOnce({ + data: { + completion: 75, + globalBytes: 1_234_567, + }, + error: undefined, + }) + .mockReturnValueOnce({ + data: { + errors: ["err 1", "err 2"], + }, + error: undefined, + }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expectBlockValue(container, "syncthing.connected", 1); + expectBlockValue(container, "syncthing.synced", 75); + expectBlockValue(container, "syncthing.errors", 2); + expectBlockValue(container, "syncthing.storage", "1234567"); + }); + + it.each(["connections", "completion", "error"])("renders errors from the %s endpoint", (endpoint) => { + useWidgetAPI.mockImplementation((_widget, requestedEndpoint) => ({ + data: undefined, + error: requestedEndpoint === endpoint ? `${endpoint} failed` : undefined, + })); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(container.textContent).toContain(`${endpoint} failed`); + }); + + it("renders zero when there are no errors", () => { + useWidgetAPI + .mockReturnValueOnce({ data: { connections: {} }, error: undefined }) + .mockReturnValueOnce({ data: { completion: 100, globalBytes: 0 }, error: undefined }) + .mockReturnValueOnce({ data: {}, error: undefined }); + + const { container } = renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expectBlockValue(container, "syncthing.errors", 0); + }); +}); diff --git a/src/widgets/syncthing/widget.js b/src/widgets/syncthing/widget.js new file mode 100644 index 000000000..b8ef14c6b --- /dev/null +++ b/src/widgets/syncthing/widget.js @@ -0,0 +1,23 @@ +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; + +const widget = { + api: "{url}/rest/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + connections: { + endpoint: "system/connections", + validate: ["connections"], + }, + completion: { + endpoint: "db/completion", + validate: ["completion", "globalBytes"], + }, + error: { + endpoint: "system/error", + validate: ["errors"], + }, + }, +}; + +export default widget; diff --git a/src/widgets/syncthing/widget.test.js b/src/widgets/syncthing/widget.test.js new file mode 100644 index 000000000..7ba2281b6 --- /dev/null +++ b/src/widgets/syncthing/widget.test.js @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; + +import { expectWidgetConfigShape } from "test-utils/widget-config"; + +import widget from "./widget"; + +describe("syncthing widget config", () => { + it("exports a valid widget config", () => { + expectWidgetConfigShape(widget); + }); +}); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 0fee26a21..3ccaddd25 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -128,6 +128,7 @@ import stocks from "./stocks/widget"; import strelaysrv from "./strelaysrv/widget"; import suwayomi from "./suwayomi/widget"; import swagdashboard from "./swagdashboard/widget"; +import syncthing from "./syncthing/widget"; import tailscale from "./tailscale/widget"; import tandoor from "./tandoor/widget"; import tautulli from "./tautulli/widget"; @@ -290,6 +291,7 @@ const widgets = { strelaysrv, swagdashboard, suwayomi, + syncthing, tailscale, tandoor, tautulli,