diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx
index f2ae41d4b..d39d2f25b 100644
--- a/apps/docs/docs/advanced/environment-variables/index.mdx
+++ b/apps/docs/docs/advanced/environment-variables/index.mdx
@@ -30,7 +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` |
-| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
+| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, location search, 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` |
diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx
index 0ff4081d7..0542887cc 100644
--- a/apps/docs/docs/widgets/weather/index.mdx
+++ b/apps/docs/docs/widgets/weather/index.mdx
@@ -1,26 +1,22 @@
---
-title: 'Weather'
-description: 'Displays the current weather information of a set location.'
+title: "Weather"
+description: "Displays the current weather information of a set location."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { weatherWidget } from '.';
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { weatherWidget } from ".";
-
-
+
The Weather Widget uses your location to display the current, highest and lowest temperature.
### Screenshots
-import forecastWeather from './img/forecast.png';
-import currentWeather from './img/current.png';
+import forecastWeather from "./img/forecast.png";
+import currentWeather from "./img/current.png";
@@ -32,9 +28,15 @@ import currentWeather from './img/current.png';
### Configuration
+
### Data source
We use the Open Meteo API to fetch the weather and locations.
-If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
\ No newline at end of file
+If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
+
+:::note
+When the `NO_EXTERNAL_CONNECTION` environment variable is enabled, the widget (and the city
+search inside its configuration) cannot load any data from Open Meteo.
+:::
diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts
index 99bb9c3af..556b64ab1 100644
--- a/packages/api/src/router/location.ts
+++ b/packages/api/src/router/location.ts
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
+import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
@@ -37,6 +38,8 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
+ if (env.NO_EXTERNAL_CONNECTION) return { results: [] };
+
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
diff --git a/packages/api/src/router/test/location.spec.ts b/packages/api/src/router/test/location.spec.ts
new file mode 100644
index 000000000..0641dffb1
--- /dev/null
+++ b/packages/api/src/router/test/location.spec.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+import { createDb } from "@homarr/db/test";
+
+const mocks = vi.hoisted(() => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+ env: { NO_EXTERNAL_CONNECTION: false },
+}));
+
+vi.mock("@homarr/common/env", () => ({
+ env: mocks.env,
+}));
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: mocks.fetchWithTrustedCertificatesAsync,
+}));
+
+vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
+ withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise) => {
+ return await callback(new AbortController().signal);
+ },
+}));
+
+import { locationRouter } from "../location";
+
+const createJsonResponse = (body: unknown) =>
+ new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
+
+const createCaller = () => locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
+
+afterEach(() => {
+ mocks.env.NO_EXTERNAL_CONNECTION = false;
+ mocks.fetchWithTrustedCertificatesAsync.mockReset();
+});
+
+describe("locationRouter.searchCity", () => {
+ test("should return empty results when external connections are disabled", async () => {
+ // Arrange
+ mocks.env.NO_EXTERNAL_CONNECTION = true;
+ const caller = createCaller();
+
+ // Act
+ const result = await caller.searchCity({ query: "Paris" });
+
+ // Assert
+ expect(result.results).toEqual([]);
+ expect(mocks.fetchWithTrustedCertificatesAsync).not.toHaveBeenCalled();
+ });
+
+ test("should fetch the geocoding API when external connections are enabled", async () => {
+ // Arrange
+ mocks.env.NO_EXTERNAL_CONNECTION = false;
+ mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
+ createJsonResponse({
+ results: [
+ {
+ id: 1,
+ name: "Paris",
+ country: "France",
+ country_code: "FR",
+ latitude: 48.85341,
+ longitude: 2.3488,
+ population: 2138551,
+ },
+ ],
+ }),
+ );
+ const caller = createCaller();
+
+ // Act
+ const result = await caller.searchCity({ query: "Paris" });
+
+ // Assert
+ expect(result.results[0]?.name).toBe("Paris");
+ expect(mocks.fetchWithTrustedCertificatesAsync).toHaveBeenCalledWith(
+ expect.stringContaining("geocoding-api.open-meteo.com"),
+ expect.anything(),
+ );
+ });
+});
diff --git a/packages/api/src/router/test/widgets/weather.spec.ts b/packages/api/src/router/test/widgets/weather.spec.ts
new file mode 100644
index 000000000..3b605aee7
--- /dev/null
+++ b/packages/api/src/router/test/widgets/weather.spec.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+import { createDb } from "@homarr/db/test";
+
+const mocks = vi.hoisted(() => ({
+ getDataAsync: vi.fn(),
+ handler: vi.fn(),
+ env: { NO_EXTERNAL_CONNECTION: false },
+}));
+
+vi.mock("@homarr/common/env", () => ({
+ env: mocks.env,
+}));
+
+vi.mock("@homarr/request-handler/weather", () => ({
+ weatherRequestHandler: {
+ handler: mocks.handler,
+ },
+}));
+
+import { weatherRouter } from "../../widgets/weather";
+
+const weatherData = {
+ current: { weathercode: 0, temperature: 20, windspeed: 10 },
+ daily: [],
+};
+
+const createCaller = () => weatherRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
+
+afterEach(() => {
+ mocks.env.NO_EXTERNAL_CONNECTION = false;
+ mocks.handler.mockReset();
+ mocks.getDataAsync.mockReset();
+});
+
+describe("weatherRouter.atLocation", () => {
+ test("should return null when external connections are disabled", async () => {
+ // Arrange
+ mocks.env.NO_EXTERNAL_CONNECTION = true;
+ const caller = createCaller();
+
+ // Act
+ const result = await caller.atLocation({ latitude: 48.85341, longitude: 2.3488 });
+
+ // Assert
+ expect(result).toBeNull();
+ expect(mocks.handler).not.toHaveBeenCalled();
+ });
+
+ test("should return the weather data when external connections are enabled", async () => {
+ // Arrange
+ mocks.env.NO_EXTERNAL_CONNECTION = false;
+ mocks.handler.mockReturnValue({ getDataAsync: mocks.getDataAsync });
+ mocks.getDataAsync.mockResolvedValue({ data: weatherData, timestamp: new Date() });
+ const caller = createCaller();
+
+ // Act
+ const result = await caller.atLocation({ latitude: 48.85341, longitude: 2.3488 });
+
+ // Assert
+ expect(result).toEqual(weatherData);
+ expect(mocks.handler).toHaveBeenCalledWith({ latitude: 48.85341, longitude: 2.3488 });
+ });
+});
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 13356dedf..9918291d4 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2309,6 +2309,11 @@
}
},
"currentWindSpeed": "{currentWindSpeed} {unit}",
+ "error": {
+ "disabled": "Weather data is unavailable because outbound connections are disabled",
+ "failed": "Failed to load weather data",
+ "retry": "Retry"
+ },
"dailyForecast": {
"sunrise": "Sunrise",
"sunset": "Sunset",
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index 6b11453b2..c9d575314 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -1,7 +1,14 @@
"use client";
-import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
-import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
+import { Box, Button, Group, HoverCard, Stack, Text } from "@mantine/core";
+import {
+ IconArrowDownRight,
+ IconArrowUpRight,
+ IconCloudOff,
+ IconDroplets,
+ IconMapPin,
+ IconWind,
+} from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
@@ -16,13 +23,38 @@ import { AnimatedWeatherIcon } from "./animated-icon";
import { WeatherDescription } from "./icon";
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
+ const tError = useScopedI18n("widget.weather.error");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
};
- const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input);
+ const { data: weather, isPending, isError, refetch } = clientApi.widget.weather.atLocation.useQuery(input);
- if (!weather) return ;
+ if (isPending) return ;
+
+ if (isError) {
+ return (
+
+
+
+ {tError("failed")}
+
+
+
+ );
+ }
+
+ if (!weather) {
+ return (
+
+
+ {tError("disabled")}
+
+
+ );
+ }
return (