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,