Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
331925c197 | ||
|
|
0e51f745d3 | ||
|
|
d6f720d0e6 | ||
|
|
5324bf12d4 | ||
|
|
87cdcf28e0 | ||
|
|
441b181e79 | ||
|
|
eba639b59c | ||
|
|
da36283ad1 | ||
|
|
c050368782 | ||
|
|
82b50f0fd7 | ||
|
|
a0149d5894 | ||
|
|
8579fcbfc2 | ||
|
|
b6f3aba23e | ||
|
|
31e218295d |
No files matched your search
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
|
||||
|
||||
## [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)
|
||||
|
||||
@@ -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, location search, and RSS feeds | `true` or `false` | `false` |
|
||||
| `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` |
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
---
|
||||
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 '.';
|
||||
|
||||
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
|
||||
|
||||
<WidgetHeader
|
||||
widget={weatherWidget}
|
||||
categories={["Weather"]}
|
||||
/>
|
||||
|
||||
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';
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={forecastWeather} alt="weather forecast-5-days" width={128 * 4.5} />
|
||||
@@ -28,15 +32,9 @@ import currentWeather from "./img/current.png";
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={weatherWidget.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`
|
||||
|
||||
:::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.
|
||||
:::
|
||||
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
|
||||
@@ -14,6 +14,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
import { serializeMcpToolResult } from "../_serialize-result";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
|
||||
@@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler(
|
||||
const input = args && Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await procedure(input);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serializeMcpToolResult } from "./_serialize-result";
|
||||
|
||||
describe("serializeMcpToolResult", () => {
|
||||
it("serializes objects as JSON strings", () => {
|
||||
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
|
||||
});
|
||||
|
||||
it("serializes primitive values", () => {
|
||||
expect(serializeMcpToolResult("hello")).toBe('"hello"');
|
||||
expect(serializeMcpToolResult(42)).toBe("42");
|
||||
expect(serializeMcpToolResult(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("serializes null as the string null", () => {
|
||||
expect(serializeMcpToolResult(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("serializes void results as the string null so MCP content text is always a string", () => {
|
||||
expect(serializeMcpToolResult(undefined)).toBe("null");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serializeMcpToolResult(result: unknown): string {
|
||||
return JSON.stringify(result) ?? "null";
|
||||
}
|
||||
+2
-2
@@ -9,7 +9,7 @@ http {
|
||||
|
||||
# Route websockets traffic to port 3001
|
||||
location /websockets {
|
||||
proxy_pass http://${HOSTNAME}:3001;
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
@@ -18,7 +18,7 @@ http {
|
||||
|
||||
# Route all other traffic to port 3000
|
||||
location / {
|
||||
proxy_pass http://${HOSTNAME}:3000;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.76.0",
|
||||
"version": "1.76.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -38,8 +37,6 @@ 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}`,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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<Response>) => {
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
|
||||
return "http://127.0.0.1:3000";
|
||||
}
|
||||
|
||||
export const trpcPath = "/api/trpc";
|
||||
|
||||
@@ -13,11 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { TrustedCertificateHostname } from "../certificates/hostnames";
|
||||
import { withTimeoutAsync } from "./timeout";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`;
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: TrustedCertificateHostname[],
|
||||
): typeof checkServerIdentity => {
|
||||
@@ -83,10 +78,6 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -97,10 +88,6 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
"User-Agent": getDefaultUserAgent(),
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { getServerStatistics, init } from "@immich/sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||
getAllTrustedCertificatesAsync: vi.fn(async () => []),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
|
||||
test("x-api-key set via init() reaches the server", async () => {
|
||||
let receivedXApiKey: string | string[] | undefined;
|
||||
const server = createServer((req, res) => {
|
||||
receivedXApiKey = req.headers["x-api-key"];
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
|
||||
try {
|
||||
const stats = await getServerStatistics({
|
||||
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(receivedXApiKey).toBe("sdk-configured-key");
|
||||
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2309,11 +2309,6 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Button, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconArrowDownRight,
|
||||
IconArrowUpRight,
|
||||
IconCloudOff,
|
||||
IconDroplets,
|
||||
IconMapPin,
|
||||
IconWind,
|
||||
} from "@tabler/icons-react";
|
||||
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@@ -23,38 +16,13 @@ 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, isPending, isError, refetch } = clientApi.widget.weather.atLocation.useQuery(input);
|
||||
const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input);
|
||||
|
||||
if (isPending) return <WidgetEmptyState />;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Stack align="center" justify="center" gap="xs" w="100%" h="100%" p="sm">
|
||||
<IconCloudOff size={24} />
|
||||
<Text c="red" size="sm" ta="center">
|
||||
{tError("failed")}
|
||||
</Text>
|
||||
<Button variant="subtle" size="compact-xs" onClick={() => void refetch()}>
|
||||
{tError("retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!weather) {
|
||||
return (
|
||||
<Stack align="center" justify="center" gap="xs" w="100%" h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{tError("disabled")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (!weather) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
+6
-5
@@ -21,20 +21,18 @@ fi
|
||||
export AUTH_SECRET=$(openssl rand -base64 32)
|
||||
|
||||
# Start nginx proxy
|
||||
# 1. Replace the HOSTNAME in the nginx template file
|
||||
# 2. Create the nginx configuration file from the template
|
||||
# 3. Start the nginx server
|
||||
# 1. Create the nginx configuration file from the template
|
||||
# 2. Start the nginx server
|
||||
# Only listen on IPv6 when the host actually has IPv6 configured (#4596).
|
||||
# Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family
|
||||
# not supported by protocol)". Check HOMARR_DISABLE_IPV6 first so users can
|
||||
# force IPv4-only even when the host supports IPv6, then probe for IPv6
|
||||
# addresses via grep (not -s: proc files always report size 0).
|
||||
export HOSTNAME
|
||||
export NGINX_LISTEN_IPV6=''
|
||||
if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then
|
||||
NGINX_LISTEN_IPV6='listen [::]:7575;'
|
||||
fi
|
||||
envsubst '${HOSTNAME} ${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
|
||||
envsubst '${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
|
||||
# Start services in the background and store their PIDs
|
||||
nginx -g 'daemon off;' &
|
||||
NGINX_PID=$!
|
||||
@@ -67,6 +65,9 @@ terminate() {
|
||||
|
||||
trap terminate TERM INT
|
||||
|
||||
# Next.js standalone uses HOSTNAME as its bind address. Docker's generated
|
||||
# hostname can be unresolvable, so bind explicitly while nginx uses loopback.
|
||||
export HOSTNAME=0.0.0.0
|
||||
node apps/nextjs/server.js &
|
||||
NEXTJS_PID=$!
|
||||
|
||||
|
||||
Reference in new issue
Block a user