Compare commits

...

21 Commits

Author SHA1 Message Date
shamoon
a4e29bc7a7 1.11.0 2026-03-14 08:58:53 -07:00
shamoon
a7982bda06 Merge branch 'dev' 2026-03-14 08:58:38 -07:00
shamoon
f7c12ad642 Enhancement: better Crowdsec auth parsing, caching, and retries (#6419) 2026-03-13 21:58:24 -07:00
shamoon
a6639b04b9 Fix troubleshooting link in support.yml 2026-03-09 10:02:06 -07:00
shamoon
6b3bff1f1d Fix typo in shortcuts documentation 2026-03-07 16:13:08 -08:00
shamoon
597059045f Change: use byterate for beszel network field (#6402) 2026-03-06 23:20:38 -08:00
dependabot[bot]
b676424d98 Chore(deps): Bump docker/build-push-action from 6 to 7 (#6397)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 17:51:11 +00:00
dependabot[bot]
e87b62f3ac Chore(deps): Bump docker/setup-buildx-action from 3 to 4 (#6398)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 17:38:26 +00:00
dependabot[bot]
776f190aed Chore(deps): Bump docker/metadata-action from 5 to 6 (#6399)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 09:27:07 -08:00
dependabot[bot]
71a524da89 Chore(deps): Bump docker/setup-qemu-action from 3.7.0 to 4.0.0 (#6386)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 14:38:16 +00:00
dependabot[bot]
9dea3a4d4f Chore(deps): Bump react and react-dom (#6380)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-04 21:47:32 +00:00
dependabot[bot]
adc042fa8a Chore(deps): Bump next-i18next from 12.1.0 to 15.4.3 (#6376)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 13:12:08 -08:00
dependabot[bot]
f16878bca9 Chore(deps): Bump docker/login-action from 3 to 4 (#6385)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 13:10:07 -08:00
shamoon
01b951f3ba Create pr-quality.yml 2026-03-04 13:03:25 -08:00
dependabot[bot]
94122ba078 Chore(deps): Bump ical.js from 2.1.0 to 2.2.1 (#6377)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 20:54:24 +00:00
dependabot[bot]
fb88da5a5a Chore(deps-dev): Bump jsdom from 26.1.0 to 28.1.0 (#6378)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 12:36:09 -08:00
dependabot[bot]
de7e730283 Chore(deps-dev): Bump prettier from 3.7.3 to 3.8.1 (#6379)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 19:59:25 +00:00
shamoon
b5b502b433 Enhancement: use lighter endpoints for qbittorrent (#6388) 2026-03-04 11:40:20 -08:00
Hugo CAMPION
db9b2d0245 Chore: add security context, liveness probe and config mount to k8s deployment example (#6375)
Signed-off-by: CAMPION Hugo <h.campion@geco-it.fr>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-03-02 16:22:28 -08:00
shamoon
e3ca0adf11 Documentation: add 'unit' option for temperature in glances config 2026-02-20 22:12:12 -08:00
Kristiyan Nikolov
d62404f164 Documentation: Fix doc heading for PWA/App icons (#6290) 2026-02-05 11:36:19 -08:00
17 changed files with 635 additions and 418 deletions

View File

@@ -51,7 +51,7 @@ body:
id: troubleshooting
attributes:
label: Troubleshooting
description: Please include output from your [troubleshooting steps](https://gethomepage.dev/more/troubleshooting/#service-widget-errors), if relevant.
description: Please include output from your [troubleshooting steps](https://gethomepage.dev/troubleshooting/#service-widget-errors), if relevant.
validations:
required: true
- type: markdown

View File

@@ -66,7 +66,7 @@ jobs:
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -115,7 +115,7 @@ jobs:
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -123,20 +123,20 @@ jobs:
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v3.7.0
uses: docker/setup-qemu-action@v4.0.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

18
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4

View File

@@ -129,7 +129,7 @@ A progressive web app is an app that can be installed on a device and provide us
More information on PWAs can be found in [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps).
## App icons
### App icons
You can set custom icons for installable apps. More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/icons).
@@ -150,7 +150,7 @@ For icon `src` you can pass either full URL or a local path relative to the `/ap
### Shortcuts
Shortcuts can e used to specify links to tabs, to be preselected when the homepage is opened as an app.
Shortcuts can be used to specify links to tabs, to be preselected when the homepage is opened as an app.
More information about how you can set them can be found in the [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/shortcuts).
```yaml

View File

@@ -223,13 +223,33 @@ spec:
- name: homepage
image: "ghcr.io/gethomepage/homepage:latest"
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
env:
- name: MY_POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: HOMEPAGE_ALLOWED_HOSTS
value: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
value: "$(MY_POD_IP):3000,gethomepage.dev" # See gethomepage.dev/installation/#homepage_allowed_hosts . Value before the comma is required for the k8s probe
ports:
- name: http
containerPort: 3000
protocol: TCP
livenessProbe:
httpGet:
path: /api/healthcheck
port: http
initialDelaySeconds: 5
periodSeconds: 15
volumeMounts:
- mountPath: /app/config/custom.js
name: homepage-config

View File

@@ -16,6 +16,7 @@ The Glances widget allows you to monitor the resources (CPU, memory, storage, te
cpu: true # optional, enabled by default, disable by setting to false
mem: true # optional, enabled by default, disable by setting to false
cputemp: true # disabled by default
unit: imperial # optional for temp, default is metric
uptime: true # disabled by default
disk: / # disabled by default, use mount point of disk(s) in glances. Can also be a list (see below)
diskUnits: bytes # optional, bytes (default) or bbytes. Only applies to disk
@@ -31,5 +32,3 @@ disk:
- /boot
...
```
_Added in v0.4.18, updated in v0.6.11, v0.6.21_

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.10.1",
"version": "1.11.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -22,19 +22,19 @@
"follow-redirects": "^1.15.11",
"gamedig": "^5.3.2",
"i18next": "^25.8.0",
"ical.js": "^2.1.0",
"ical.js": "^2.2.1",
"js-yaml": "^4.1.1",
"json-rpc-2.0": "^1.7.0",
"luxon": "^3.6.1",
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.5.11",
"next-i18next": "^12.1.0",
"next-i18next": "^15.4.3",
"ping": "^0.4.4",
"pretty-bytes": "^7.1.0",
"raw-body": "^3.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^15.5.3",
"react-icons": "^5.5.0",
"recharts": "^3.1.2",
@@ -63,9 +63,9 @@
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"prettier": "^3.7.3",
"prettier": "^3.8.1",
"prettier-plugin-organize-imports": "^4.3.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.18",

726
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { fireEvent, screen } from "@testing-library/react";
import { act, fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
@@ -188,7 +188,9 @@ describe("components/services/item", () => {
// Still rendered while the close animation runs.
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(300);
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
vi.useRealTimers();

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { act, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
@@ -21,7 +21,9 @@ describe("components/widgets/datetime", () => {
// `render` wraps in `act`, so effects should flush synchronously.
expect(screen.getByText(expected0)).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(1000);
act(() => {
vi.advanceTimersByTime(1000);
});
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
expect(screen.getByText(expected1)).toBeInTheDocument();

View File

@@ -54,7 +54,10 @@ export default function Component({ service }) {
<Block label="beszel.cpu" value={t("common.percent", { value: system.info.cpu, maximumFractionDigits: 2 })} />
<Block label="beszel.memory" value={t("common.percent", { value: system.info.mp, maximumFractionDigits: 2 })} />
<Block label="beszel.disk" value={t("common.percent", { value: system.info.dp, maximumFractionDigits: 2 })} />
<Block label="beszel.network" value={t("common.percent", { value: system.info.b, maximumFractionDigits: 2 })} />
<Block
label="beszel.network"
value={t("common.byterate", { value: system.info.bb, maximumFractionDigits: 2 })}
/>
</Container>
);
}

View File

@@ -76,6 +76,35 @@ describe("widgets/beszel/component", () => {
expect(screen.queryByText("beszel.updated")).toBeNull();
});
it("renders optional fields", () => {
useWidgetAPI.mockReturnValue({
data: {
totalItems: 1,
items: [
{
id: "sys1",
name: "MySystem",
status: "up",
updated: 123,
info: { cpu: 10, mp: 20, dp: 30, b: 40, bb: 14.5 },
},
],
},
error: undefined,
});
const service = {
widget: { type: "beszel", systemId: "sys1", fields: ["name", "disk", "network"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["name", "disk", "network"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expectBlockValue(container, "beszel.name", "MySystem");
expectBlockValue(container, "beszel.disk", 30);
expectBlockValue(container, "beszel.network", 14.5);
});
it("renders error when systemId is not found", () => {
useWidgetAPI.mockReturnValue({
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },

View File

@@ -25,13 +25,25 @@ async function login(widget, service) {
}),
});
const dataParsed = JSON.parse(data);
let dataParsed;
try {
dataParsed = JSON.parse(data);
} catch {
logger.error("Failed to parse Crowdsec login response, status: %d", status);
cache.del(`${sessionTokenCacheKey}.${service}`);
return null;
}
if (!(status === 200) || !dataParsed.token) {
if (status !== 200 || !dataParsed.token) {
logger.error("Failed to login to Crowdsec API, status: %d", status);
cache.del(`${sessionTokenCacheKey}.${service}`);
return null;
}
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, new Date(dataParsed.expire) - new Date());
const ttl = Math.max(new Date(dataParsed.expire) - new Date(), 1);
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, ttl);
return dataParsed.token;
}
export default async function crowdsecProxyHandler(req, res) {
@@ -48,11 +60,10 @@ export default async function crowdsecProxyHandler(req, res) {
return res.status(400).json({ error: "Invalid widget configuration" });
}
if (!cache.get(`${sessionTokenCacheKey}.${service}`)) {
await login(widget, service);
let token = cache.get(`${sessionTokenCacheKey}.${service}`);
if (!token) {
token = await login(widget, service);
}
const token = cache.get(`${sessionTokenCacheKey}.${service}`);
if (!token) {
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
}
@@ -71,7 +82,20 @@ export default async function crowdsecProxyHandler(req, res) {
logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
const [status, , data] = await httpProxy(url, params);
let [status, , data] = await httpProxy(url, params);
if (status === 401) {
logger.debug("Crowdsec API returned 401, refreshing token and retrying request");
cache.del(`${sessionTokenCacheKey}.${service}`);
const refreshedToken = await login(widget, service);
if (!refreshedToken) {
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
}
params.headers.Authorization = `Bearer ${refreshedToken}`;
[status, , data] = await httpProxy(url, params);
}
if (status !== 200) {
logger.error("Error calling Crowdsec API: %d. Data: %s", status, data);

View File

@@ -89,4 +89,76 @@ describe("widgets/crowdsec/proxy", () => {
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
it("re-authenticates and retries once when API returns 401", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-old", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([401, "application/json", Buffer.from("bad token")])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-new", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(4);
expect(httpProxy.mock.calls[3][1].headers.Authorization).toBe("Bearer tok-new");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns 500 when 401 refresh fails to get a new token", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-old", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([401, "application/json", Buffer.from("bad token")])
.mockResolvedValueOnce([500, "application/json", JSON.stringify({ error: "no token" })]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
it("returns 500 when login response is not JSON", async () => {
getServiceWidget.mockResolvedValue({ type: "crowdsec", url: "http://cs", username: "machine", password: "pw" });
httpProxy.mockResolvedValueOnce([200, "text/plain", "not-json"]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
});

View File

@@ -10,13 +10,28 @@ export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: torrentData, error: torrentError } = useWidgetAPI(widget, "torrents");
const { data: transferData, error: transferError } = useWidgetAPI(widget, "transfer");
const { data: totalCountData, error: totalCountError } = useWidgetAPI(widget, "torrentCount");
const { data: completedCountData, error: completedCountError } = useWidgetAPI(widget, "torrentCount", {
filter: "completed",
});
const { data: leechTorrentData, error: leechTorrentError } = useWidgetAPI(
widget,
widget?.enableLeechProgress ? "torrents" : "",
widget?.enableLeechProgress ? { filter: "downloading" } : undefined,
);
if (torrentError) {
return <Container service={service} error={torrentError} />;
const apiError = transferError || totalCountError || completedCountError || leechTorrentError;
if (apiError) {
return <Container service={service} error={apiError} />;
}
if (!torrentData) {
if (
!transferData ||
totalCountData === undefined ||
completedCountData === undefined ||
(widget?.enableLeechProgress && !leechTorrentData)
) {
return (
<Container service={service}>
<Block label="qbittorrent.leech" />
@@ -27,24 +42,15 @@ export default function Component({ service }) {
);
}
let rateDl = 0;
let rateUl = 0;
let completed = 0;
const leechTorrents = [];
const rateDl = Number(transferData?.dl_info_speed ?? 0);
const rateUl = Number(transferData?.up_info_speed ?? 0);
const totalCount = Number(totalCountData?.all ?? totalCountData?.count ?? totalCountData ?? 0);
const completedCount = Number(
completedCountData?.completed ?? completedCountData?.count ?? completedCountData?.all ?? completedCountData ?? 0,
);
const leech = Math.max(0, totalCount - completedCount);
for (let i = 0; i < torrentData.length; i += 1) {
const torrent = torrentData[i];
rateDl += torrent.dlspeed;
rateUl += torrent.upspeed;
if (torrent.progress === 1) {
completed += 1;
}
if (torrent.state.includes("DL") || torrent.state === "downloading") {
leechTorrents.push(torrent);
}
}
const leech = torrentData.length - completed;
const leechTorrents = Array.isArray(leechTorrentData) ? [...leechTorrentData] : [];
const statePriority = [
"downloading",
"forcedDL",
@@ -55,7 +61,6 @@ export default function Component({ service }) {
"queuedDL",
"pausedDL",
];
leechTorrents.sort((firstTorrent, secondTorrent) => {
const firstStateIndex = statePriority.indexOf(firstTorrent.state);
const secondStateIndex = statePriority.indexOf(secondTorrent.state);
@@ -70,7 +75,7 @@ export default function Component({ service }) {
<Container service={service}>
<Block label="qbittorrent.leech" value={t("common.number", { value: leech })} />
<Block label="qbittorrent.download" value={t("common.bibyterate", { value: rateDl, decimals: 1 })} />
<Block label="qbittorrent.seed" value={t("common.number", { value: completed })} />
<Block label="qbittorrent.seed" value={t("common.number", { value: completedCount })} />
<Block label="qbittorrent.upload" value={t("common.bibyterate", { value: rateUl, decimals: 1 })} />
</Container>
{widget?.enableLeechProgress &&

View File

@@ -34,19 +34,38 @@ describe("widgets/qbittorrent/component", () => {
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
});
it("computes leech/seed counts and upload/download rates, and can render leech progress entries", () => {
useWidgetAPI.mockReturnValue({
data: [
{ name: "A", dlspeed: 10, upspeed: 1, progress: 1, state: "uploading" },
{ name: "B", dlspeed: 5, upspeed: 2, progress: 0.5, state: "downloading", eta: 60, size: 100, amount_left: 50 },
],
error: undefined,
it("uses lightweight endpoints for counts/rates and filtered torrents for leech progress", () => {
useWidgetAPI.mockImplementation((_widget, endpoint, query) => {
if (endpoint === "transfer") {
return { data: { dl_info_speed: 15, up_info_speed: 3 }, error: undefined };
}
if (endpoint === "torrentCount" && !query) {
return { data: 2, error: undefined };
}
if (endpoint === "torrentCount" && query?.filter === "completed") {
return { data: 1, error: undefined };
}
if (endpoint === "torrents" && query?.filter === "downloading") {
return {
data: [
{
name: "B",
progress: 0.5,
state: "downloading",
eta: 60,
size: 100,
amount_left: 50,
},
],
error: undefined,
};
}
return { data: undefined, error: undefined };
});
const service = { widget: { type: "qbittorrent", enableLeechProgress: true } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// total=2, completed=1 => leech=1
expectBlockValue(container, "qbittorrent.leech", 1);
expectBlockValue(container, "qbittorrent.seed", 1);
expectBlockValue(container, "qbittorrent.download", 15);

View File

@@ -4,8 +4,16 @@ const widget = {
proxyHandler: qbittorrentProxyHandler,
mappings: {
transfer: {
endpoint: "transfer/info",
},
torrentCount: {
endpoint: "torrents/count",
optionalParams: ["filter"],
},
torrents: {
endpoint: "torrents/info",
optionalParams: ["filter"],
},
},
};