-
+
diff --git a/plane-src/apps/admin/helpers/nodedc-brand.ts b/plane-src/apps/admin/helpers/nodedc-brand.ts
new file mode 100644
index 0000000..7032fea
--- /dev/null
+++ b/plane-src/apps/admin/helpers/nodedc-brand.ts
@@ -0,0 +1,28 @@
+export function buildNodeDCLauncherUrl(): string {
+ const configuredUrl = process.env.VITE_NODEDC_LAUNCHER_URL;
+
+ if (configuredUrl) {
+ return configuredUrl;
+ }
+
+ if (typeof window === "undefined") {
+ return "http://launcher.local.nodedc/";
+ }
+
+ const hostname = window.location.hostname.toLowerCase();
+
+ if (hostname.endsWith(".nodedc.ru")) {
+ return "https://hub.nodedc.ru/";
+ }
+
+ if (hostname.endsWith(".nas.nodedc")) {
+ const port = window.location.port ? `:${window.location.port}` : "";
+ return `${window.location.protocol}//launcher.nas.nodedc${port}/`;
+ }
+
+ return "http://launcher.local.nodedc/";
+}
+
+export function buildNodeDCBrandConfigUrl(): string {
+ return new URL("/api/public/brand", buildNodeDCLauncherUrl()).toString();
+}
diff --git a/plane-src/apps/admin/hooks/use-nodedc-brand-link-url.ts b/plane-src/apps/admin/hooks/use-nodedc-brand-link-url.ts
new file mode 100644
index 0000000..9fdff67
--- /dev/null
+++ b/plane-src/apps/admin/hooks/use-nodedc-brand-link-url.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from "react";
+import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-brand";
+
+type TNodeDCBrandPayload = {
+ logoLinkUrl?: string | null;
+};
+
+export function useNodeDCBrandLinkUrl() {
+ const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((payload: TNodeDCBrandPayload | null) => {
+ const configuredUrl = typeof payload?.logoLinkUrl === "string" ? payload.logoLinkUrl.trim() : "";
+ if (isMounted && configuredUrl) setLogoLinkUrl(configuredUrl);
+ return undefined;
+ })
+ .catch((error: unknown) => {
+ console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return logoLinkUrl;
+}
diff --git a/plane-src/apps/live/Dockerfile.live b/plane-src/apps/live/Dockerfile.live
index 51151cf..a50d0d4 100644
--- a/plane-src/apps/live/Dockerfile.live
+++ b/plane-src/apps/live/Dockerfile.live
@@ -40,24 +40,11 @@ COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
-RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
-RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
+RUN pnpm fetch --store-dir=/pnpm/store
+RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ENV TURBO_TELEMETRY_DISABLED=1
-RUN mkdir -p \
- apps/live/.turbo \
- packages/constants/.turbo \
- packages/decorators/.turbo \
- packages/editor/.turbo \
- packages/hooks/.turbo \
- packages/i18n/.turbo \
- packages/logger/.turbo \
- packages/propel/.turbo \
- packages/types/.turbo \
- packages/ui/.turbo \
- packages/utils/.turbo
-
RUN pnpm turbo run build --filter=live
# *****************************************************************************
diff --git a/plane-src/apps/space/Dockerfile.space b/plane-src/apps/space/Dockerfile.space
index f12ff80..c03fe25 100644
--- a/plane-src/apps/space/Dockerfile.space
+++ b/plane-src/apps/space/Dockerfile.space
@@ -68,8 +68,8 @@ COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
-RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
-RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
+RUN pnpm fetch --store-dir=/pnpm/store
+RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the space package
RUN pnpm turbo run build --filter=space
diff --git a/plane-src/apps/web/Dockerfile.web.nas-legacy b/plane-src/apps/web/Dockerfile.web.nas-legacy
new file mode 100644
index 0000000..36a90b5
--- /dev/null
+++ b/plane-src/apps/web/Dockerfile.web.nas-legacy
@@ -0,0 +1,97 @@
+FROM node:22-alpine AS base
+
+# Setup pnpm package manager with corepack and configure global bin directory for caching
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
+RUN corepack enable
+
+# *****************************************************************************
+# STAGE 1: Build the project
+# *****************************************************************************
+FROM base AS builder
+RUN apk add --no-cache libc6-compat
+# Set working directory
+WORKDIR /app
+
+ARG TURBO_VERSION=2.9.4
+RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
+COPY . .
+
+RUN turbo prune --scope=web --docker
+
+# *****************************************************************************
+# STAGE 2: Install dependencies & build the project
+# *****************************************************************************
+# Add lockfile and package.json's of isolated subworkspace
+FROM base AS installer
+
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# First install the dependencies (as they change less often)
+COPY .gitignore .gitignore
+COPY --from=builder /app/out/json/ .
+COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
+RUN corepack enable pnpm
+
+# Copy full directory structure before fetch to ensure all package.json files are available
+COPY --from=builder /app/out/full/ .
+COPY turbo.json turbo.json
+
+# Fetch dependencies to cache store, then install offline with dev deps
+RUN pnpm fetch --store-dir=/pnpm/store
+RUN CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
+
+ARG VITE_API_BASE_URL=""
+ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
+
+ARG VITE_ADMIN_BASE_URL=""
+ENV VITE_ADMIN_BASE_URL=$VITE_ADMIN_BASE_URL
+
+ARG VITE_ADMIN_BASE_PATH="/nodedcsudo"
+ENV VITE_ADMIN_BASE_PATH=$VITE_ADMIN_BASE_PATH
+
+ARG VITE_LIVE_BASE_URL=""
+ENV VITE_LIVE_BASE_URL=$VITE_LIVE_BASE_URL
+
+ARG VITE_LIVE_BASE_PATH="/live"
+ENV VITE_LIVE_BASE_PATH=$VITE_LIVE_BASE_PATH
+
+ARG VITE_SPACE_BASE_URL=""
+ENV VITE_SPACE_BASE_URL=$VITE_SPACE_BASE_URL
+
+ARG VITE_SPACE_BASE_PATH="/spaces"
+ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
+
+ARG VITE_WEB_BASE_URL=""
+ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL
+
+ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
+ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
+
+ARG VITE_NODEDC_OIDC_LOGIN_URL=""
+ENV VITE_NODEDC_OIDC_LOGIN_URL=$VITE_NODEDC_OIDC_LOGIN_URL
+
+ARG VITE_NODEDC_LAUNCHER_URL=""
+ENV VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL
+
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV TURBO_TELEMETRY_DISABLED=1
+
+RUN pnpm turbo run build --filter=web
+
+# *****************************************************************************
+# STAGE 3: Serve with nginx
+# *****************************************************************************
+FROM nginx:1.27-alpine AS production
+
+COPY apps/web/nginx/nginx.conf /etc/nginx/nginx.conf
+COPY --from=installer /app/apps/web/build/client /usr/share/nginx/html
+RUN chmod -R a+rX /usr/share/nginx/html
+
+EXPOSE 3000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/top-toolbar/expanded-layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/top-toolbar/expanded-layout.tsx
index 5df1e25..3825cd4 100644
--- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/top-toolbar/expanded-layout.tsx
+++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/top-toolbar/expanded-layout.tsx
@@ -8,11 +8,10 @@
import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react";
-import { useEffect, useState } from "react";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
-import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
+import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useHome } from "@/hooks/store/use-home";
@@ -32,26 +31,7 @@ export const ExpandedProjectShellToolbarLayout = ({
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
const { toggleWidgetSettings } = useHome();
- const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
-
- useEffect(() => {
- let isMounted = true;
-
- fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
- .then((response) => (response.ok ? response.json() : null))
- .then((payload: { logoLinkUrl?: string } | null) => {
- if (isMounted && payload?.logoLinkUrl) {
- setLogoLinkUrl(payload.logoLinkUrl);
- }
- })
- .catch((error: unknown) => {
- console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
- });
-
- return () => {
- isMounted = false;
- };
- }, []);
+ const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
diff --git a/plane-src/apps/web/core/components/auth-screens/header.tsx b/plane-src/apps/web/core/components/auth-screens/header.tsx
index 64186e0..ffa90b4 100644
--- a/plane-src/apps/web/core/components/auth-screens/header.tsx
+++ b/plane-src/apps/web/core/components/auth-screens/header.tsx
@@ -12,6 +12,7 @@ import { useTranslation } from "@plane/i18n";
import { PlaneLockup } from "@plane/propel/icons";
import { PageHead } from "@/components/core/page-title";
import { EAuthModes } from "@/helpers/authentication.helper";
+import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { useInstance } from "@/hooks/store/use-instance";
const authContentMap = {
@@ -68,17 +69,19 @@ type TAuthHeaderBase = {
export function AuthHeaderBase(props: TAuthHeaderBase) {
const { pageTitle, additionalAction } = props;
+ const logoLinkUrl = useNodeDCBrandLinkUrl();
+
return (
<>
-
+
-
+
{additionalAction}
>
diff --git a/plane-src/apps/web/core/components/instance/not-ready-view.tsx b/plane-src/apps/web/core/components/instance/not-ready-view.tsx
index 806b978..4d02e6b 100644
--- a/plane-src/apps/web/core/components/instance/not-ready-view.tsx
+++ b/plane-src/apps/web/core/components/instance/not-ready-view.tsx
@@ -12,14 +12,23 @@ import GradientBgLogo from "@/app/assets/auth/gradient-bg-logo.webp?url";
import DefaultLayout from "@/layouts/default-layout";
import { PlaneLockup } from "@plane/propel/icons";
import { Button } from "@plane/propel/button";
+import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
export function InstanceNotReady() {
+ const logoLinkUrl = useNodeDCBrandLinkUrl();
+
return (
diff --git a/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx b/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx
index 785b83b..8c61fa0 100644
--- a/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx
+++ b/plane-src/apps/web/core/components/nodedc/standalone-shell.tsx
@@ -7,11 +7,10 @@
*/
import type { ReactNode } from "react";
-import { useEffect, useState } from "react";
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
-import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
+import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
type TNodeDCStandaloneShellProps = {
@@ -24,25 +23,7 @@ type TNodeDCStandaloneShellProps = {
export const NodeDCStandaloneShell = (props: TNodeDCStandaloneShellProps) => {
const { children, notificationsCount = 0, onOpenNotifications, showUserControls = false } = props;
const { t } = useTranslation();
- const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
-
- useEffect(() => {
- let isMounted = true;
-
- fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
- .then((response) => (response.ok ? response.json() : null))
- .then((payload: { logoLinkUrl?: string } | null) => {
- if (isMounted && payload?.logoLinkUrl) setLogoLinkUrl(payload.logoLinkUrl);
- return undefined;
- })
- .catch((error: unknown) => {
- console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
- });
-
- return () => {
- isMounted = false;
- };
- }, []);
+ const logoLinkUrl = useNodeDCBrandLinkUrl();
return (
diff --git a/plane-src/apps/web/core/components/onboarding/header.tsx b/plane-src/apps/web/core/components/onboarding/header.tsx
index 21bb081..996f727 100644
--- a/plane-src/apps/web/core/components/onboarding/header.tsx
+++ b/plane-src/apps/web/core/components/onboarding/header.tsx
@@ -8,12 +8,14 @@ import { observer } from "mobx-react";
// plane imports
import { PlaneLockup } from "@plane/propel/icons";
// hooks
+import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { useUser } from "@/hooks/store/user";
// local imports
import { SwitchAccountDropdown } from "./switch-account-dropdown";
export const OnboardingHeader = observer(function OnboardingHeader() {
const { data: user } = useUser();
+ const logoLinkUrl = useNodeDCBrandLinkUrl();
const userName = user?.display_name
? user.display_name
@@ -27,7 +29,9 @@ export const OnboardingHeader = observer(function OnboardingHeader() {
diff --git a/plane-src/apps/web/core/hooks/use-nodedc-brand-link-url.ts b/plane-src/apps/web/core/hooks/use-nodedc-brand-link-url.ts
new file mode 100644
index 0000000..46acd8d
--- /dev/null
+++ b/plane-src/apps/web/core/hooks/use-nodedc-brand-link-url.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from "react";
+import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
+
+type TNodeDCBrandPayload = {
+ logoLinkUrl?: string | null;
+};
+
+export function useNodeDCBrandLinkUrl() {
+ const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((payload: TNodeDCBrandPayload | null) => {
+ const configuredUrl = typeof payload?.logoLinkUrl === "string" ? payload.logoLinkUrl.trim() : "";
+ if (isMounted && configuredUrl) setLogoLinkUrl(configuredUrl);
+ return undefined;
+ })
+ .catch((error: unknown) => {
+ console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return logoLinkUrl;
+}
diff --git a/plane-src/rebuild-nas-legacy.sh b/plane-src/rebuild-nas-legacy.sh
new file mode 100755
index 0000000..5d4acec
--- /dev/null
+++ b/plane-src/rebuild-nas-legacy.sh
@@ -0,0 +1,150 @@
+#!/bin/sh
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+
+DOCKER=${DOCKER:-/usr/local/bin/docker}
+SUDO_CMD=${SUDO_CMD:-sudo}
+SRC=${SRC:-$SCRIPT_DIR}
+APP=${APP:-$(CDPATH= cd -- "$SRC/../plane-app" && pwd)}
+
+COMPOSE_PROJECT=${COMPOSE_PROJECT:-nodedc-tasker}
+COMPOSE_ENV_FILE=${COMPOSE_ENV_FILE:-.env.synology}
+COMPOSE_FILE=${COMPOSE_FILE:-docker-compose.yaml}
+COMPOSE_OVERRIDE_FILE=${COMPOSE_OVERRIDE_FILE:-docker-compose.synology.override.yml}
+
+BUILD_BACKEND=${BUILD_BACKEND:-1}
+BUILD_WEB=${BUILD_WEB:-1}
+BUILD_ADMIN=${BUILD_ADMIN:-0}
+
+BACKEND_BUILDKIT=${BACKEND_BUILDKIT:-0}
+WEB_BUILDKIT=${WEB_BUILDKIT:-0}
+ADMIN_BUILDKIT=${ADMIN_BUILDKIT:-1}
+
+PLANE_BACKEND_IMAGE=${PLANE_BACKEND_IMAGE:-nodedc/plane-backend:local}
+PLANE_FRONTEND_IMAGE=${PLANE_FRONTEND_IMAGE:-nodedc/plane-frontend:ru}
+PLANE_ADMIN_IMAGE=${PLANE_ADMIN_IMAGE:-nodedc/plane-admin:ru}
+
+VITE_NODEDC_LAUNCHER_URL=${VITE_NODEDC_LAUNCHER_URL:-https://hub.nodedc.ru}
+VITE_NODEDC_OIDC_LOGIN_ENABLED=${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}
+
+RUN_SMOKE=${RUN_SMOKE:-1}
+SMOKE_BASE_URL=${SMOKE_BASE_URL:-https://ops.nodedc.ru}
+SMOKE_RESOLVE=${SMOKE_RESOLVE:-ops.nodedc.ru:443:127.0.0.1}
+
+RECREATE_SERVICES=""
+
+run() {
+ if [ -n "$SUDO_CMD" ]; then
+ "$SUDO_CMD" "$@"
+ else
+ "$@"
+ fi
+}
+
+run_env() {
+ if [ -n "$SUDO_CMD" ]; then
+ "$SUDO_CMD" env "$@"
+ else
+ env "$@"
+ fi
+}
+
+compose_up() {
+ cd "$APP"
+ set -- compose -p "$COMPOSE_PROJECT" --env-file "$COMPOSE_ENV_FILE" -f "$COMPOSE_FILE"
+
+ if [ -n "$COMPOSE_OVERRIDE_FILE" ] && [ -f "$COMPOSE_OVERRIDE_FILE" ]; then
+ set -- "$@" -f "$COMPOSE_OVERRIDE_FILE"
+ fi
+
+ set -- "$@" up -d --no-build --force-recreate
+ for service in $RECREATE_SERVICES; do
+ set -- "$@" "$service"
+ done
+
+ run "$DOCKER" "$@"
+}
+
+curl_with_optional_resolve() {
+ if [ -n "$SMOKE_RESOLVE" ]; then
+ curl -k --resolve "$SMOKE_RESOLVE" "$@"
+ else
+ curl -k "$@"
+ fi
+}
+
+if [ -n "$SUDO_CMD" ]; then
+ printf "== sudo session ==\n"
+ "$SUDO_CMD" -v
+fi
+
+if [ "$BUILD_BACKEND" = "1" ]; then
+ printf "== backend image: %s ==\n" "$PLANE_BACKEND_IMAGE"
+ cd "$SRC/apps/api"
+ run_env DOCKER_BUILDKIT="$BACKEND_BUILDKIT" "$DOCKER" build \
+ -t "$PLANE_BACKEND_IMAGE" \
+ -f Dockerfile.api .
+ RECREATE_SERVICES="$RECREATE_SERVICES api worker beat-worker"
+else
+ printf "== skip backend image ==\n"
+fi
+
+if [ "$BUILD_WEB" = "1" ]; then
+ printf "== frontend image: %s ==\n" "$PLANE_FRONTEND_IMAGE"
+ cd "$SRC"
+ run_env DOCKER_BUILDKIT="$WEB_BUILDKIT" "$DOCKER" build \
+ --build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \
+ --build-arg "VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED" \
+ -t "$PLANE_FRONTEND_IMAGE" \
+ -f apps/web/Dockerfile.web.nas-legacy .
+ RECREATE_SERVICES="$RECREATE_SERVICES web"
+else
+ printf "== skip frontend image ==\n"
+fi
+
+if [ "$BUILD_ADMIN" = "1" ]; then
+ printf "== admin image: %s ==\n" "$PLANE_ADMIN_IMAGE"
+ cd "$SRC"
+ run_env DOCKER_BUILDKIT="$ADMIN_BUILDKIT" "$DOCKER" build \
+ --build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \
+ -t "$PLANE_ADMIN_IMAGE" \
+ -f apps/admin/Dockerfile.admin .
+ RECREATE_SERVICES="$RECREATE_SERVICES admin"
+else
+ printf "== skip admin image ==\n"
+fi
+
+if [ -z "$RECREATE_SERVICES" ]; then
+ printf "== nothing to recreate ==\n"
+ exit 0
+fi
+
+printf "== recreate tasker services ==\n"
+compose_up
+
+printf "== containers ==\n"
+run "$DOCKER" ps \
+ --filter "name=$COMPOSE_PROJECT" \
+ --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+
+if [ "$RUN_SMOKE" = "1" ]; then
+ printf "== smoke: logo ==\n"
+ curl_with_optional_resolve -sSI \
+ "$SMOKE_BASE_URL/nodedc-logo.svg" \
+ | grep -Ei "HTTP/|content-type" || true
+
+ printf "== smoke: websocket ==\n"
+ curl_with_optional_resolve -i --http1.1 \
+ -H "Connection: Upgrade" \
+ -H "Upgrade: websocket" \
+ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
+ -H "Sec-WebSocket-Version: 13" \
+ "$SMOKE_BASE_URL/live/nodedc/stream" \
+ --max-time 35 \
+ 2>/dev/null | sed -n "1,25p" || true
+else
+ printf "== skip smoke checks ==\n"
+fi
+
+printf "== done ==\n"