Feature: Syncthing service widget (#6865)
Release Drafter / Auto Label PR (push) Has been cancelled
Docker CI / Docker Build & Push (push) Has been cancelled
Lint / Linting Checks (push) Has been cancelled
Release Drafter / Update Release Draft (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
Tests / vitest (4) (push) Has been cancelled

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Kat9_123
2026-07-13 02:12:21 +02:00
committed by GitHub
parent 2b22049a37
commit c7153c99dc
9 changed files with 202 additions and 0 deletions
+15
View File
@@ -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
```
+1
View File
@@ -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
+6
View File
@@ -1224,5 +1224,11 @@
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
},
"syncthing": {
"connected": "Connected",
"synced": "Synced",
"errors": "Errors",
"storage": "Storage"
}
}
+1
View File
@@ -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")),
+50
View File
@@ -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 <Container service={service} error={connectionError ?? completionError ?? errorError} />;
}
if (!connectionData || !completionData || !errorData) {
return (
<Container service={service}>
<Block label="syncthing.connected" />
<Block label="syncthing.synced" />
<Block label="syncthing.errors" />
<Block label="syncthing.storage" />
</Container>
);
}
const connections = Object.values(connectionData.connections);
return (
<Container service={service}>
<Block
label="syncthing.connected"
value={`${t("common.number", { value: connections.filter((c) => c.connected).length })} / ${t("common.number", { value: connections.length })}`}
/>
<Block label="syncthing.synced" value={t("common.percent", { value: completionData.completion })} />
<Block
label="syncthing.errors"
value={t("common.number", { value: errorData.errors ? errorData.errors.length : 0 })}
/>
<Block
label="syncthing.storage"
value={t("common.bytes", { value: completionData.globalBytes, maximumFractionDigits: 1, binary: true })}
/>
</Container>
);
}
+93
View File
@@ -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(<Component service={{ widget: { type: "syncthing" } }} />, {
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(<Component service={{ widget: { type: "syncthing" } }} />, {
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(<Component service={{ widget: { type: "syncthing" } }} />, {
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(<Component service={{ widget: { type: "syncthing" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "syncthing.errors", 0);
});
});
+23
View File
@@ -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;
+11
View File
@@ -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);
});
});
+2
View File
@@ -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,