Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda4f4d4ed | ||
|
|
bb08a6d03b | ||
|
|
3ea74f12d2 | ||
|
|
9b7a796934 | ||
|
|
0eb5d72ad6 | ||
|
|
a07e0e5ab6 |
No files matched your search
@@ -1,5 +1,3 @@
|
||||
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
|
||||
|
||||
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
@@ -30,6 +30,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `BASE_URL` | Public HTTP(S) origin used for OAuth metadata and redirects. Set this when Homarr is behind a reverse proxy that does not preserve the public host and protocol. Paths, credentials, query parameters, and fragments are not supported. | For example `https://homarr.example.com` | - |
|
||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
volumes:
|
||||
- ./homarr/appdata:/appdata
|
||||
environment:
|
||||
- BASE_URL=your.internal.dns.address.here.com
|
||||
- BASE_URL=https://your.internal.dns.address.here.com
|
||||
- SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32`
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
@@ -27,7 +27,7 @@ import Alert from '@theme/Admonition';
|
||||
]}
|
||||
/>
|
||||
|
||||
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
|
||||
The storage values shown by these widgets use the root ZFS dataset's usable `used` and `available` space, including RAIDZ parity overhead and dataset limits.
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
@@ -52,21 +52,14 @@ The certificates are stored in the `/appdata/trusted-certificates` directory whi
|
||||
This means you can automate the update of the certificates by simply replacing or adding a new file in the directory.
|
||||
|
||||
## Obtaining certificates
|
||||
|
||||
Every integration has its own way of obtaining certificates.
|
||||
Generally it is recommended to search for the documentation of the integration you are using.
|
||||
Most of the time you can find the certificate in the file system of the integration.
|
||||
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
|
||||
- use `scp user@host:/path/to/file ./certificate.pem`
|
||||
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
|
||||
|
||||
Below you can find instructions for some of the integrations.
|
||||
|
||||
### Pi-hole (v6)
|
||||
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
|
||||
### Proxmox
|
||||
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
|
||||
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
|
||||
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
|
||||
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
|
||||
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
|
||||
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
|
||||
@@ -64,6 +64,16 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{ source: "/.well-known/:path*", destination: "/well-known/:path*" },
|
||||
{
|
||||
source: "/api/mcp/:transport/.well-known/:path*",
|
||||
destination: "/well-known/:path*/api/mcp/:transport",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// skipcq: JS-0116
|
||||
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
||||
async headers() {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
|
||||
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
resource: baseUrl,
|
||||
authorization_servers: [baseUrl],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { removeTrailingSlash } from "@homarr/common";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { getMcpBaseUrl } from "../_base-url";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
@@ -264,14 +265,17 @@ const handler = async (req: NextRequest) => {
|
||||
|
||||
if (!apiKeyValue) {
|
||||
recordAuthFailure(ipAddress);
|
||||
const baseUrl = extractBaseUrlFromHeaders(req.headers);
|
||||
const baseUrl = getMcpBaseUrl(req.headers);
|
||||
const resourcePath = removeTrailingSlash(new URL(req.url).pathname);
|
||||
return jsonErrorResponse(
|
||||
401,
|
||||
{
|
||||
error: "unauthorized",
|
||||
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
|
||||
},
|
||||
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
|
||||
{
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource${resourcePath}"`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export const getMcpBaseUrl = (headers: ReadonlyHeaders) =>
|
||||
removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers));
|
||||
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { getMcpBaseUrl } from "../../_base-url";
|
||||
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -137,7 +138,8 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/auth/login", url.origin);
|
||||
const baseUrl = getMcpBaseUrl(req.headers);
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
|
||||
return Response.redirect(loginUrl.toString());
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("~/env", () => ({ env: { BASE_URL: "https://homarr.example.com/" } }));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const request = new NextRequest("http://internal-container:3000/well-known/oauth-protected-resource", {
|
||||
headers: {
|
||||
host: "internal-container:3000",
|
||||
"x-forwarded-host": "untrusted.example.com",
|
||||
"x-forwarded-proto": "http",
|
||||
},
|
||||
});
|
||||
|
||||
describe("MCP OAuth metadata", () => {
|
||||
test("uses the configured public base URL for authorization server metadata", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-authorization-server"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
issuer: "https://homarr.example.com",
|
||||
authorization_endpoint: "https://homarr.example.com/api/mcp/oauth/authorize",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves a path-inserted authorization server issuer", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-authorization-server", "api", "mcp", "mcp"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
issuer: "https://homarr.example.com/api/mcp/mcp",
|
||||
authorization_endpoint: "https://homarr.example.com/api/mcp/oauth/authorize",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves the transport path in protected resource metadata", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-protected-resource", "api", "mcp", "mcp"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
resource: "https://homarr.example.com/api/mcp/mcp",
|
||||
authorization_servers: ["https://homarr.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the public origin for root protected resource metadata", async () => {
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ path: ["oauth-protected-resource"] }),
|
||||
});
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
resource: "https://homarr.example.com",
|
||||
authorization_servers: ["https://homarr.example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getMcpBaseUrl } from "~/app/api/mcp/_base-url";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest, props: { params: Promise<{ path: string[] }> }) {
|
||||
const [kind, ...rest] = (await props.params).path;
|
||||
const baseUrl = getMcpBaseUrl(req.headers);
|
||||
const identifierPath = rest.join("/");
|
||||
const identifier = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl;
|
||||
|
||||
if (kind === "oauth-authorization-server") {
|
||||
return Response.json(
|
||||
{
|
||||
issuer: identifier,
|
||||
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
|
||||
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "oauth-protected-resource") {
|
||||
return Response.json(
|
||||
{
|
||||
resource: identifier,
|
||||
authorization_servers: [baseUrl],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ error: "not_found" }, { status: 404, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { baseUrlSchema } from "./env";
|
||||
|
||||
describe("BASE_URL", () => {
|
||||
test.each(["http://localhost:7575", "https://homarr.example.com", "https://homarr.example.com/"])(
|
||||
"accepts the HTTP(S) origin %s",
|
||||
(value) => {
|
||||
expect(baseUrlSchema.safeParse(value).success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
"ftp://homarr.example.com",
|
||||
"https://user:password@homarr.example.com",
|
||||
"https://homarr.example.com/prefix",
|
||||
"https://homarr.example.com/.",
|
||||
"https://homarr.example.com/a/..",
|
||||
"https://homarr.example.com?preview=1",
|
||||
"https://homarr.example.com#preview",
|
||||
])("rejects the non-origin URL %s", (value) => {
|
||||
expect(baseUrlSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const httpOriginRegex = /^https?:\/\/[^/?#\\]+\/?$/i;
|
||||
|
||||
export const baseUrlSchema = z.url({ protocol: /^https?$/ }).refine((value) => {
|
||||
const url = new URL(value);
|
||||
return httpOriginRegex.test(value) && !url.username && !url.password;
|
||||
}, "BASE_URL must be an HTTP(S) origin without credentials, a path, query parameters, or a fragment");
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
BASE_URL: baseUrlSchema.optional(),
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.76.0",
|
||||
"version": "1.75.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -59,12 +59,10 @@ const ws = vi.hoisted(() => {
|
||||
}
|
||||
|
||||
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
|
||||
// A responder returning an Error mimics a method the server rejects, e.g. an unsupported filter.
|
||||
const body = result instanceof Error ? { error: { message: result.message } } : { result };
|
||||
const envelope =
|
||||
payload.jsonrpc === "2.0"
|
||||
? { jsonrpc: "2.0", id: payload.id, ...body }
|
||||
: { msg: "result", id: payload.id, ...body };
|
||||
? { jsonrpc: "2.0", id: payload.id, result }
|
||||
: { msg: "result", id: payload.id, result };
|
||||
this.respond(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
@@ -93,10 +91,7 @@ const ws = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
|
||||
|
||||
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
@@ -172,11 +167,6 @@ const happyResponder = (method: string) => {
|
||||
|
||||
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
|
||||
|
||||
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
|
||||
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
|
||||
|
||||
const ERROR_MESSAGE_LIMIT = 200;
|
||||
|
||||
let integrationCounter = 0;
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||
new TrueNasIntegration({
|
||||
@@ -198,7 +188,6 @@ describe("TrueNasIntegration", () => {
|
||||
ws.constructedUrls.length = 0;
|
||||
ws.failures.length = 0;
|
||||
ws.setResponder(happyResponder);
|
||||
logs.warn.mockClear();
|
||||
});
|
||||
|
||||
test("fetches and maps system info over the JSON-RPC API", async () => {
|
||||
@@ -241,112 +230,6 @@ describe("TrueNasIntegration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset is not returned", async () => {
|
||||
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// Dropping the pool would also hide its health status.
|
||||
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? [{ id: "tank", used: { parsed: null }, available: null }]
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
|
||||
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// A malformed payload must not take the rest of the system info down with it.
|
||||
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
|
||||
});
|
||||
|
||||
test("falls back to physical pool space when the dataset query is rejected", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
|
||||
// A rejected dataset query must not take the rest of the system info down with it.
|
||||
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
|
||||
});
|
||||
|
||||
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
|
||||
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
|
||||
: happyResponder(method),
|
||||
);
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
await integration.getSystemInfoAsync();
|
||||
|
||||
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
|
||||
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
|
||||
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
|
||||
expect(reason).toContain("[EINVAL] query-filters");
|
||||
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
|
||||
});
|
||||
|
||||
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
|
||||
ws.setResponder((method) => {
|
||||
if (method === "pool.query")
|
||||
return [
|
||||
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
|
||||
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
|
||||
];
|
||||
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
|
||||
return happyResponder(method);
|
||||
});
|
||||
const integration = createIntegration(credentials);
|
||||
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.smart).toMatchObject([
|
||||
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
|
||||
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
|
||||
]);
|
||||
expect(result.fileSystem).toMatchObject([
|
||||
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
|
||||
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports zero usage for an empty root dataset", async () => {
|
||||
ws.setResponder((method) =>
|
||||
method === "pool.dataset.query"
|
||||
|
||||
@@ -15,20 +15,6 @@ const logger = createLogger({ module: "trueNasIntegration" });
|
||||
|
||||
const NETWORK_MULTIPLIER = 100;
|
||||
|
||||
const ERROR_MESSAGE_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The error decorator replaces a rejected request with a generic message and moves the actionable
|
||||
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
|
||||
* reason is truncated before it reaches a log line that repeats on every poll.
|
||||
*/
|
||||
const describeRequestError = (error: unknown) => {
|
||||
const cause = error instanceof Error ? error.cause : undefined;
|
||||
const reason = cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error);
|
||||
|
||||
return reason.length > ERROR_MESSAGE_LIMIT ? `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…` : reason;
|
||||
};
|
||||
|
||||
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
private client?: TrueNasClient;
|
||||
|
||||
@@ -122,10 +108,9 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
.map((pool) => {
|
||||
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
||||
return {
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
...pool,
|
||||
allocated: pool.allocated,
|
||||
size: pool.size,
|
||||
free: pool.free,
|
||||
};
|
||||
}
|
||||
@@ -134,75 +119,39 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
})
|
||||
.filter((pool) => pool !== null);
|
||||
|
||||
if (activePools.length === 0) return [];
|
||||
|
||||
const datasets = await this.requestAsync("pool.dataset.query", [
|
||||
[["id", "in", activePools.map((pool) => pool.name)]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const parsedDatasets = await poolDatasetSchema.parseAsync(datasets);
|
||||
|
||||
const poolsWithUsableSpace = activePools
|
||||
.map((pool) => {
|
||||
const dataset = parsedDatasets.find((candidate) => candidate.id === pool.name);
|
||||
if (!dataset || dataset.used.parsed === null || dataset.available.parsed === null) return null;
|
||||
|
||||
return {
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
used: dataset.used.parsed,
|
||||
available: dataset.available.parsed,
|
||||
};
|
||||
})
|
||||
.filter((pool) => pool !== null);
|
||||
|
||||
logger.debug("Retrieved pools", {
|
||||
url: this.integration.url,
|
||||
totalCount: result.length,
|
||||
activeCount: activePools.length,
|
||||
activeCount: poolsWithUsableSpace.length,
|
||||
});
|
||||
|
||||
if (activePools.length === 0) return [];
|
||||
|
||||
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
|
||||
|
||||
return activePools.map((pool) => {
|
||||
const usableSpace = usableSpaceByPool.get(pool.name);
|
||||
if (!usableSpace) {
|
||||
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
|
||||
url: this.integration.url,
|
||||
pool: pool.name,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: pool.name,
|
||||
status: pool.status,
|
||||
healthy: pool.healthy,
|
||||
used: usableSpace?.used ?? pool.allocated,
|
||||
available: usableSpace?.available ?? pool.free,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
|
||||
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
|
||||
* See https://github.com/homarr-labs/homarr/issues/6566
|
||||
*
|
||||
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
|
||||
* its physical values instead of dropping it, which would also hide its health status:
|
||||
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
|
||||
* - response rejected → the payload does not match the expected shape
|
||||
* - dataset missing → the pool's root dataset was not returned by the query
|
||||
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
|
||||
*/
|
||||
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
|
||||
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
|
||||
|
||||
try {
|
||||
const datasets = await this.requestAsync("pool.dataset.query", [
|
||||
[["id", "in", poolNames]],
|
||||
{
|
||||
extra: {
|
||||
properties: ["used", "available"],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
|
||||
const used = dataset.used?.parsed;
|
||||
const available = dataset.available?.parsed;
|
||||
if (typeof used !== "number" || typeof available !== "number") continue;
|
||||
|
||||
usableSpaceByPool.set(dataset.id, { used, available });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
|
||||
url: this.integration.url,
|
||||
error: describeRequestError(error),
|
||||
});
|
||||
}
|
||||
|
||||
return usableSpaceByPool;
|
||||
return poolsWithUsableSpace;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,16 +292,11 @@ const poolSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
|
||||
// so they are optional here and the pool falls back to its physical values instead of failing
|
||||
// the whole request, which would also drop cpu, memory and network data
|
||||
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
|
||||
|
||||
const poolDatasetSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
used: poolDatasetPropertySchema,
|
||||
available: poolDatasetPropertySchema,
|
||||
used: z.object({ parsed: z.number().min(0).nullable() }),
|
||||
available: z.object({ parsed: z.number().min(0).nullable() }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
|
||||
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"beanbag420","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontentLine truncated
|
||||
@@ -1 +1 @@
|
||||
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated
|
||||
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/aaa1f083e04c572f953fde4563171e4a.jpeg"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"https://crowLine truncated
|
||||
Reference in new issue
Block a user