Skip to content

Commit 92597dd

Browse files
authored
Merge branch 'dev' into feat/auto-mobile-boards
2 parents 535a8fc + 7bbfd74 commit 92597dd

14 files changed

Lines changed: 311 additions & 177 deletions

File tree

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ body:
3333
options:
3434
# The below comment is used to insert a new version with on-release.yml
3535
#NEXT_VERSION#
36+
- 1.71.0
3637
- 1.70.0
3738
- 1.69.2
3839
- 1.69.0

apps/docs/docs/getting-started/installation/helm.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
44

5-
![Version: 8.22.0](https://img.shields.io/badge/Version-8.22.0-informational?style=flat)
5+
![Version: 8.23.0](https://img.shields.io/badge/Version-8.23.0-informational?style=flat)
66
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
7-
![AppVersion: v1.70.0](https://img.shields.io/badge/AppVersion-v1.70.0-informational?style=flat)
7+
![AppVersion: v1.71.0](https://img.shields.io/badge/AppVersion-v1.71.0-informational?style=flat)
88

99
A Helm chart to deploy homarr for Kubernetes
1010

@@ -259,7 +259,7 @@ httproute:
259259
value: /
260260
backendRefs:
261261
- name: homarr
262-
port: 8080
262+
port: 7575
263263
````
264264
</details>
265265

@@ -419,17 +419,17 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
419419
| envSecrets.dbEncryption.key | string | `"db-encryption-key"` | Secret key for SECRET_ENCRYPTION_KEY can be generated with `openssl rand -hex 32` |
420420
| fullnameOverride | string | `""` | Overrides chart's fullname |
421421
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
422-
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
422+
| httproute | object | `{"enabled":false,"hostnames":["chart-example.local"],"parentRefs":[{"name":"my-gateway","namespace":"default"}],"rules":[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]}` | Gateway API HTTPRoute configuration |
423423
| httproute.enabled | bool | `false` | Enable HTTPRoute |
424424
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
425425
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
426-
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
426+
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
427427
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
428428
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
429429
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
430430
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
431431
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
432-
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
432+
| image.tag | string | `"v1.71.0"` | Overrides the image tag whose default is the chart appVersion |
433433
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
434434
| ingress.annotations | object | `{}` | Ingress annotations |
435435
| ingress.enabled | bool | `false` | Enable ingress |

apps/docs/docs/widgets/beszel-system-stats/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
1515

1616
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
1717

18-
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
18+
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
1919

2020
### Screenshots
2121

apps/nextjs/src/app/[locale]/manage/layout.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
6161
icon: IconBox,
6262
href: "/manage/apps",
6363
label: t("items.apps"),
64-
hidden: !session?.user.permissions.includes("board-modify-all"),
64+
hidden: !session?.user.permissions.includes("app-create"),
6565
iconProps: {
6666
strokeWidth: 2.5,
6767
},
@@ -71,20 +71,20 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
7171
icon: IconAffiliateFilled,
7272
href: "/manage/integrations",
7373
label: t("items.integrations"),
74-
hidden: !session,
74+
hidden: !session?.user.permissions.includes("integration-create"),
7575
"data-onboarding-tour-id": "manage-integrations",
7676
},
7777
{
7878
icon: IconApi,
7979
href: "/manage/custom-widgets",
8080
label: t("items.customWidgets"),
81-
hidden: !session,
81+
hidden: !session?.user.permissions.includes("admin"),
8282
},
8383
{
8484
icon: IconSearch,
8585
href: "/manage/search-engines",
8686
label: t("items.searchEngies"),
87-
hidden: !session,
87+
hidden: !session?.user.permissions.includes("search-engine-create"),
8888
iconProps: {
8989
strokeWidth: 2.5,
9090
},
@@ -94,7 +94,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
9494
icon: IconPhotoFilled,
9595
href: "/manage/medias",
9696
label: t("items.medias"),
97-
hidden: !session,
97+
hidden: !session?.user.permissions.includes("media-upload"),
9898
"data-onboarding-tour-id": "manage-medias",
9999
},
100100
{
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { BoundedAsyncQueue } from "../bounded-async-queue";
4+
5+
describe("BoundedAsyncQueue", () => {
6+
test("keeps only the newest values when the queue is full", async () => {
7+
const queue = new BoundedAsyncQueue<number>(2);
8+
queue.push(1);
9+
queue.push(2);
10+
queue.push(3);
11+
queue.close();
12+
13+
await expect(queue.next()).resolves.toEqual({ value: 2, done: false });
14+
await expect(queue.next()).resolves.toEqual({ value: 3, done: false });
15+
await expect(queue.next()).resolves.toEqual({ value: undefined, done: true });
16+
});
17+
18+
test("resolves a pending read as soon as a value arrives", async () => {
19+
const queue = new BoundedAsyncQueue<string>(1);
20+
const next = queue.next();
21+
22+
queue.push("latest");
23+
24+
await expect(next).resolves.toEqual({ value: "latest", done: false });
25+
});
26+
27+
test("resolves a pending read when the producer closes", async () => {
28+
const queue = new BoundedAsyncQueue<number>(1);
29+
const next = queue.next();
30+
31+
queue.close();
32+
33+
await expect(next).resolves.toEqual({ value: undefined, done: true });
34+
});
35+
36+
test("rejects a pending read when the producer fails", async () => {
37+
const queue = new BoundedAsyncQueue<number>(1);
38+
const next = queue.next();
39+
const error = new Error("upstream failed");
40+
41+
queue.fail(error);
42+
43+
await expect(next).rejects.toBe(error);
44+
});
45+
});

packages/api/src/router/widgets/beszel.ts

Lines changed: 75 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { TRPCError } from "@trpc/server";
2-
import { observable } from "@trpc/server/observable";
32
import { z } from "zod/v4";
43

54
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,8 +13,10 @@ import {
1413
import { settleIntegrationQueries } from "../../settle-integrations";
1514
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
1615
import { createTRPCRouter, publicProcedure } from "../../trpc";
16+
import { BoundedAsyncQueue } from "./bounded-async-queue";
1717

1818
const logger = createLogger({ module: "beszelRouter" });
19+
const MAX_PENDING_LIVE_EVENTS = 4;
1920

2021
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
2122

@@ -203,98 +204,94 @@ export const beszelRouter = createTRPCRouter({
203204
systemId: z.string(),
204205
}),
205206
)
206-
.subscription(({ ctx, input }) => {
207-
return observable<LiveStatsEvent>((emit) => {
208-
const controller = new AbortController();
209-
let isActive = true;
210-
let emittedEventCount = 0;
207+
.subscription(async function* ({ ctx, input, signal }) {
208+
const integration = ctx.integrations[0];
209+
if (!integration) {
210+
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
211+
}
211212

212-
logger.debug("Beszel realtime subscription started", {
213-
userId: ctx.session?.user?.id,
214-
integrationIds: ctx.integrations.map((integration) => integration.id),
215-
systemId: input.systemId,
216-
});
213+
const queue = new BoundedAsyncQueue<LiveStatsEvent>(MAX_PENDING_LIVE_EVENTS);
214+
const controller = new AbortController();
215+
let emittedEventCount = 0;
217216

218-
void (async () => {
219-
const integration = ctx.integrations[0];
220-
if (!integration) {
221-
emit.error(
222-
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
223-
);
224-
return;
225-
}
217+
const stop = () => {
218+
controller.abort();
219+
void queue.return();
220+
};
221+
if (signal?.aborted) return;
222+
signal?.addEventListener("abort", stop, { once: true });
226223

224+
logger.debug("Beszel realtime subscription started", {
225+
userId: ctx.session?.user?.id,
226+
integrationIds: ctx.integrations.map((candidate) => candidate.id),
227+
systemId: input.systemId,
228+
});
229+
230+
try {
231+
const instance = await createIntegrationAsync(integration);
232+
if (controller.signal.aborted) return;
233+
void (async () => {
227234
try {
228-
const instance = await createIntegrationAsync(integration);
229-
if (typeof instance.subscribeRealtimeMetrics === "function") {
230-
await instance.subscribeRealtimeMetrics(
231-
input.systemId,
232-
(event) => {
233-
if (!isActive) return;
234-
emittedEventCount += 1;
235-
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
236-
logger.debug("Forwarding Beszel realtime events", {
237-
userId: ctx.session?.user?.id,
238-
integrationId: integration.id,
239-
systemId: input.systemId,
240-
eventType: event.type,
241-
emittedEventCount,
242-
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
243-
});
244-
}
245-
emit.next(event);
246-
},
247-
controller.signal,
248-
);
249-
} else {
250-
emit.error(
251-
new TRPCError({
252-
code: "METHOD_NOT_SUPPORTED",
253-
message: `Integration ${integration.kind} does not support realtime metrics`,
254-
}),
255-
);
235+
if (typeof instance.subscribeRealtimeMetrics !== "function") {
236+
throw new TRPCError({
237+
code: "METHOD_NOT_SUPPORTED",
238+
message: `Integration ${integration.kind} does not support realtime metrics`,
239+
});
256240
}
241+
242+
await instance.subscribeRealtimeMetrics(
243+
input.systemId,
244+
(event) => {
245+
emittedEventCount += 1;
246+
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
247+
logger.debug("Forwarding Beszel realtime events", {
248+
userId: ctx.session?.user?.id,
249+
integrationId: integration.id,
250+
systemId: input.systemId,
251+
eventType: event.type,
252+
emittedEventCount,
253+
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
254+
});
255+
}
256+
queue.push(event);
257+
},
258+
controller.signal,
259+
);
260+
queue.close();
257261
} catch (error) {
258-
if (isActive) {
259-
logger.warn("Beszel realtime subscription failed", {
260-
userId: ctx.session?.user?.id,
261-
integrationId: integration.id,
262-
systemId: input.systemId,
263-
emittedEventCount,
264-
error: errorMessage(error),
265-
});
266-
emit.error(
267-
error instanceof TRPCError
268-
? error
269-
: new TRPCError({
270-
code: "INTERNAL_SERVER_ERROR",
271-
message: error instanceof Error ? error.message : String(error),
272-
}),
273-
);
262+
if (controller.signal.aborted) {
263+
queue.close();
264+
return;
274265
}
275-
}
276266

277-
if (isActive) {
278-
logger.debug("Beszel realtime subscription completed", {
267+
logger.warn("Beszel realtime subscription failed", {
279268
userId: ctx.session?.user?.id,
280269
integrationId: integration.id,
281270
systemId: input.systemId,
282271
emittedEventCount,
272+
error: errorMessage(error),
283273
});
284-
emit.complete();
274+
queue.fail(
275+
error instanceof TRPCError
276+
? error
277+
: new TRPCError({
278+
code: "INTERNAL_SERVER_ERROR",
279+
message: error instanceof Error ? error.message : String(error),
280+
}),
281+
);
285282
}
286283
})();
287284

288-
return () => {
289-
isActive = false;
290-
logger.debug("Beszel realtime subscription cancelled", {
291-
userId: ctx.session?.user?.id,
292-
integrationIds: ctx.integrations.map((integration) => integration.id),
293-
systemId: input.systemId,
294-
emittedEventCount,
295-
});
296-
controller.abort();
297-
};
298-
});
285+
for await (const event of queue) yield event;
286+
} finally {
287+
stop();
288+
signal?.removeEventListener("abort", stop);
289+
logger.debug("Beszel realtime subscription stopped", {
290+
userId: ctx.session?.user?.id,
291+
integrationId: integration.id,
292+
systemId: input.systemId,
293+
emittedEventCount,
294+
});
295+
}
299296
}),
300297
});

0 commit comments

Comments
 (0)