diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
deleted file mode 100644
index eb772ff..0000000
--- a/.devcontainer/Dockerfile
+++ /dev/null
@@ -1,3 +0,0 @@
-FROM mcr.microsoft.com/devcontainers/javascript-node:22 as base
-
-RUN apt update && apt install -y redis-tools
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 4ca5465..2fc88e6 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -50,7 +50,6 @@
"DavidAnson.vscode-markdownlint",
"ms-vscode-remote.remote-containers",
"ms-azuretools.vscode-docker",
- "cweijan.vscode-redis-client",
"bradlc.vscode-tailwindcss",
"GitHub.vscode-pull-request-github",
"eamodio.gitlens",
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 58802bc..4834aca 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -1,15 +1,7 @@
services:
# Update this to the name of the service you want to work with in your docker-compose.yml file
lab-framework-core:
- # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
- # folder. Note that the path of the Dockerfile and context is relative to the *primary*
- # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
- # array). The sample below assumes your primary file is in the root of your project.
- #
- build:
- context: .
- dockerfile: ./Dockerfile
- # image: mcr.microsoft.com/devcontainers/javascript-node:20
+ image: mcr.microsoft.com/devcontainers/javascript-node:24-bookworm
volumes:
# Update this to wherever you want VS Code to mount the folder of your project
- ..:/workspace:cached
@@ -28,7 +20,7 @@ services:
image: mockserver/mockserver
ports:
- "80:1080" # for UDF mocks
- - "5123:1080" # for petname service mocks
+ - "5123:1080" # for deployment identifier service mocks
volumes:
cache:
diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh
index 990eda0..f2a88bf 100755
--- a/.devcontainer/postCreateCommand.sh
+++ b/.devcontainer/postCreateCommand.sh
@@ -74,7 +74,7 @@ curl --retry 12 --retry-all-errors -X PUT 'http://metadata.udf:80/mockserver/exp
{
"id": "0cd74e44-90fc-4220-96fe-4c1b1cf1502e",
"name": "Runner",
- "osName": "Ubuntu 22.04 LTS Server",
+ "osName": "Ubuntu 24.04 LTS Server",
"mgmtIp": "10.1.1.5",
"trafficIps": [],
"accessMethods": {
@@ -172,7 +172,7 @@ curl -X PUT 'http://metadata.udf:80/mockserver/expectation' \
{
"id": "0cd74e44-90fc-4220-96fe-4c1b1cf1502e",
"name": "Runner",
- "osName": "Ubuntu 22.04 LTS Server",
+ "osName": "Ubuntu 24.04 LTS Server",
"mgmtIp": "10.1.1.5",
"trafficIps": [],
"accessMethods": {
diff --git a/.env.example b/.env.example
index 97267b6..726c4ea 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,6 @@
# Framework ENV Vars
# DOCKER_API_URL=tcp://host.docker.internal:2375 # Set to use the Docker API (instead of docker.sock) when the framework makes docker calls.
+DEPLOYMENT_IDENTIFIER_RESPONSE_FIELD=petname
REMOTE_DOCS_REPO_SERVER="https://raw.githubusercontent.com"
REMOTE_DOCS_REPO_API_SERVER="https://api.github.com"
REMOTE_DOCS_REPO_OWNER=f5devcentral
@@ -10,6 +11,6 @@ REMOTE_DOCS_REPO_MEDIA_PATH=docs/media
REMOTE_DOCS_REPO_CACHE_SECONDS=300
# SIMULATE_LOAD_DELAY=1 # Adds artificial delay to key document loading functions. Used to test the loading animation page.
# NODE_TLS_REJECT_UNAUTHORIZED=0 # Allows API Check components to test TLS connections with untrusted certs
-
+# NODE_NO_WARNINGS=1 # Suppresses Node.js warnings, such as from the above setting
# Custom Content-based ENV Vars
CONTAINER_MOUNT_BASE_DIR=/Users/d.edgar/Documents/workspaces/lab-framework
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..06d136b
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,32 @@
+# Copilot Instructions
+
+## Test Execution Rules
+
+- Never run tests in watch mode for this repository.
+- Do not use `npm test` because the script enables Jest watch mode.
+- Always run tests in CI-style non-watch mode.
+
+Approved examples:
+
+- `CI=1 npx jest --runInBand --watchAll=false`
+- `CI=1 npx jest --runInBand --watchAll=false src/lib/variables.test.ts`
+- `CI=1 npx jest --runInBand --watchAll=false src/app/components/set-variable.test.tsx`
+
+## Implementation Rules
+
+- Never write legacy conversion code.
+- Never write migration compatibility code paths.
+- Implement clean forward-only behavior and update callers/tests accordingly.
+- Always run relevant tests after making code changes.
+- Always update or add tests when behavior, interfaces, or outputs change.
+- Treat component and context state transitions as first-class behavior: when changing rendering or refactoring stateful code, add or update tests that assert the transition path, not just the final markup, and review any test edits for hidden state regressions before accepting them.
+- Use browser localStorage as the source of truth for UI state where applicable.
+- Ensure UI updates immediately when localStorage-backed variables change.
+- For client-side polling, background status sync, or repeated variable resolution, prefer lightweight API endpoints plus targeted client state updates over server actions or page-route roundtrips that trigger full route rendering.
+- When a polling or refresh loop is needed, scope it to the smallest data surface possible, avoid re-rendering the whole page tree, and pause or reduce polling for hidden/inactive tabs when practical.
+- If a change may deviate from any of these rules, ask for explicit user approval before proceeding.
+- Refactor static/hardcoded values into named constants or environment variables.
+- If it is unclear whether a value should be a constant or an environment variable, ask the user before implementing.
+- Do not implement readability-reducing compatibility alias patterns that accept multiple names for the same input.
+- For any given input/option, accept a single canonical name and update callers/tests/docs to match it instead of adding fallback aliases or normalization logic.
+- Do not introduce duplicate code; extract shared logic into centralized utilities/modules and update call sites instead of copy-pasting implementations.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f33a02c..f9c7a19 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -10,3 +10,7 @@ updates:
directory: "/"
schedule:
interval: weekly
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml
index c713c1e..c63a565 100644
--- a/.github/workflows/build-containers.yml
+++ b/.github/workflows/build-containers.yml
@@ -4,6 +4,7 @@ on:
push:
branches: ["main"]
pull_request:
+ types: [opened, synchronize, reopened]
env:
REGISTRY: ghcr.io
@@ -18,30 +19,34 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
- name: Log in to the Container registry
- uses: docker/login-action@v3.3.0
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Extract metadata (tags, labels) for lab-framework container
- if: github.ref == 'refs/heads/main'
- id: meta-lab-framework
- uses: docker/metadata-action@v5.5.1
+ - name: Extract metadata (tags, labels) for core container
+ id: meta-core
+ uses: docker/metadata-action@v6
with:
- images: ${{ env.REGISTRY }}/${{ env.REPO_NAME }}-lab-framework
+ images: ${{ env.REGISTRY }}/${{ env.REPO_NAME }}-core
tags: |
type=sha
- type=raw,value=latest
+ type=raw,value=latest,enable={{is_default_branch}}
- - name: Build and push lab-framework image
- uses: docker/build-push-action@v6.7.0
+ - name: Build and push lab-framework core image
+ uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
- push: ${{ github.ref == 'refs/heads/main' }}
- tags: ${{ steps.meta-lab-framework.outputs.tags }}
- labels: ${{ steps.meta-lab-framework.outputs.labels }}
+ push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+ tags: ${{ steps.meta-core.outputs.tags }}
+ labels: ${{ steps.meta-core.outputs.labels }}
+
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index ca01857..96dfdc2 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -8,11 +8,11 @@ jobs:
coverage:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - name: Use Node.js 22.x
- uses: actions/setup-node@v4
+ - uses: actions/checkout@v7
+ - name: Use Node.js 24.x
+ uses: actions/setup-node@v7
with:
- node-version: 22.x
+ node-version: 24.x
- name: Check Node.js version
run: node -pe process.versions
- name: Install dependencies
diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml
new file mode 100644
index 0000000..6a38bab
--- /dev/null
+++ b/.github/workflows/pr-tests.yml
@@ -0,0 +1,30 @@
+name: PR Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - opened
+ - synchronize
+ - reopened
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24.x
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm run test:ci
diff --git a/.gitignore b/.gitignore
index ac6e85f..c2e1564 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,7 @@ next-env.d.ts
.env
report.json
+
+# Used in lab content
+nginx-api.conf
+ssl
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index cb44418..be71260 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -20,7 +20,10 @@
"settings.json",
"jsonconfig.json"
],
- "typescript.validate.enable": true,
+ "js/ts.validate.enabled": true,
"eslint.useFlatConfig": true,
"jest.runMode": "on-demand",
+ "chat.tools.terminal.autoApprove": {
+ "npx jest": true
+ },
}
diff --git a/Dockerfile b/Dockerfile
index 48671df..842322d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
-FROM node:22-alpine AS base
+FROM node:24-alpine AS base
# Install dependencies only when needed
FROM base AS deps
diff --git a/README.md b/README.md
index 47c2570..243290c 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ You can start editing the lab markdown page by modifying `app/docs/nginx-one.mdx
The lab framework runs in Docker, so the it must be installed in the host system. Additionally, the lab framework container will use the host's docker API to manage containers. The host system needs to expose the Docker API over a network so that the lab framework container can connect to it. This approach involves some setup and is more secure when properly configured, especially when using TLS for encrypted communication.
-Assuming a host system of Ubuntu 22.04, docker can be installed using the following:
+Assuming a host system of Ubuntu 24.04, docker can be installed using the following:
```shell
sudo apt install -y ca-certificates curl gnupg lsb-release
@@ -87,7 +87,7 @@ sudo chmod 0666 /var/run/docker.sock
```
-A `docker-compose.yaml` file has been provided to orchestrate the lab framework and redis containers. In order for the lab framework to create additional containers required by the lab author, the docker calls need to use the host's docker daemon. The above steps enable mounting docker.sock as a volume into the container.
+A `docker-compose.yaml` file has been provided to orchestrate the lab framework containers. In order for the lab framework to create additional containers required by the lab author, the docker calls need to use the host's docker daemon. The above steps enable mounting docker.sock as a volume into the container.
If this isn't preferred (or will not work), the host's docker daemon has been configured to allow remote hosts to call the docker API. To use this, you will specify a custom host via the `-H` parameter in order to connect to the hosts Docker API. Example:
diff --git a/jest.setup.ts b/jest.setup.ts
index ea65682..fcd35ac 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -3,3 +3,21 @@ import "@testing-library/jest-dom";
import { enableFetchMocks } from 'jest-fetch-mock';
enableFetchMocks();
+
+jest.mock("next/image", () => ({
+ __esModule: true,
+ default: (props: {
+ src?: string;
+ alt?: string;
+ [key: string]: unknown;
+ }) => {
+ const React = require("react");
+ const { src, alt, ...rest } = props;
+
+ return React.createElement("img", {
+ ...rest,
+ alt: alt ?? "",
+ src: typeof src === "string" ? src : "",
+ });
+ },
+}));
diff --git a/next.config.mjs b/next.config.mjs
index e1d2fd3..ae904e6 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -3,7 +3,18 @@ const nextConfig = {
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
reactStrictMode: true,
output: "standalone",
+ allowedDevOrigins: ["[::1]", "localhost", "127.0.0.1"],
serverExternalPackages: ["dockerode"],
+ images: {
+ domains: ["raw.githubusercontent.com"],
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "raw.githubusercontent.com",
+ pathname: "/**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 376789d..31f21af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,14 @@
"version": "0.1.0",
"dependencies": {
"@pondorasti/remark-img-links": "^1.0.8",
+ "create-cert": "^1.0.6",
"dockerode": "^5.0.1",
"gray-matter": "^4.0.3",
"next": "^16.2.10",
"next-mdx-remote": "^6.0.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+ "react-syntax-highlighter": "^16.1.1",
"remark-emoji": "^5.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
@@ -34,6 +36,7 @@
"@types/node": "^26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
+ "@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.10",
"jest": "^30.4.2",
@@ -556,13 +559,10 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.25.7",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz",
- "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==",
- "dev": true,
- "dependencies": {
- "regenerator-runtime": "^0.14.0"
- },
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -3470,6 +3470,12 @@
"undici-types": "~8.3.0"
}
},
+ "node_modules/@types/prismjs": {
+ "version": "1.26.6",
+ "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
+ "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -3489,6 +3495,16 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/react-syntax-highlighter": {
+ "version": "15.5.13",
+ "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
+ "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/ssh2": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.1.tgz",
@@ -4731,6 +4747,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/charenc": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+ "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -4893,6 +4918,16 @@
"node": ">=10.0.0"
}
},
+ "node_modules/create-cert": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/create-cert/-/create-cert-1.0.6.tgz",
+ "integrity": "sha512-jOVt/HWAWhFFlt3H8TrnlAfvZMUoVf4XjvZqLxZ1Yzy+VTEm/rTgnfliKs3yioYENs9mNoHRWZQKAmVSrHfESw==",
+ "license": "MIT",
+ "dependencies": {
+ "pem": "^1.9.7",
+ "pify": "^3.0.0"
+ }
+ },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -4922,6 +4957,15 @@
"node": ">= 8"
}
},
+ "node_modules/crypt": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+ "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -5491,6 +5535,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es6-promisify": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz",
+ "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/esast-util-from-estree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz",
@@ -6517,6 +6570,19 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fault": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
+ "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
+ "license": "MIT",
+ "dependencies": {
+ "format": "^0.2.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -6623,6 +6689,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/format": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
+ "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -7034,6 +7108,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-estree": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.1.tgz",
@@ -7099,6 +7186,33 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hastscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript/node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -7116,6 +7230,21 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/highlight.js": {
+ "version": "10.7.3",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
+ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/highlightjs-vue": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
+ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
+ "license": "CC0-1.0"
+ },
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -7405,6 +7534,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+ "license": "MIT"
+ },
"node_modules/is-bun-module": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-1.1.0.tgz",
@@ -7806,7 +7941,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -9375,6 +9509,20 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/lowlight": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
+ "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
+ "license": "MIT",
+ "dependencies": {
+ "fault": "^1.0.0",
+ "highlight.js": "~10.7.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -9464,6 +9612,17 @@
"node": ">= 0.4"
}
},
+ "node_modules/md5": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+ "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "charenc": "0.0.2",
+ "crypt": "0.0.2",
+ "is-buffer": "~1.1.6"
+ }
+ },
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -10948,6 +11107,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -11143,6 +11311,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/pem": {
+ "version": "1.14.8",
+ "resolved": "https://registry.npmjs.org/pem/-/pem-1.14.8.tgz",
+ "integrity": "sha512-ZpbOf4dj9/fQg5tQzTqv4jSKJQsK7tPl0pm4/pvPcZVjZcJg7TMfr3PBk6gJH97lnpJDu4e4v8UUqEz5daipCg==",
+ "license": "MIT",
+ "dependencies": {
+ "es6-promisify": "^7.0.0",
+ "md5": "^2.3.0",
+ "os-tmpdir": "^1.0.2",
+ "which": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11161,6 +11344,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -11320,6 +11512,15 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+ "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -11464,6 +11665,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-syntax-highlighter": {
+ "version": "16.1.1",
+ "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
+ "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "highlight.js": "^10.4.1",
+ "highlightjs-vue": "^1.0.0",
+ "lowlight": "^1.17.0",
+ "prismjs": "^1.30.0",
+ "refractor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 16.20.2"
+ },
+ "peerDependencies": {
+ "react": ">= 0.14.0"
+ }
+ },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -11573,11 +11794,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/regenerator-runtime": {
- "version": "0.14.1",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
- "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
- "dev": true
+ "node_modules/refractor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
+ "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/prismjs": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "parse-entities": "^4.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@@ -13419,7 +13650,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
diff --git a/package.json b/package.json
index 14b189e..d5f762e 100644
--- a/package.json
+++ b/package.json
@@ -3,21 +3,26 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --turbopack",
+ "dev": "next dev --webpack",
"build": "next build",
- "start": "next start",
+ "prestart": "npm run build",
+ "start": "node .next/standalone/server.js",
"lint": "eslint .",
- "test": "jest --watch",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:ci": "jest --runInBand --watchAll=false",
"test-coverage": "jest --coverage"
},
"dependencies": {
"@pondorasti/remark-img-links": "^1.0.8",
+ "create-cert": "^1.0.6",
"dockerode": "^5.0.1",
"gray-matter": "^4.0.3",
"next": "^16.2.10",
"next-mdx-remote": "^6.0.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+ "react-syntax-highlighter": "^16.1.1",
"remark-emoji": "^5.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
@@ -37,6 +42,7 @@
"@types/node": "^26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
+ "@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.10",
"jest": "^30.4.2",
diff --git a/src/app/[doc]/layout.tsx b/src/app/[doc]/layout.tsx
index f43e71e..b880b58 100644
--- a/src/app/[doc]/layout.tsx
+++ b/src/app/[doc]/layout.tsx
@@ -1,4 +1,5 @@
import { ToC } from "@/app/components/toc"
+import { InstancesContextProvider } from "@/app/contexts/instances";
import { ReactNode } from "react";
interface MdxLayoutProps {
@@ -6,15 +7,17 @@ interface MdxLayoutProps {
}
export default function MdxLayout({ children }: MdxLayoutProps) {
-
+
return (
-
-
-
-
-
+
)
}
\ No newline at end of file
diff --git a/src/app/[doc]/page.tsx b/src/app/[doc]/page.tsx
index e317455..07288e7 100644
--- a/src/app/[doc]/page.tsx
+++ b/src/app/[doc]/page.tsx
@@ -1,4 +1,4 @@
-// import { getMdxContent } from "@/lib/mdxUtils"
+import { getMdxContent } from "@/lib/mdxUtils"
import { delay } from "@/lib/utils"
import { getEnvVariable } from "@/lib/variables"
@@ -9,14 +9,18 @@ interface PageParams {
export default async function Page(props: { params: Promise
}) {
const params = await props.params;
if (await getEnvVariable("SIMULATE_LOAD_DELAY")) await delay(5000);
- const { content, frontmatter } = { content: "content", frontmatter: { title: params.doc, description: "description" } }
- // const { content, frontmatter } = await getMdxContent(params.doc)
+ const { content, frontmatter } = await getMdxContent(params.doc)
+
return (
<>
-
{frontmatter?.title}
- {frontmatter?.description && (
-
{frontmatter.description}
+
+ {frontmatter.title ?? params.doc}
+
+ {frontmatter.description && (
+
+ {frontmatter.description}
+
)}
{content}
diff --git a/src/app/api/docker/instances/sync/route.ts b/src/app/api/docker/instances/sync/route.ts
new file mode 100644
index 0000000..f714eca
--- /dev/null
+++ b/src/app/api/docker/instances/sync/route.ts
@@ -0,0 +1,102 @@
+import { NextResponse } from "next/server";
+import { getComponentName, getVariable } from "@/lib/variables";
+import {
+ getContainerStatus,
+ getDockerInstanceSnapshot,
+ isContainerPresent,
+} from "@/app/lib/docker-lib";
+import { DockerPortMapping, InstanceState, InstanceType } from "@/lib/types";
+
+type DockerSyncRequest = {
+ names?: string[];
+};
+
+type DockerSyncItem = {
+ name: string;
+ componentId?: string;
+ image?: string;
+ isPresent: boolean;
+ ports?: DockerPortMapping[];
+ status: InstanceState;
+ type: InstanceType.Docker;
+};
+
+type DockerSyncSnapshotItem = DockerSyncItem & {
+ componentId: string;
+ image: string;
+ isPresent: true;
+ ports: DockerPortMapping[];
+ status: typeof InstanceState.Running;
+};
+
+async function resolveNameCandidates(name: string): Promise {
+ const mappedName = await getVariable(name).catch(() => null);
+ const deploymentName = await getComponentName(name);
+
+ return [mappedName, deploymentName, name].filter(
+ (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0
+ );
+}
+
+async function syncDockerInstance(name: string): Promise {
+ const resolvedNameCandidates = await resolveNameCandidates(name);
+
+ for (const resolvedName of resolvedNameCandidates) {
+ try {
+ const isPresent = await isContainerPresent(resolvedName);
+ if (!isPresent) {
+ continue;
+ }
+
+ const status = await getContainerStatus(resolvedName);
+ if (status === InstanceState.Running) {
+ const snapshot = await getDockerInstanceSnapshot(resolvedName);
+ if (snapshot !== null) {
+ return {
+ ...snapshot,
+ componentId: snapshot.componentId ?? resolvedName,
+ ports: snapshot.ports ?? [],
+ name,
+ isPresent: true,
+ status,
+ type: InstanceType.Docker,
+ } satisfies DockerSyncSnapshotItem;
+ }
+ }
+
+ return {
+ name,
+ componentId: resolvedName,
+ isPresent: true,
+ status,
+ type: InstanceType.Docker,
+ };
+ } catch {
+ // Try the next candidate. The client will preserve the last known stable state.
+ }
+ }
+
+ return {
+ name,
+ isPresent: false,
+ status: InstanceState.Unknown,
+ type: InstanceType.Docker,
+ };
+}
+
+export async function POST(request: Request) {
+ try {
+ const rawBody = await request.text();
+ const body = rawBody.trim().length > 0 ? (JSON.parse(rawBody) as DockerSyncRequest) : {};
+ const names = Array.isArray(body.names)
+ ? [...new Set(body.names.filter((name): name is string => typeof name === "string" && name.length > 0))]
+ : [];
+
+ const instances = await Promise.all(names.map((name) => syncDockerInstance(name)));
+
+ return NextResponse.json({ instances });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unable to sync docker instances";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/variables/resolve/route.ts b/src/app/api/variables/resolve/route.ts
new file mode 100644
index 0000000..4d408da
--- /dev/null
+++ b/src/app/api/variables/resolve/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from "next/server";
+import { getVariableServer } from "@/lib/variables-action";
+
+type VariableResolveRequest = {
+ name?: string;
+};
+
+export async function POST(request: Request) {
+ try {
+ const rawBody = await request.text();
+ const body = rawBody.trim().length > 0 ? (JSON.parse(rawBody) as VariableResolveRequest) : {};
+ const name = typeof body.name === "string" ? body.name.trim() : "";
+
+ if (name.length === 0) {
+ return NextResponse.json({ value: null });
+ }
+
+ const value = await getVariableServer(name);
+ return NextResponse.json({ value: value ?? null });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unable to resolve variable";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/components/api-check.test.tsx b/src/app/components/api-check.test.tsx
index a8e68b8..6635174 100644
--- a/src/app/components/api-check.test.tsx
+++ b/src/app/components/api-check.test.tsx
@@ -1,5 +1,6 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { APICheck } from "./api-check";
+import { checkAPI } from "@/lib/check-api";
jest.mock("@/lib/check-api", () => ({
checkAPI: jest.fn(),
@@ -55,4 +56,26 @@ describe("APICheck Component", () => {
expect(screen.getByRole("button")).toHaveTextContent("Check");
});
+
+ it("should treat serialized '{true}' tlsComponent value as TLS enabled", async () => {
+ (checkAPI as jest.Mock).mockResolvedValueOnce(true);
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Check" }));
+
+ await waitFor(() => {
+ expect(checkAPI).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tlsComponent: true,
+ })
+ );
+ });
+ });
});
diff --git a/src/app/components/api-check.tsx b/src/app/components/api-check.tsx
index 72b2611..e9f6aa3 100644
--- a/src/app/components/api-check.tsx
+++ b/src/app/components/api-check.tsx
@@ -1,4 +1,5 @@
import { APIBase } from "@/lib/api-base";
+import { isTruthyBooleanLike } from "@/lib/utils";
/**
* APICheck component
@@ -30,13 +31,17 @@ export function APICheck({
targetStatusCode = 200,
url = null,
tlsComponent = false,
+ ...restProps
}: {
componentName?: string | null;
path?: string;
targetStatusCode?: number;
url?: string | null;
- tlsComponent?: boolean;
+ tlsComponent?: boolean | string;
+ [key: string]: unknown;
}) {
+ const resolvedTlsComponent = isTruthyBooleanLike(tlsComponent);
+
return (
API Check
@@ -64,11 +69,12 @@ export function APICheck({
diff --git a/src/app/components/api-header-check.tsx b/src/app/components/api-header-check.tsx
index 4150edd..e1edfb7 100644
--- a/src/app/components/api-header-check.tsx
+++ b/src/app/components/api-header-check.tsx
@@ -1,4 +1,5 @@
import { APIBase } from "@/lib/api-base"
+import { isTruthyBooleanLike } from "@/lib/utils";
/**
* APIHeaderCheck component
@@ -34,7 +35,8 @@ export function APIHeaderCheck({
path = "/",
targetStatusCode = 200,
url = null,
- tlsComponent = false
+ tlsComponent = false,
+ ...restProps
}: {
componentName?: string | null;
headerName?: string | null;
@@ -42,8 +44,11 @@ export function APIHeaderCheck({
path?: string;
targetStatusCode?: number;
url?: string | null;
- tlsComponent?: boolean;
+ tlsComponent?: boolean | string;
+ [key: string]: unknown;
}) {
+ const resolvedTlsComponent = isTruthyBooleanLike(tlsComponent);
+
return (
API Header Check
@@ -70,13 +75,14 @@ export function APIHeaderCheck({
diff --git a/src/app/components/api-response-check.tsx b/src/app/components/api-response-check.tsx
index ae20c5e..0a00107 100644
--- a/src/app/components/api-response-check.tsx
+++ b/src/app/components/api-response-check.tsx
@@ -1,4 +1,5 @@
import { APIBase } from "@/lib/api-base";
+import { isTruthyBooleanLike } from "@/lib/utils";
/**
* APIResponseCheck component
@@ -33,6 +34,7 @@ export function APIResponseCheck({
targetStatusCode = 200,
url = null,
tlsComponent = false,
+ ...restProps
}: {
componentName?: string | null;
headerName?: string;
@@ -41,8 +43,11 @@ export function APIResponseCheck({
searchString?: string | null;
targetStatusCode?: number;
url?: string | null;
- tlsComponent?: boolean;
+ tlsComponent?: boolean | string;
+ [key: string]: unknown;
}) {
+ const resolvedTlsComponent = isTruthyBooleanLike(tlsComponent);
+
return (
API Response Check
@@ -74,12 +79,13 @@ export function APIResponseCheck({
diff --git a/src/app/components/button.tsx b/src/app/components/button.tsx
index f9334cc..c66f4c1 100644
--- a/src/app/components/button.tsx
+++ b/src/app/components/button.tsx
@@ -3,7 +3,7 @@ import { mergeClasses } from "@/app/lib/utils";
import { useState } from "react";
const buttonStyles =
- "px-4 py-2 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed";
+ "px-4 py-2 text-white rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed";
/**
* A reusable button component.
diff --git a/src/app/components/codeblock-copy.tsx b/src/app/components/codeblock-copy.tsx
new file mode 100644
index 0000000..892739f
--- /dev/null
+++ b/src/app/components/codeblock-copy.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { useState } from "react";
+
+type CodeBlockCopyProps = {
+ children: string;
+};
+
+export function CodeBlockCopy({ children }: CodeBlockCopyProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(children.trim());
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/components/codeblock.test.tsx b/src/app/components/codeblock.test.tsx
new file mode 100644
index 0000000..3ece95f
--- /dev/null
+++ b/src/app/components/codeblock.test.tsx
@@ -0,0 +1,65 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { CodeBlock } from "./codeblock";
+import { getVariable } from "@/lib/variables";
+
+jest.mock("@/lib/variables", () => ({
+ getVariable: jest.fn(),
+}));
+
+jest.mock("react-syntax-highlighter", () => ({
+ Prism: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+jest.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({
+ solarizedlight: {},
+}));
+
+Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+});
+
+describe("CodeBlock", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders inline code styling by default", async () => {
+ const ui = await CodeBlock({ children: "npm run dev" });
+ render(ui);
+
+ const code = screen.getByText("npm run dev");
+ expect(code.tagName).toBe("CODE");
+ });
+
+ it("renders block code with copy button", async () => {
+ const ui = await CodeBlock({ children: "const value = 1;", isBlock: true });
+ render(ui);
+
+ expect(screen.getByText("Copy")).toBeInTheDocument();
+ expect(screen.getByText("const value = 1;")).toBeInTheDocument();
+ });
+
+ it("copies block code to clipboard", async () => {
+ const ui = await CodeBlock({ children: "const value = 1;", isBlock: true });
+ render(ui);
+
+ fireEvent.click(screen.getByText("Copy"));
+
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const value = 1;");
+ });
+ expect(await screen.findByText("Copied")).toBeInTheDocument();
+ });
+
+ it("replaces variable placeholders in block code", async () => {
+ (getVariable as jest.Mock).mockResolvedValue("dep-value");
+
+ const ui = await CodeBlock({ children: "echo {{deployment_identifier}}", isBlock: true, className: "language-bash" });
+ render(ui);
+
+ expect(getVariable).toHaveBeenCalledWith("deployment_identifier");
+ expect(screen.getByText("echo dep-value")).toBeInTheDocument();
+ });
+});
diff --git a/src/app/components/codeblock.tsx b/src/app/components/codeblock.tsx
new file mode 100644
index 0000000..badb80d
--- /dev/null
+++ b/src/app/components/codeblock.tsx
@@ -0,0 +1,96 @@
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { solarizedlight } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { CodeBlockCopy } from "@/app/components/codeblock-copy";
+import { getVariable } from "@/lib/variables";
+
+type CodeBlockProps = {
+ children?: React.ReactNode;
+ className?: string;
+ isBlock?: boolean;
+};
+
+function getCodeText(node: React.ReactNode): string {
+ if (typeof node === "string" || typeof node === "number") {
+ return String(node);
+ }
+
+ if (Array.isArray(node)) {
+ return node.map(getCodeText).join("");
+ }
+
+ if (node && typeof node === "object" && "props" in node) {
+ const props = (node as { props?: { children?: React.ReactNode } }).props;
+ return getCodeText(props?.children);
+ }
+
+ return "";
+}
+
+async function processChildren(children: string): Promise {
+ if (!children) {
+ return children;
+ }
+
+ const regex = /{{(.*?)}}/g;
+ const variables: string[] = [];
+ const promises: Promise[] = [];
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(children)) !== null) {
+ const variable = match[1]?.trim();
+ if (!variable) {
+ continue;
+ }
+ variables.push(variable);
+ promises.push(getVariable(variable));
+ }
+
+ if (variables.length === 0) {
+ return children;
+ }
+
+ const results = await Promise.all(promises);
+ let nextChildren = children;
+
+ variables.forEach((variable, index) => {
+ nextChildren = nextChildren.replace(`{{${variable}}}`, String(results[index] ?? ""));
+ });
+
+ return nextChildren;
+}
+
+export async function CodeBlock({ children, className = "", isBlock = false, ...props }: CodeBlockProps) {
+ const codeText = getCodeText(children);
+ const processedCodeText = await processChildren(codeText);
+
+ if (!isBlock) {
+ return (
+
+
+ {codeText}
+
+
+ );
+ }
+
+ const language = /language-(\w+)/.exec(className || "");
+
+ return (
+
+ {processedCodeText}
+
+ {processedCodeText}
+
+
+ );
+}
diff --git a/src/app/components/create-certificate.tsx b/src/app/components/create-certificate.tsx
new file mode 100644
index 0000000..0541752
--- /dev/null
+++ b/src/app/components/create-certificate.tsx
@@ -0,0 +1,68 @@
+import { Suspense } from "react";
+import { createCertificate } from "@/lib/certificates";
+import { getDeploymentIdentifier } from "@/lib/variables";
+import { DownloadItem } from "@/app/components/download-item";
+import { CodeBlockCopy } from "@/app/components/codeblock-copy";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { solarizedlight } from "react-syntax-highlighter/dist/esm/styles/prism";
+
+type CreateCertificateProps = {
+ commonName?: string;
+ name?: string;
+};
+
+/**
+ * Creates and displays a certificate/key pair for a provided common name.
+ */
+export async function CreateCertificate({ commonName = "", name = "" }: CreateCertificateProps) {
+ let normalizedName = (commonName || name).trim();
+
+ if (!normalizedName) {
+ const deploymentIdentifier = await getDeploymentIdentifier();
+ if (deploymentIdentifier) {
+ normalizedName = `${deploymentIdentifier}.f5demos.com`;
+ }
+ }
+
+ if (!normalizedName) {
+ return "commonName empty";
+ }
+
+ let value: Awaited>;
+
+ try {
+ value = await createCertificate(normalizedName);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`unable to retrieve certificate ${normalizedName}: ${message}`);
+ return "Error retrieving certificate";
+ }
+
+ return (
+
+
+
+ Certificate for {normalizedName}
+
+
+
+ {value.cert}
+
+ {value.cert}
+
+
+
+
+ Key for {normalizedName}
+
+
+
+ {value.key}
+
+ {value.key}
+
+
+
+
+ );
+}
diff --git a/src/app/components/docker-container.tsx b/src/app/components/docker-container.tsx
new file mode 100644
index 0000000..ee379bf
--- /dev/null
+++ b/src/app/components/docker-container.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { Docker, DockerInstance } from "@/app/components/docker";
+
+type DockerContainerProps = {
+ children?: React.ReactNode;
+};
+
+export function DockerContainer({ children }: DockerContainerProps) {
+ const dockerComponents = React.Children.toArray(children).filter((child) => {
+ if (!React.isValidElement(child)) {
+ return false;
+ }
+
+ const childType = child.type as React.ElementType;
+ return childType === Docker || childType === DockerInstance;
+ });
+
+ return (
+
+
Docker Containers
+
+ {dockerComponents.map((component, index) => (
+
+ {component}
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/components/docker-instance.tsx b/src/app/components/docker-instance.tsx
new file mode 100644
index 0000000..148474a
--- /dev/null
+++ b/src/app/components/docker-instance.tsx
@@ -0,0 +1,481 @@
+"use client";
+
+import { Button } from "@/app/components/button";
+import { useInstancesContext } from "@/app/contexts/instances";
+import { getContainerLogs, getContainerPorts } from "@/app/lib/docker-lib";
+import { syncDockerInstances } from "@/app/lib/docker-instance-sync";
+import { DockerShellTerminal } from "@/app/components/docker-shell-terminal";
+import { checkAPI } from "@/lib/check-api";
+import {
+ DockerAttribute,
+ DockerPortMapping,
+ InstanceDocker,
+ InstanceDockerEnv,
+ InstanceState,
+ InstanceType,
+} from "@/lib/types";
+import { useEffect, useMemo, useState } from "react";
+
+const defaultPorts: DockerPortMapping[] = [{ containerPort: 80 }];
+
+const isContainerDoesNotExistError = (error: unknown) =>
+ error instanceof Error && error.message.toLowerCase().includes("no such container");
+
+const formatStatusLabel = (label: string) => {
+ if (label === InstanceState.Unknown) {
+ return "Not Started";
+ }
+
+ return label.length > 0 ? label.charAt(0).toUpperCase() + label.slice(1) : label;
+};
+
+function summarizeLogs(rawLogs: string, maxLines = 8, scanWindow = 200): string {
+ const lines = rawLogs
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .filter((line) => line.length > 0);
+
+ const recentLines = lines.slice(-scanWindow);
+ const severityLines = recentLines.filter((line) =>
+ /(error|critical|fatal)/i.test(line)
+ );
+
+ if (severityLines.length === 0) {
+ return "No recent error/critical/fatal log lines found.";
+ }
+
+ return severityLines.slice(-maxLines).join("\n");
+}
+
+type DockerInstanceProps = {
+ name: string;
+ description?: string;
+ image: string;
+ env?: InstanceDockerEnv[];
+ port?: { host?: number; container: number } | null;
+ ports?: DockerPortMapping[];
+ attrs?: DockerAttribute[];
+ initialState?: InstanceState;
+};
+
+export function DockerInstance({
+ name,
+ description = "",
+ image,
+ env = [],
+ port,
+ ports = defaultPorts,
+ attrs = [],
+ initialState = InstanceState.Unknown,
+}: DockerInstanceProps) {
+ const { addInstance, instances, removeInstance, setDockerStoppedByUser, upsertDockerInstance } = useInstancesContext();
+
+ const resolvedDescription = description;
+ const resolvedPorts = useMemo(
+ () => (port ? [{ containerPort: port.container, hostPort: port.host }] : ports),
+ [port, ports]
+ );
+
+ const [state, setState] = useState(initialState);
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [showLogs, setShowLogs] = useState(false);
+ const [logs, setLogs] = useState("");
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+ const [lastLogSummary, setLastLogSummary] = useState(null);
+ const [runtimePorts, setRuntimePorts] = useState(null);
+ const [pendingActionLabel, setPendingActionLabel] = useState(null);
+ const [awaitingStart, setAwaitingStart] = useState(false);
+ const [awaitingRemoval, setAwaitingRemoval] = useState(false);
+
+ const dockerInstance = useMemo(
+ () =>
+ (instances ?? []).find(
+ (entry): entry is InstanceDocker =>
+ entry.type === InstanceType.Docker && entry.name === name
+ ),
+ [instances, name]
+ );
+
+ const stoppedByUser = dockerInstance?.stoppedByUser === true;
+ const externalState = dockerInstance?.status ?? InstanceState.Unknown;
+ const effectiveState = awaitingStart || awaitingRemoval ? state : externalState;
+ const displayPorts =
+ effectiveState === InstanceState.Running
+ ? dockerInstance?.ports ?? runtimePorts ?? resolvedPorts
+ : resolvedPorts;
+ const componentName = dockerInstance?.componentId ?? "unknown";
+
+ useEffect(() => {
+ if (awaitingRemoval && !dockerInstance) {
+ const resetId = window.setTimeout(() => {
+ setState(InstanceState.Unknown);
+ setPendingActionLabel(null);
+ setAwaitingRemoval(false);
+ }, 0);
+
+ return () => {
+ window.clearTimeout(resetId);
+ };
+ }
+ }, [awaitingRemoval, awaitingStart, dockerInstance]);
+
+ useEffect(() => {
+ if (dockerInstance?.status === InstanceState.Running) {
+ return;
+ }
+
+ let cancelled = false;
+
+ const bootstrapDockerInstance = async () => {
+ try {
+ const [syncEntry] = await syncDockerInstances([name]);
+ if (
+ !cancelled &&
+ syncEntry?.isPresent === true &&
+ syncEntry.status === InstanceState.Running &&
+ typeof syncEntry.image === "string" &&
+ syncEntry.image.length > 0 &&
+ typeof syncEntry.componentId === "string" &&
+ syncEntry.componentId.length > 0
+ ) {
+ upsertDockerInstance({
+ componentId: syncEntry.componentId,
+ image: syncEntry.image,
+ name: syncEntry.name,
+ ports: syncEntry.ports,
+ status: syncEntry.status,
+ stoppedByUser: false,
+ type: InstanceType.Docker,
+ });
+ }
+ } catch {
+ // Leave state unchanged if bootstrap fails; the user can still run the check manually.
+ }
+ };
+
+ bootstrapDockerInstance();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [dockerInstance?.status, name, upsertDockerInstance]);
+
+ const canCreate =
+ effectiveState !== InstanceState.Creating &&
+ effectiveState !== InstanceState.Running &&
+ effectiveState !== InstanceState.Removing;
+
+ const canStop = effectiveState === InstanceState.Running;
+ const isExistingNonRunningState =
+ effectiveState === InstanceState.Created ||
+ effectiveState === InstanceState.Paused ||
+ effectiveState === InstanceState.Restarting ||
+ effectiveState === InstanceState.Exited ||
+ effectiveState === InstanceState.Dead;
+ const isUnexpectedNonRunningState = isExistingNonRunningState && !stoppedByUser;
+ const showDelete = isUnexpectedNonRunningState;
+ const visibleLastLogSummary = isUnexpectedNonRunningState ? lastLogSummary : null;
+
+ const finishCreateSuccess = () => {
+ setState(InstanceState.Running);
+ setPendingActionLabel(null);
+ setAwaitingStart(false);
+ };
+
+ const finishCreateFailure = (message: string) => {
+ setError(message);
+ setState(InstanceState.Unknown);
+ setPendingActionLabel(null);
+ setAwaitingStart(false);
+ };
+
+ const finishRemovalFailure = (message: string) => {
+ setError(message);
+ setAwaitingRemoval(false);
+ setPendingActionLabel(null);
+ setState(InstanceState.Running);
+ };
+
+ useEffect(() => {
+ if (!isUnexpectedNonRunningState) {
+ return;
+ }
+
+ let cancelled = false;
+ const fetchRecentLogs = async () => {
+ try {
+ const recentLogs = await getContainerLogs(name);
+ if (!cancelled) {
+ setLastLogSummary(summarizeLogs(recentLogs));
+ }
+ } catch (error) {
+ if (!cancelled) {
+ if (isContainerDoesNotExistError(error)) {
+ setLastLogSummary(null);
+ return;
+ }
+ setLastLogSummary("Unable to retrieve recent logs.");
+ }
+ }
+ };
+
+ fetchRecentLogs();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isUnexpectedNonRunningState, name]);
+
+ const handleCreate = async () => {
+ setState(InstanceState.Creating);
+ setPendingActionLabel("Starting container...");
+ setAwaitingStart(true);
+ setError(null);
+ setMessage(null);
+ setDockerStoppedByUser(name, false);
+
+ try {
+ const createError = await addInstance({
+ attrs,
+ env,
+ image,
+ name,
+ ports: resolvedPorts,
+ type: InstanceType.Docker,
+ } as InstanceDocker);
+
+ if (createError) {
+ finishCreateFailure(createError.message || "Unable to create container");
+ return;
+ }
+
+ finishCreateSuccess();
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : "Unable to create container";
+ finishCreateFailure(errorMessage);
+ }
+ };
+
+ const handleStop = async () => {
+ setState(InstanceState.Removing);
+ setPendingActionLabel("Stopping container...");
+ setAwaitingRemoval(true);
+ setError(null);
+ setMessage(null);
+
+ try {
+ await removeInstance({
+ name,
+ type: InstanceType.Docker,
+ } as InstanceDocker);
+ setShowLogs(false);
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : "Unable to stop container";
+ finishRemovalFailure(errorMessage);
+ }
+ };
+
+ const handleDelete = async () => {
+ setState(InstanceState.Removing);
+ setPendingActionLabel("Deleting container...");
+ setAwaitingRemoval(true);
+ setError(null);
+ setMessage(null);
+
+ try {
+ await removeInstance({
+ name,
+ type: InstanceType.Docker,
+ } as InstanceDocker);
+ setShowLogs(false);
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : "Unable to delete container";
+ finishRemovalFailure(errorMessage);
+ }
+ };
+
+ const handleTest = async () => {
+ if (!canStop) {
+ return;
+ }
+
+ setError(null);
+ setMessage(null);
+
+ try {
+ const latestPorts = await getContainerPorts(name).catch(() => displayPorts);
+ const portsForCheck = latestPorts.length > 0 ? latestPorts : displayPorts;
+ if (latestPorts.length > 0) {
+ setRuntimePorts(latestPorts);
+ }
+
+ const componentIdForCheck = dockerInstance?.componentId ?? undefined;
+
+ await checkAPI({
+ component: {
+ ...(componentIdForCheck ? { componentId: componentIdForCheck } : {}),
+ image,
+ name,
+ ports: portsForCheck,
+ type: InstanceType.Docker,
+ },
+ });
+ setMessage("works");
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : "API test failed";
+ setError(errorMessage);
+ }
+ };
+
+ const handleToggleLogs = async () => {
+ if (showLogs) {
+ setShowLogs(false);
+ return;
+ }
+
+ setError(null);
+
+ try {
+ const nextLogs = await getContainerLogs(name);
+ setLogs(nextLogs);
+ setShowLogs(true);
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : "Unable to fetch logs";
+ setError(errorMessage);
+ }
+ };
+
+ return (
+
+ {showLogs && (
+
+
+
+
+
+
+ {logs || "No logs available"}
+
+
+
+ )}
+
+
+
{name}
+
+ {effectiveState === InstanceState.Running && isExpanded && (
+
+ container name: {componentName}
+
+ )}
+
+ {isExpanded &&
{resolvedDescription}
}
+
+ {isExpanded && (
+ <>
+
+
+ image: {image}
+
+ {displayPorts.map((mappedPort, index) => (
+
+ ports: host {mappedPort.hostPort ?? "n/a"}, container {mappedPort.containerPort}
+
+ ))}
+
+
+ {attrs.length > 0 && (
+
+ {attrs.map((entry, index) => (
+
+ {entry.name}: {entry.value}
+
+ ))}
+
+ )}
+
+ {env.length > 0 && (
+
+ {env.map((entry, index) => (
+
+ {entry.name}: {entry.isSecret ? "********" : (entry.value ?? "")}
+
+ ))}
+
+ )}
+
+ {effectiveState === InstanceState.Running && (
+
+ )}
+ >
+ )}
+
+
+
+
+ Status: {formatStatusLabel(pendingActionLabel ?? effectiveState)}
+
+
+ {visibleLastLogSummary && (
+
+
+ Recent Logs
+
+
+ {visibleLastLogSummary}
+
+
+ )}
+
+
+
+
+
+
+ {showDelete && (
+
+ )}
+
+ {canStop && (
+
+ )}
+
+ {canStop && (
+
+ )}
+
+
+
+
+ {error &&
Error: {error}
}
+ {message &&
{message}
}
+
+
+ );
+}
diff --git a/src/app/components/docker-shell-terminal.tsx b/src/app/components/docker-shell-terminal.tsx
new file mode 100644
index 0000000..39405b7
--- /dev/null
+++ b/src/app/components/docker-shell-terminal.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { execShellCommand } from "@/app/lib/docker-lib";
+import { FormEvent, useEffect, useRef, useState } from "react";
+
+type DockerShellTerminalProps = {
+ containerName: string;
+};
+
+export function DockerShellTerminal({ containerName }: DockerShellTerminalProps) {
+ const [command, setCommand] = useState("");
+ const [output, setOutput] = useState("");
+ const outputRef = useRef(null);
+
+ const appendOutput = (line: string) => {
+ setOutput((prev) => `${prev}\n${line}`.trim());
+ };
+
+ useEffect(() => {
+ if (!outputRef.current) {
+ return;
+ }
+
+ outputRef.current.scrollTop = outputRef.current.scrollHeight;
+ }, [output]);
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ const trimmed = command.trim();
+ if (!trimmed) {
+ return;
+ }
+
+ appendOutput(`$ ${trimmed}`);
+ setCommand("");
+
+ try {
+ const result = await execShellCommand(containerName, trimmed);
+ appendOutput(result);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Command failed";
+ appendOutput(`Error: ${message}`);
+ }
+ };
+
+ return (
+
+
Container Shell
+
+ {output || "Run commands against this container."}
+
+
+
+ );
+}
diff --git a/src/app/components/docker.test.tsx b/src/app/components/docker.test.tsx
index ba46c65..84b04ac 100644
--- a/src/app/components/docker.test.tsx
+++ b/src/app/components/docker.test.tsx
@@ -1,23 +1,50 @@
-import { render, fireEvent, screen, act } from "@testing-library/react";
+import { render, fireEvent, screen, act, waitFor } from "@testing-library/react";
import { InstancesContextType } from "../contexts/instances";
-import { getContainerStatus } from "@/app/lib/docker-lib";
+import { getContainerLogs } from "@/app/lib/docker-lib";
+import { getContainerPorts } from "@/app/lib/docker-lib";
+import { syncDockerInstances } from "@/app/lib/docker-instance-sync";
+import { checkAPI } from "@/lib/check-api";
+import { getComponentName } from "@/lib/variables";
import { useInstancesContext } from "@/app/contexts/instances";
+import { InstanceState, InstanceType } from "@/lib/types";
jest.mock("@/app/lib/docker-lib", () => ({
createContainer: jest.fn(),
+ execShellCommand: jest.fn().mockResolvedValue("ok"),
+ getContainerLogs: jest.fn().mockResolvedValue("log-line"),
+ getContainerPorts: jest.fn().mockResolvedValue([]),
+ getDockerInstanceSnapshot: jest.fn().mockResolvedValue(null),
removeContainer: jest.fn(),
isContainerDoesNotExistError: jest.fn(),
getContainerStatus: jest.fn().mockResolvedValue("unknown"),
}));
+jest.mock("@/lib/check-api", () => ({
+ checkAPI: jest.fn().mockResolvedValue(true),
+}));
+
+jest.mock("@/app/lib/docker-instance-sync", () => ({
+ syncDockerInstances: jest.fn().mockResolvedValue([]),
+}));
+
+jest.mock("@/lib/variables", () => ({
+ getComponentName: jest.fn().mockResolvedValue("testing-test-docker"),
+ getVariable: jest.fn().mockResolvedValue(null),
+ setClientVariable: jest.fn(),
+}));
+
const mockAddInstance = jest.fn();
const mockRemoveInstance = jest.fn();
+const mockSetDockerStoppedByUser = jest.fn();
+const mockUpsertDockerInstance = jest.fn();
const providerProps: InstancesContextType = {
instances: [],
addInstance: mockAddInstance,
removeInstance: mockRemoveInstance,
+ setDockerStoppedByUser: mockSetDockerStoppedByUser,
+ upsertDockerInstance: mockUpsertDockerInstance,
};
jest.mock("@/app/contexts/instances", () => ({
@@ -28,35 +55,87 @@ import { DockerInstance } from "./docker";
import React from "react";
describe("DockerInstance Component", () => {
+ const createDeferred = () => {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+ };
+
beforeEach(() => {
- (useInstancesContext as jest.Mock).mockReturnValue(providerProps);
+ jest.clearAllMocks();
+ localStorage.clear();
+ (getComponentName as jest.Mock).mockResolvedValue("testing-test-docker");
+ (checkAPI as jest.Mock).mockResolvedValue(true);
+ (syncDockerInstances as jest.Mock).mockResolvedValue([]);
+ mockUpsertDockerInstance.mockImplementation(() => undefined);
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Unknown,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
});
- it("should render DockerInstance with default props", () => {
+ const flushStatusUpdate = async () => {
+ await act(async () => {
+ await Promise.resolve();
+ });
+ };
+
+ const expectStatusText = (statusText: string) => {
+ expect(screen.getByTestId("docker-status")).toHaveTextContent(
+ new RegExp(`Status:\\s*${statusText}`, "i")
+ );
+ };
+
+ it("should render DockerInstance with default props", async () => {
const { getByText } = render(
);
+ await flushStatusUpdate();
+
+ await act(async () => {
+ fireEvent.click(getByText("Details"));
+ });
+
expect(getByText("Test Docker")).toBeInTheDocument();
expect(getByText("test-image")).toBeInTheDocument();
- expect(getByText("80")).toBeInTheDocument();
+ expect(screen.getByText(/host\s*n\/a,\s*container\s*80/i)).toBeInTheDocument();
});
it("should call addInstance when Create button is clicked", async () => {
const { getByText } = render(
);
- const createButton = getByText("Create");
+ await flushStatusUpdate();
+
+ const createButton = getByText("Run");
await act(async () => {
fireEvent.click(createButton);
});
expect(mockAddInstance).toHaveBeenCalledWith({
+ attrs: [{ name: "volume", value: "/host:/container" }],
+ env: [{ name: "TOKEN", isVariable: true }],
image: "test-image",
name: "Test Docker",
ports: [{ containerPort: 8089, hostPort: 9000 }],
@@ -64,8 +143,75 @@ describe("DockerInstance Component", () => {
});
});
- it("should call removeInstance when Stop button is clicked", async () => {
- (getContainerStatus as jest.Mock).mockResolvedValue("running");
+ it("should show pending indicator while Run is in progress", async () => {
+ const deferred = createDeferred();
+ mockAddInstance.mockReturnValueOnce(deferred.promise);
+
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Run"));
+ });
+
+ expectStatusText("Starting container\\.\\.\\.");
+
+ await act(async () => {
+ deferred.resolve(null);
+ await deferred.promise;
+ });
+
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Starting container\.\.\./i);
+ });
+
+ it("should not switch to not started while run is reconciling", async () => {
+ const deferred = createDeferred();
+ mockAddInstance.mockReturnValueOnce(deferred.promise);
+
+ const { rerender } = render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Run"));
+ });
+
+ expectStatusText("Starting container\\.\\.\\.");
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Unknown,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ rerender();
+
+ expectStatusText("Starting container\\.\\.\\.");
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Status:\s*Not Started/i);
+
+ await act(async () => {
+ deferred.resolve(null);
+ await deferred.promise;
+ });
+ });
+
+ it("should removeInstance when Stop button is clicked", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
await act(async () => {
render();
@@ -83,7 +229,490 @@ describe("DockerInstance Component", () => {
});
});
- it("should render description if provided", () => {
+ it("should disable Run button when container is running", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ render();
+
+ await flushStatusUpdate();
+
+ expect(screen.getByText("Run")).toBeDisabled();
+ });
+
+ it("should render running status immediately when the context already has a running instance", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ render();
+
+ expectStatusText("Running");
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Not Started/i);
+ });
+
+ it("should show pending indicator while Stop is in progress", async () => {
+ const deferred = createDeferred();
+ mockRemoveInstance.mockReturnValueOnce(deferred.promise);
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ const { rerender } = render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Stop"));
+ });
+
+ expectStatusText("Stopping container\\.\\.\\.");
+
+ await act(async () => {
+ deferred.resolve();
+ await deferred.promise;
+ });
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [],
+ });
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Stopping container\.\.\./i);
+ });
+ });
+
+ it("should not flash running status after Stop while removal is still reconciling", async () => {
+ const deferred = createDeferred();
+ mockRemoveInstance.mockReturnValueOnce(deferred.promise);
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ const { rerender } = render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Stop"));
+ });
+
+ expectStatusText("Stopping container\\.\\.\\.");
+
+ await act(async () => {
+ deferred.resolve();
+ await deferred.promise;
+ });
+
+ // Context still reports running until reconciliation completes.
+ rerender();
+ expectStatusText("Stopping container\\.\\.\\.");
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Status:\s*Running/i);
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [],
+ });
+ rerender();
+
+ await waitFor(() => {
+ expectStatusText("Not Started");
+ });
+ });
+
+ it("should not switch to not started while stop is reconciling", async () => {
+ const deferred = createDeferred();
+ mockRemoveInstance.mockReturnValueOnce(deferred.promise);
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ const { rerender } = render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Stop"));
+ });
+
+ expectStatusText("Stopping container\\.\\.\\.");
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Unknown,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ rerender();
+
+ expectStatusText("Stopping container\\.\\.\\.");
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Status:\s*Not Started/i);
+
+ await act(async () => {
+ deferred.resolve();
+ await deferred.promise;
+ });
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [],
+ });
+ rerender();
+
+ await waitFor(() => {
+ expectStatusText("Not Started");
+ });
+ });
+
+ it("should only show Delete button when container terminated unexpectedly", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ render();
+
+ expect(screen.queryByText("Delete")).not.toBeInTheDocument();
+ });
+
+ it("should call removeInstance when Delete button is clicked for unexpected termination", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Exited,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ await act(async () => {
+ const deleteButton = screen.getByText("Delete");
+ expect(deleteButton).not.toBeDisabled();
+ fireEvent.click(deleteButton);
+ });
+
+ expect(mockRemoveInstance).toHaveBeenCalledWith({
+ name: "Test Docker",
+ type: 0,
+ });
+ });
+
+ it("should show non-running status in red when container stopped unexpectedly", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Exited,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ render();
+
+ await flushStatusUpdate();
+
+ const statusElement = screen.getByTestId("docker-status");
+ expectStatusText("Exited");
+ expect(statusElement).toHaveClass("text-red-600");
+ });
+
+ it("should reset status to not started when container is removed from context", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Exited,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ const { rerender } = render();
+
+ await flushStatusUpdate();
+ expectStatusText("Exited");
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [],
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expectStatusText("Not Started");
+ });
+ });
+
+ it("should display not started when docker instance status is missing", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ render();
+
+ await waitFor(() => {
+ expectStatusText("Not Started");
+ });
+ });
+
+ it("should update status when the docker instance appears in context after the initial render", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValueOnce({
+ ...providerProps,
+ instances: [],
+ });
+
+ const { rerender } = render();
+
+ await flushStatusUpdate();
+ expectStatusText("Not Started");
+
+ (useInstancesContext as jest.Mock).mockReturnValueOnce({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expectStatusText("Running");
+ expect(screen.getByTestId("docker-status")).not.toHaveTextContent(/Not Started/i);
+ });
+ });
+
+ it("should keep the rendered state aligned with the current context status", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ const { rerender } = render();
+
+ expectStatusText("Running");
+
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [],
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expectStatusText("Not Started");
+ });
+ });
+
+ it("should bootstrap a live docker instance into context when the local state is empty", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [],
+ });
+ (syncDockerInstances as jest.Mock).mockResolvedValueOnce([
+ {
+ componentId: "testing-test-docker",
+ image: "test-image",
+ isPresent: true,
+ name: "Test Docker",
+ ports: [],
+ status: InstanceState.Running,
+ type: InstanceType.Docker,
+ },
+ ]);
+
+ render();
+
+ await waitFor(() => {
+ expect(mockUpsertDockerInstance).toHaveBeenCalledWith(
+ expect.objectContaining({
+ componentId: "testing-test-docker",
+ name: "Test Docker",
+ status: InstanceState.Running,
+ type: InstanceType.Docker,
+ })
+ );
+ expect(syncDockerInstances).toHaveBeenCalledWith(["Test Docker"]);
+ });
+ });
+
+ it("should keep non-running status non-red when user explicitly stops container", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Exited,
+ stoppedByUser: true,
+ type: 0,
+ },
+ ],
+ });
+
+ const { rerender } = render();
+
+ rerender();
+
+ const statusElement = screen.getByTestId("docker-status");
+ expectStatusText("Exited");
+ expect(statusElement).toHaveClass("text-gray-700");
+ expect(statusElement).not.toHaveClass("text-red-600");
+ });
+
+ it("should show recent log summary when container stops unexpectedly", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Exited,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+ (getContainerLogs as jest.Mock).mockResolvedValue(
+ "line1\ncritical: disk pressure\nline2\nERROR: upstream timeout\nline3\nfatal: process crashed\nline4\n"
+ );
+
+ render();
+
+ await flushStatusUpdate();
+
+ await waitFor(() => {
+ expect(screen.getByText("Recent Logs")).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText(/line1/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/line2/)).not.toBeInTheDocument();
+ expect(screen.getByText(/critical: disk pressure/i)).toBeInTheDocument();
+ expect(screen.getByText(/error: upstream timeout/i)).toBeInTheDocument();
+ expect(screen.getByText(/fatal: process crashed/i)).toBeInTheDocument();
+ });
+
+ it("should hide recent logs summary when container no longer exists", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ status: InstanceState.Exited,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+ (getContainerLogs as jest.Mock).mockRejectedValue(new Error("no such container"));
+
+ render();
+
+ await flushStatusUpdate();
+
+ await waitFor(() => {
+ expect(getContainerLogs).toHaveBeenCalledWith("Test Docker");
+ });
+ expect(screen.queryByText("Recent Logs")).not.toBeInTheDocument();
+ expect(screen.queryByText("Unable to retrieve recent logs.")).not.toBeInTheDocument();
+ });
+
+ it("should render description if provided", async () => {
const { getByText } = render(
{
/>
);
+ await flushStatusUpdate();
+
+ await act(async () => {
+ fireEvent.click(getByText("Details"));
+ });
+
expect(getByText("Test Description")).toBeInTheDocument();
});
- it("should render custom ports if provided", () => {
+ it("should render custom ports if provided", async () => {
const customPorts = [{ containerPort: 8080, hostPort: 8080 }];
const { getByText } = render(
{
/>
);
- expect(getByText("8080")).toBeInTheDocument();
+ await flushStatusUpdate();
+
+ await act(async () => {
+ fireEvent.click(getByText("Details"));
+ });
+
+ expect(screen.getByText(/host\s*8080,\s*container\s*8080/i)).toBeInTheDocument();
+ });
+
+ it("should show docker-assigned host port for running container", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ instances: [
+ {
+ image: "test-image",
+ name: "Test Docker",
+ ports: [{ containerPort: 80, hostPort: 49153 }],
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ },
+ ],
+ });
+
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Details"));
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/host\s*49153,\s*container\s*80/i)).toBeInTheDocument();
+ });
+ });
+
+ it("should render create errors returned from addInstance", async () => {
+ (useInstancesContext as jest.Mock).mockReturnValue({
+ ...providerProps,
+ addInstance: jest.fn().mockResolvedValue(new Error("Docker registry authentication is required")),
+ });
+
+ render();
+
+ await flushStatusUpdate();
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Run"));
+ });
+
+ expect(screen.getByText("Error: Docker registry authentication is required")).toBeInTheDocument();
});
});
diff --git a/src/app/components/docker.tsx b/src/app/components/docker.tsx
index a24d930..d5e4feb 100644
--- a/src/app/components/docker.tsx
+++ b/src/app/components/docker.tsx
@@ -1,103 +1,83 @@
-"use client";
-import { Button } from "@/app/components/button";
-import { useInstancesContext } from "@/app/contexts/instances";
-import { InstanceState, InstanceType } from "@/lib/types";
-import { getContainerStatus } from "@/app/lib/docker-lib";
-import type { InstanceDocker, DockerPortMapping } from "@/lib/types";
-import { useEffect, useState } from "react";
+import { DockerInstance } from "./docker-instance";
+import { DockerAttribute, DockerPortMapping, InstanceDockerEnv, InstanceState } from "@/lib/types";
-/**
- * The default ports for a Docker instance.
- */
-const defaultPorts: DockerPortMapping[] = [{ containerPort: 80, hostPort: 80 }];
-
-/**
- * A component that represents a Docker instance.
- *
- * @param {InstanceDocker} props - The props for the DockerInstance component.
- * @returns {JSX.Element} - The rendered DockerInstance component.
- */
-export function DockerInstance({
- name,
- description = "",
- image,
- ports = defaultPorts as DockerPortMapping[],
-}: {
+type DockerProps = {
name: string;
description?: string;
image: string;
+ env?: InstanceDockerEnv[] | string;
+ port?: { host?: number; container: number } | null;
ports?: DockerPortMapping[];
-}) {
- const { instances, addInstance, removeInstance } = useInstancesContext();
+ attrs?: DockerAttribute[] | string;
+};
+
+function normalizeDockerEnvProp(envProp?: InstanceDockerEnv[] | string): InstanceDockerEnv[] | undefined {
+ if (!envProp) {
+ return undefined;
+ }
+
+ if (Array.isArray(envProp)) {
+ return envProp;
+ }
+
+ try {
+ const parsed = JSON.parse(envProp) as unknown;
+ if (!Array.isArray(parsed)) {
+ return undefined;
+ }
+
+ return parsed
+ .filter((entry): entry is Record => !!entry && typeof entry === "object")
+ .map((entry) => ({
+ name: typeof entry.name === "string" ? entry.name : "",
+ ...(typeof entry.value === "string" ? { value: entry.value } : {}),
+ ...(typeof entry.isVariable === "boolean" ? { isVariable: entry.isVariable } : {}),
+ ...(typeof entry.isSecret === "boolean" ? { isSecret: entry.isSecret } : {}),
+ }))
+ .filter((entry) => entry.name.length > 0);
+ } catch {
+ return undefined;
+ }
+}
+
+function normalizeDockerAttrsProp(attrsProp?: DockerAttribute[] | string): DockerAttribute[] | undefined {
+ if (!attrsProp) {
+ return undefined;
+ }
- const [state, setState] = useState(InstanceState.Unknown);
+ if (Array.isArray(attrsProp)) {
+ return attrsProp;
+ }
- useEffect(() => {
- const fetchStatus = async () => {
- const instanceState = await getContainerStatus(name);
- setState(instanceState);
- return instanceState as InstanceState;
- };
- fetchStatus();
- const interval = setInterval(fetchStatus, 5000);
- return () => clearInterval(interval);
- }, [name]);
+ try {
+ const parsed = JSON.parse(attrsProp) as unknown;
+ if (!Array.isArray(parsed)) {
+ return undefined;
+ }
+ return parsed
+ .filter((entry): entry is Record => !!entry && typeof entry === "object")
+ .map((entry) => ({
+ name: typeof entry.name === "string" ? entry.name : "",
+ value: typeof entry.value === "string" ? entry.value : "",
+ }))
+ .filter((entry) => entry.name.length > 0);
+ } catch {
+ return undefined;
+ }
+}
+
+export function Docker(props: DockerProps) {
+ const normalizedEnv = normalizeDockerEnvProp(props.env);
+ const normalizedAttrs = normalizeDockerAttrsProp(props.attrs);
return (
- <>
-
-
{name}
-
{description}
-
-
-
- image:
- {image}
-
-
- port:
- {ports[0].hostPort}
-
-
-
-
-
-
-
-
- {JSON.stringify(instances)}
- >
+
);
}
+
+export { DockerInstance };
diff --git a/src/app/components/download-item.tsx b/src/app/components/download-item.tsx
new file mode 100644
index 0000000..7f486f2
--- /dev/null
+++ b/src/app/components/download-item.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+type DownloadItemProps = {
+ item: string;
+ fileName: string;
+};
+
+export function DownloadItem({ item, fileName }: DownloadItemProps) {
+ const dataHref = `data:text/plain;charset=utf-8,${encodeURIComponent(item)}`;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/components/get-variable.test.tsx b/src/app/components/get-variable.test.tsx
new file mode 100644
index 0000000..d182636
--- /dev/null
+++ b/src/app/components/get-variable.test.tsx
@@ -0,0 +1,98 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { GetVariable } from "./get-variable";
+import { resolveClientVariable } from "@/app/lib/client-variable-resolver";
+
+jest.mock("@/app/lib/client-variable-resolver", () => ({
+ resolveClientVariable: jest.fn(),
+}));
+
+describe("GetVariable component", () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("renders string variable values", async () => {
+ (resolveClientVariable as jest.Mock).mockResolvedValue("dep-id-value");
+
+ render();
+
+ await waitFor(() => expect(resolveClientVariable).toHaveBeenCalledWith("deployment_identifier"));
+ expect(await screen.findByText("dep-id-value")).toBeInTheDocument();
+ });
+
+ it("renders numeric variable values", async () => {
+ (resolveClientVariable as jest.Mock).mockResolvedValue(42);
+
+ render();
+
+ expect(await screen.findByText("42")).toBeInTheDocument();
+ });
+
+ it("renders fallback for null values", async () => {
+ (resolveClientVariable as jest.Mock).mockResolvedValue(null);
+
+ render();
+
+ expect(await screen.findByText("n/a")).toBeInTheDocument();
+ });
+
+ it("renders fallback for non-primitive values", async () => {
+ (resolveClientVariable as jest.Mock).mockResolvedValue({ nested: "value" });
+
+ render();
+
+ expect(await screen.findByText("unsupported")).toBeInTheDocument();
+ });
+
+ it("renders empty-name message when name is missing", () => {
+ render();
+
+ expect(resolveClientVariable).not.toHaveBeenCalled();
+ expect(screen.getByText("Variable name empty")).toBeInTheDocument();
+ });
+
+ it("renders error text when variable retrieval throws", async () => {
+ (resolveClientVariable as jest.Mock).mockRejectedValue(new Error("boom"));
+
+ render();
+
+ expect(await screen.findByText("Error retrieving variable")).toBeInTheDocument();
+ });
+
+ it("refreshes when local-storage-change event is fired for the same key", async () => {
+ (resolveClientVariable as jest.Mock)
+ .mockResolvedValueOnce("initial")
+ .mockResolvedValueOnce("updated");
+
+ render();
+ expect(await screen.findByText("initial")).toBeInTheDocument();
+
+ window.dispatchEvent(
+ new CustomEvent("local-storage-change", { detail: { key: "deployment_identifier" } })
+ );
+
+ expect(await screen.findByText("updated")).toBeInTheDocument();
+ });
+
+ it("refreshes when local-storage-change key casing differs", async () => {
+ (resolveClientVariable as jest.Mock)
+ .mockResolvedValueOnce("initial")
+ .mockResolvedValueOnce("updated-upper");
+
+ render();
+ expect(await screen.findByText("initial")).toBeInTheDocument();
+
+ window.dispatchEvent(
+ new CustomEvent("local-storage-change", { detail: { key: "DEPLOYMENT_IDENTIFIER" } })
+ );
+
+ expect(await screen.findByText("updated-upper")).toBeInTheDocument();
+ });
+});
diff --git a/src/app/components/get-variable.tsx b/src/app/components/get-variable.tsx
new file mode 100644
index 0000000..36dbb34
--- /dev/null
+++ b/src/app/components/get-variable.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { LOCAL_VARIABLES_STORAGE_KEY } from "@/lib/variables";
+import { resolveClientVariable } from "@/app/lib/client-variable-resolver";
+
+const STORAGE_KEY = LOCAL_VARIABLES_STORAGE_KEY;
+
+function normalizeKey(value: string | null | undefined): string {
+ return value?.trim().toLowerCase() ?? "";
+}
+
+function shouldReloadForKey(changedKey: string | null | undefined, targetKey: string): boolean {
+ const normalizedChangedKey = normalizeKey(changedKey);
+ return normalizedChangedKey === targetKey || normalizedChangedKey === normalizeKey(STORAGE_KEY);
+}
+
+type GetVariableProps = {
+ name?: string;
+ fallback?: string;
+ loadingText?: string;
+ errorText?: string;
+ emptyNameText?: string;
+};
+
+export function GetVariable({
+ name,
+ fallback = "",
+ loadingText = "loading...",
+ errorText = "Error retrieving variable",
+ emptyNameText = "Variable name empty",
+}: GetVariableProps) {
+ const [value, setValue] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [hasError, setHasError] = useState(false);
+
+ const loadValue = useCallback(async () => {
+ if (!name?.trim()) {
+ setLoading(false);
+ setHasError(false);
+ setValue(null);
+ return;
+ }
+
+ setLoading(true);
+ setHasError(false);
+
+ try {
+ const nextValue = await resolveClientVariable(name);
+ setValue(nextValue);
+ } catch {
+ console.error("unable to retrieve variable:", name);
+ setHasError(true);
+ setValue(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [name]);
+
+ useEffect(() => {
+ const loadId = window.setTimeout(() => {
+ void loadValue();
+ }, 0);
+
+ return () => {
+ window.clearTimeout(loadId);
+ };
+ }, [loadValue]);
+
+ useEffect(() => {
+ if (!name?.trim()) {
+ return;
+ }
+
+ const normalizedName = normalizeKey(name);
+
+ const onStorage = (event: StorageEvent) => {
+ if (shouldReloadForKey(event.key, normalizedName)) {
+ void loadValue();
+ }
+ };
+
+ const onLocalStorageChange = (event: Event) => {
+ const detail = (event as CustomEvent<{ key?: string }>).detail;
+ if (shouldReloadForKey(detail?.key, normalizedName)) {
+ void loadValue();
+ }
+ };
+
+ window.addEventListener("storage", onStorage);
+ window.addEventListener("local-storage-change", onLocalStorageChange as EventListener);
+
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener("local-storage-change", onLocalStorageChange as EventListener);
+ };
+ }, [loadValue, name]);
+
+ if (!name?.trim()) {
+ return {emptyNameText};
+ }
+
+ if (loading) {
+ return {loadingText};
+ }
+
+ if (hasError) {
+ return {errorText};
+ }
+
+ if (value === null || value === undefined) {
+ return {fallback};
+ }
+
+ if (
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return {String(value)};
+ }
+
+ return {fallback};
+}
diff --git a/src/app/components/header.tsx b/src/app/components/header.tsx
new file mode 100644
index 0000000..b09c59a
--- /dev/null
+++ b/src/app/components/header.tsx
@@ -0,0 +1,29 @@
+import Image from "next/image";
+import Link from "next/link";
+
+export function Header() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/components/image-modal-client.test.tsx b/src/app/components/image-modal-client.test.tsx
new file mode 100644
index 0000000..9ece0bf
--- /dev/null
+++ b/src/app/components/image-modal-client.test.tsx
@@ -0,0 +1,21 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import ImageModalClient from "./image-modal-client";
+
+describe("ImageModalClient", () => {
+ it("opens the modal when the image is clicked", () => {
+ render();
+
+ fireEvent.click(screen.getByAltText("Example (thumbnail)"));
+
+ expect(screen.getByAltText("Example (full size)")).toBeInTheDocument();
+ });
+
+ it("closes the modal on escape", () => {
+ render();
+
+ fireEvent.click(screen.getByAltText("Example (thumbnail)"));
+ fireEvent.keyDown(document, { key: "Escape" });
+
+ expect(screen.queryByAltText("Example (full size)")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/app/components/image-modal-client.tsx b/src/app/components/image-modal-client.tsx
new file mode 100644
index 0000000..2f2da23
--- /dev/null
+++ b/src/app/components/image-modal-client.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable @next/next/no-img-element */
+"use client";
+
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+
+type ImageModalClientProps = {
+ src?: string;
+ alt?: string;
+};
+
+export default function ImageModalClient({ src = "", alt = "Image" }: ImageModalClientProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const closeModal = () => {
+ setIsOpen(false);
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ closeModal();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, []);
+
+ return (
+ <>
+
setIsOpen(true)}
+ />
+ {isOpen && createPortal(
+
+
event.stopPropagation()}>
+
+

+
+
,
+ document.body
+ )}
+ >
+ );
+}
diff --git a/src/app/components/input-var.test.tsx b/src/app/components/input-var.test.tsx
new file mode 100644
index 0000000..23484f7
--- /dev/null
+++ b/src/app/components/input-var.test.tsx
@@ -0,0 +1,56 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { InputVariable } from "./input-var";
+import { setVariable } from "@/lib/variables";
+
+jest.mock("@/lib/variables", () => ({
+ setVariable: jest.fn(),
+}));
+
+describe("InputVariable component", () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("renders with provided name and value", () => {
+ render();
+
+ expect(screen.getByText("Set Variable")).toBeInTheDocument();
+ expect(screen.getByText("deployment_identifier:")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("alpha")).toBeInTheDocument();
+ });
+
+ it("calls setVariable and shows success message", async () => {
+ (setVariable as jest.Mock).mockResolvedValue(undefined);
+ render();
+
+ fireEvent.change(screen.getByPlaceholderText("Enter a value..."), {
+ target: { value: "new-value" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(setVariable).toHaveBeenCalledWith("deployment_identifier", "new-value");
+ });
+ expect(screen.getByText("Variable set successfully!")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Enter a value...")).toHaveValue("");
+ });
+
+ it("shows error message when setVariable fails", async () => {
+ (setVariable as jest.Mock).mockRejectedValue(new Error("save failed"));
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Error: save failed")).toBeInTheDocument();
+ });
+ expect(console.error).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/components/input-var.tsx b/src/app/components/input-var.tsx
new file mode 100644
index 0000000..ff4eda9
--- /dev/null
+++ b/src/app/components/input-var.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { Button } from "@/app/components/button";
+import { useState } from "react";
+import { setVariable } from "@/lib/variables";
+
+type InputVariableProps = {
+ name?: string;
+ value?: string;
+};
+
+export function InputVariable({ name = "", value = "" }: InputVariableProps) {
+ const [state, setState] = useState<{ status: boolean | null; error: string | null }>({
+ status: null,
+ error: null,
+ });
+ const [isDisabled, setIsDisabled] = useState(false);
+ const [inputValue, setInputValue] = useState(value);
+
+ const handleButtonClick = async () => {
+ setIsDisabled(true);
+
+ try {
+ await setVariable(name, inputValue);
+ setState({ status: true, error: null });
+ } catch (error) {
+ const err = error as Error;
+ console.error("Error setting variable:", error);
+ setState({ status: false, error: err.message });
+ } finally {
+ setIsDisabled(false);
+ setInputValue("");
+ }
+ };
+
+ return (
+
+
Set Variable
+
+
+ {name}:
+ setInputValue(e.target.value)}
+ placeholder="Enter a value..."
+ className="w-full min-w-0 p-2 bg-white rounded shadow-md"
+ disabled={isDisabled}
+ />
+
+
+
+
+ {state.status !== null && (
+
+ {state.status ? "Variable set successfully!" : `Error: ${state.error}`}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/components/set-variable.test.tsx b/src/app/components/set-variable.test.tsx
new file mode 100644
index 0000000..b6dc814
--- /dev/null
+++ b/src/app/components/set-variable.test.tsx
@@ -0,0 +1,74 @@
+import { SetVariable } from "./set-variable";
+import { getComponentName, setVariable } from "@/lib/variables";
+
+jest.mock("@/lib/variables", () => ({
+ getComponentName: jest.fn(),
+ setVariable: jest.fn(),
+}));
+
+describe("SetVariable component", () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it("sets a provided variable value", async () => {
+ (setVariable as jest.Mock).mockResolvedValue(undefined);
+
+ await expect(SetVariable({ name: "deployment_identifier", value: "demo" })).resolves.toBeNull();
+
+ expect(setVariable).toHaveBeenCalledWith("deployment_identifier", "demo");
+ });
+
+ it("derives value from getComponentName when isComponent is true", async () => {
+ (getComponentName as jest.Mock).mockResolvedValue("dep-nginx");
+ (setVariable as jest.Mock).mockResolvedValue(undefined);
+
+ await expect(SetVariable({ name: "nginx", isComponent: true })).resolves.toBeNull();
+
+ expect(getComponentName).toHaveBeenCalledWith("nginx");
+ expect(setVariable).toHaveBeenCalledWith("nginx", "dep-nginx");
+ });
+
+ it("supports string isComponent prop from MDX", async () => {
+ (getComponentName as jest.Mock).mockResolvedValue("dep-nginx");
+ (setVariable as jest.Mock).mockResolvedValue(undefined);
+
+ await expect(SetVariable({ name: "nginx", isComponent: "true" })).resolves.toBeNull();
+
+ expect(getComponentName).toHaveBeenCalledWith("nginx");
+ expect(setVariable).toHaveBeenCalledWith("nginx", "dep-nginx");
+ });
+
+ it("returns null when name is missing", async () => {
+ await expect(SetVariable({ value: "demo" })).resolves.toBeNull();
+ expect(setVariable).not.toHaveBeenCalled();
+ });
+
+ it("returns null when value missing and isComponent is false", async () => {
+ await expect(SetVariable({ name: "deployment_identifier" })).resolves.toBeNull();
+ expect(setVariable).not.toHaveBeenCalled();
+ });
+
+ it("handles setVariable errors without throwing", async () => {
+ (setVariable as jest.Mock).mockRejectedValue(new Error("set failed"));
+
+ await expect(SetVariable({ name: "deployment_identifier", value: "demo" })).resolves.toBeNull();
+ expect(console.error).toHaveBeenCalledWith("unable to set variable:", "deployment_identifier");
+ });
+
+ it("handles getComponentName errors without throwing", async () => {
+ (getComponentName as jest.Mock).mockRejectedValue(new Error("name failed"));
+
+ await expect(SetVariable({ name: "nginx", isComponent: true })).resolves.toBeNull();
+
+ expect(setVariable).not.toHaveBeenCalled();
+ expect(console.error).toHaveBeenCalledWith("unable to set variable:", "nginx");
+ });
+});
diff --git a/src/app/components/set-variable.tsx b/src/app/components/set-variable.tsx
new file mode 100644
index 0000000..9ebf09b
--- /dev/null
+++ b/src/app/components/set-variable.tsx
@@ -0,0 +1,49 @@
+import { getComponentName, setVariable } from "@/lib/variables";
+
+type SetVariableProps = {
+ name?: string | null;
+ value?: string | null;
+ isComponent?: boolean | string;
+};
+
+function isTrue(value: boolean | string): boolean {
+ if (typeof value === "boolean") {
+ return value;
+ }
+
+ return value.toLowerCase() === "true";
+}
+
+/**
+ * Asynchronously sets a variable value for MDX flows.
+ */
+export async function SetVariable({
+ name = null,
+ value = null,
+ isComponent = false,
+}: SetVariableProps) {
+ if (!name) {
+ return null;
+ }
+
+ const resolveFromComponentName = isTrue(isComponent);
+
+ if (value === null && resolveFromComponentName === false) {
+ return null;
+ }
+
+ try {
+ let resolvedValue = value;
+
+ if (resolveFromComponentName) {
+ resolvedValue = await getComponentName(name);
+ }
+
+ await setVariable(name, resolvedValue ?? "");
+ } catch {
+ console.error("unable to set variable:", name);
+ return null;
+ }
+
+ return null;
+}
diff --git a/src/app/components/toc.tsx b/src/app/components/toc.tsx
index bd759a1..a73abb2 100644
--- a/src/app/components/toc.tsx
+++ b/src/app/components/toc.tsx
@@ -1,66 +1,93 @@
"use client";
import { useEffect, useState } from "react";
-export function ToC() {
- type Heading = {
- title: string;
- id: string;
- level: number;
+type Heading = {
+ title: string;
+ id: string;
+ level: number;
+};
+
+function toHeadingId(value: string): string {
+ return value.trim().toLowerCase().replace(/\s/g, "-");
+}
+
+function getHeadingSummary(heading: Element, id: string): Heading {
+ return {
+ title: (heading as HTMLElement).innerText,
+ id,
+ level: Number.parseInt(heading.tagName.substring(1), 10),
};
+}
+
+function collectHeadings(headingElements: NodeListOf): Heading[] {
+ const idCounts: Record = {};
+
+ return Array.from(headingElements).map((heading) => {
+ const baseId = toHeadingId(heading.childNodes[0]?.textContent ?? "");
+ const nextCount = (idCounts[baseId] ?? 0) + 1;
+ idCounts[baseId] = nextCount;
+
+ const id = nextCount > 1 ? `${baseId}-${nextCount}` : baseId;
+ if (id) {
+ heading.setAttribute("id", id);
+ }
+
+ return getHeadingSummary(heading, id);
+ });
+}
+function syncActiveHeading(observerEntry: IntersectionObserverEntry, setActiveId: (id: string) => void): void {
+ if (observerEntry.isIntersecting) {
+ setActiveId(observerEntry.target.id);
+ }
+}
+
+export function ToC() {
const [headings, setHeadings] = useState([]);
const [activeId, setActiveId] = useState("");
+ const handleHeadingClick = (event: React.MouseEvent, id: string) => {
+ event.preventDefault();
+
+ const element = document.getElementById(id);
+ if (!element) {
+ return;
+ }
+
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ inline: "nearest",
+ });
+ window.history.replaceState(null, "", `#${id}`);
+ };
+
useEffect(() => {
- // Get all the headings on the page
const headingElements = document.querySelectorAll("h1, h2, h3, h4");
- const idCounts: { [key: string]: number } = {}; // To keep track of the number of times an ID has been used
- const headings = Array.from(headingElements).map((heading) => {
- let id = (heading.childNodes[0]?.textContent as string)
- ?.toLowerCase()
- .replace(/\s/g, "-");
-
- // If the ID already exists, append a counter to make it unique
- if (idCounts[id]) {
- idCounts[id] += 1;
- id = `${id}-${idCounts[id]}`;
- } else {
- idCounts[id] = 1;
- }
-
- if (id) {
- heading.setAttribute("id", id); // Set the id attribute of the heading element
- }
- return {
- title: (heading as HTMLElement).innerText,
- id: id,
- level: parseInt(heading.tagName.substring(1)), // Get the heading level
- };
- });
- // Defer state update to avoid synchronous setState in effect.
- queueMicrotask(() => setHeadings(headings));
+ const nextHeadings = collectHeadings(headingElements);
+
+ setHeadings(nextHeadings);
const observer = new IntersectionObserver(
(entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- setActiveId(entry.target.id);
- }
- });
+ entries.forEach((entry) => syncActiveHeading(entry, setActiveId));
},
- { rootMargin: "0px 0px -80% 0px" } // Adjust as needed
+ { rootMargin: "0px 0px -80% 0px" }
);
headingElements.forEach((heading) => observer.observe(heading));
return () => {
- headingElements.forEach((heading) => observer.unobserve(heading));
+ observer.disconnect();
};
}, []);
return (
-
-
+
+
+ On this page
+
+
{headings.map((heading, index) => (
-
handleHeadingClick(event, heading.id)}
>
{heading.title}
diff --git a/src/app/components/udf-component-button.test.tsx b/src/app/components/udf-component-button.test.tsx
new file mode 100644
index 0000000..22138ae
--- /dev/null
+++ b/src/app/components/udf-component-button.test.tsx
@@ -0,0 +1,26 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import UDFComponentButton from "./udf-component-button";
+
+describe("UDFComponentButton", () => {
+ it("opens web shell URL in a new tab", () => {
+ const windowOpenSpy = jest.spyOn(window, "open").mockImplementation(() => null);
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Web Shell" }));
+
+ expect(windowOpenSpy).toHaveBeenCalledWith(
+ "https://example-shell.local",
+ "_blank",
+ "noopener,noreferrer"
+ );
+
+ windowOpenSpy.mockRestore();
+ });
+
+ it("is disabled when web shell URL is unavailable", () => {
+ render();
+
+ expect(screen.getByRole("button", { name: "Open Web Shell" })).toBeDisabled();
+ });
+});
diff --git a/src/app/components/udf-component-button.tsx b/src/app/components/udf-component-button.tsx
new file mode 100644
index 0000000..dfa134e
--- /dev/null
+++ b/src/app/components/udf-component-button.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+type UDFComponentButtonProps = {
+ webShellUrl: string | null;
+};
+
+export default function UDFComponentButton({ webShellUrl }: UDFComponentButtonProps) {
+ const handleButtonClick = () => {
+ if (webShellUrl) {
+ window.open(webShellUrl, "_blank", "noopener,noreferrer");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/components/udf-component.test.tsx b/src/app/components/udf-component.test.tsx
new file mode 100644
index 0000000..eb6f312
--- /dev/null
+++ b/src/app/components/udf-component.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from "@testing-library/react";
+import UDFComponent from "./udf-component";
+import { fetchUdfComponentWebShell } from "@/lib/udf";
+
+jest.mock("@/lib/udf", () => ({
+ fetchUdfComponentWebShell: jest.fn(),
+}));
+
+jest.mock("@/app/components/udf-component-button", () => ({
+ __esModule: true,
+ default: ({ webShellUrl }: { webShellUrl: string | null }) => (
+
{webShellUrl ?? "null"}
+ ),
+}));
+
+describe("UDFComponent", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders component name and passes fetched URL to button", async () => {
+ (fetchUdfComponentWebShell as jest.Mock).mockResolvedValue("https://runner-shell.local");
+
+ const ui = await UDFComponent({ name: "Runner" });
+ render(ui);
+
+ expect(fetchUdfComponentWebShell).toHaveBeenCalledWith("Runner");
+ expect(screen.getByText("Runner")).toBeInTheDocument();
+ expect(screen.getByTestId("mock-udf-button")).toHaveTextContent("https://runner-shell.local");
+ });
+
+ it("passes null URL when shell endpoint is unavailable", async () => {
+ (fetchUdfComponentWebShell as jest.Mock).mockResolvedValue(null);
+
+ const ui = await UDFComponent({ name: "Runner" });
+ render(ui);
+
+ expect(fetchUdfComponentWebShell).toHaveBeenCalledWith("Runner");
+ expect(screen.getByTestId("mock-udf-button")).toHaveTextContent("null");
+ });
+});
diff --git a/src/app/components/udf-component.tsx b/src/app/components/udf-component.tsx
new file mode 100644
index 0000000..1c7bd6d
--- /dev/null
+++ b/src/app/components/udf-component.tsx
@@ -0,0 +1,34 @@
+import { fetchUdfComponentWebShell } from "@/lib/udf";
+import UDFComponentButton from "@/app/components/udf-component-button";
+
+type UDFComponentProps = {
+ name: string;
+};
+
+async function getWebShellUrl(componentName: string): Promise {
+ try {
+ if (!componentName) {
+ console.error("componentName must be defined");
+ return null;
+ }
+
+ const webShellUrl = await fetchUdfComponentWebShell(componentName);
+ return webShellUrl || null;
+ } catch (error) {
+ console.error("Error fetching UDF component web shell URL:", error);
+ return null;
+ }
+}
+
+export default async function UDFComponent({ name }: UDFComponentProps) {
+ const webShellUrl = await getWebShellUrl(name);
+
+ return (
+
+ );
+}
diff --git a/src/app/components/udf-deployment-metadata.test.tsx b/src/app/components/udf-deployment-metadata.test.tsx
new file mode 100644
index 0000000..872c7cd
--- /dev/null
+++ b/src/app/components/udf-deployment-metadata.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from "@testing-library/react";
+import { UdfDeploymentMetadata } from "./udf-deployment-metadata";
+import { fetchUDFInfo } from "@/lib/udf";
+
+jest.mock("@/lib/udf", () => ({
+ fetchUDFInfo: jest.fn(),
+}));
+
+describe("UdfDeploymentMetadata", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders loading when deployment metadata is unavailable", async () => {
+ (fetchUDFInfo as jest.Mock).mockResolvedValue(null);
+
+ const ui = await UdfDeploymentMetadata();
+ render(<>{ui}>);
+
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("renders deployment metadata JSON", async () => {
+ (fetchUDFInfo as jest.Mock).mockResolvedValue({ deployment: { id: "dep-1" } });
+
+ const ui = await UdfDeploymentMetadata();
+ render(<>{ui}>);
+
+ expect(screen.getByText("UDF Deployment Info")).toBeInTheDocument();
+ expect(screen.getByText(/"deployment":/)).toBeInTheDocument();
+ expect(screen.getByText(/"dep-1"/)).toBeInTheDocument();
+ });
+});
diff --git a/src/app/components/udf-deployment-metadata.tsx b/src/app/components/udf-deployment-metadata.tsx
new file mode 100644
index 0000000..b1bf055
--- /dev/null
+++ b/src/app/components/udf-deployment-metadata.tsx
@@ -0,0 +1,19 @@
+import { fetchUDFInfo } from "@/lib/udf";
+
+const UDF_DEPLOYMENT_PATH = "deployment";
+
+export async function UdfDeploymentMetadata() {
+ const data = await fetchUDFInfo(UDF_DEPLOYMENT_PATH);
+ if (!data) {
+ return "Loading...";
+ }
+
+ return (
+
+
+
UDF Deployment Info
+
{JSON.stringify(data, null, 2)}
+
+
+ );
+}
diff --git a/src/app/contexts/instances.test.tsx b/src/app/contexts/instances.test.tsx
index d0f48bd..d2b8953 100644
--- a/src/app/contexts/instances.test.tsx
+++ b/src/app/contexts/instances.test.tsx
@@ -1,21 +1,27 @@
import { render, screen, act, waitFor } from "@testing-library/react";
import { useState } from "react";
import { InstancesContextProvider, useInstancesContext } from "./instances";
-import { getComponentName } from "@/lib/variables";
+import { getComponentName, getVariable } from "@/lib/variables";
import {
InstanceDocker,
InstanceK8s,
+ InstanceState,
InstanceType,
InstanceUdf,
} from "@/lib/types";
import {
createContainer,
+ getContainerPorts,
removeContainer,
} from "@/app/lib/docker-lib";
+import { syncDockerInstances } from "@/app/lib/docker-instance-sync";
jest.mock("@/lib/variables", () => ({
getComponentName: jest.fn(),
+ getVariable: jest.fn(),
+ setClientVariable: jest.fn(),
+ setVariable: jest.fn(),
useInstances: jest.fn(),
}));
@@ -31,16 +37,23 @@ jest.mock("@/lib/variables", () => ({
return "unknown";
}
});
-
jest.mock("@/lib/client-variables");
import { useInstances as mockUseInstances } from "@/lib/client-variables";
jest.mock("@/app/lib/docker-lib", () => ({
createContainer: jest.fn(),
+ getContainerPorts: jest.fn().mockResolvedValue([]),
+ getContainerStatus: jest.fn().mockResolvedValue("unknown"),
+ getDockerInstanceSnapshot: jest.fn().mockResolvedValue(null),
+ isContainerPresent: jest.fn().mockResolvedValue(true),
removeContainer: jest.fn(),
}));
+jest.mock("@/app/lib/docker-instance-sync", () => ({
+ syncDockerInstances: jest.fn().mockResolvedValue([]),
+}));
+
const mockUseInstancesState = (
initial: (InstanceDocker | InstanceK8s | InstanceUdf)[]
) => {
@@ -74,7 +87,16 @@ describe("InstanceType Enum", () => {
});
describe("InstancesContextProvider", () => {
- afterEach(() => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ jest.runOnlyPendingTimers();
+ await Promise.resolve();
+ });
+ jest.useRealTimers();
jest.clearAllMocks();
});
@@ -194,11 +216,9 @@ describe("InstancesContextProvider", () => {
}
);
- it("should log an error if an instance with a specific name already exists", async () => {
- const consoleErrorMock = jest.spyOn(console, "error").mockImplementation();
-
+ it("should no-op if an instance with a specific name already exists", async () => {
mockUseInstancesState([
- { name: "test-docker", type: 0 } as InstanceDocker,
+ { name: "test-docker", status: InstanceState.Running, type: 0 } as InstanceDocker,
{ name: "test-udf", type: 1 } as InstanceUdf,
{ name: "test-k8s", type: 2 } as InstanceK8s,
]);
@@ -212,24 +232,232 @@ describe("InstancesContextProvider", () => {
const addButton = screen.getByText("Add Docker Instance");
const removeButton = screen.getByText("Remove Docker Instance");
- // create an initial container to produce the scenario where the instance already exists
- await act(async () => {
- addButton.click();
- });
-
await act(async () => {
addButton.click();
});
const instances = await waitFor(() => screen.getByTestId("instances"));
expect(instances.textContent).toContain("test-docker");
- expect(consoleErrorMock).toHaveBeenCalledWith("Instance already exists");
+ expect(createContainer).not.toHaveBeenCalled();
await act(async () => {
removeButton.click();
});
+ });
- consoleErrorMock.mockRestore();
+ it("should recreate a stale docker instance if it only exists in local state", async () => {
+ mockUseInstancesState([
+ { name: "test-docker", status: InstanceState.Unknown, type: 0 } as InstanceDocker,
+ ]);
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ screen.getByText("Add Docker Instance").click();
+ });
+
+ expect(createContainer).toHaveBeenCalled();
+ });
+
+ it("should recreate an exited docker instance instead of treating it as duplicate", async () => {
+ mockUseInstancesState([
+ { name: "test-docker", status: InstanceState.Exited, type: 0 } as InstanceDocker,
+ ]);
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ screen.getByText("Add Docker Instance").click();
+ });
+
+ expect(createContainer).toHaveBeenCalled();
+ });
+
+ it("should poll docker api and update docker status in local model", async () => {
+ mockUseInstancesState([
+ { name: "test-docker", status: InstanceState.Running, type: 0 } as InstanceDocker,
+ ]);
+
+ (syncDockerInstances as jest.Mock).mockResolvedValueOnce([
+ {
+ componentId: "mapped-test-docker",
+ isPresent: true,
+ name: "test-docker",
+ status: InstanceState.Exited,
+ type: InstanceType.Docker,
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("instances").textContent).toContain("\"status\":\"exited\"");
+ });
+ });
+
+ it("should preserve the last known running status when polling returns unknown", async () => {
+ mockUseInstancesState([
+ { name: "test-docker", status: InstanceState.Running, type: 0 } as InstanceDocker,
+ ]);
+
+ (syncDockerInstances as jest.Mock).mockResolvedValueOnce([
+ {
+ componentId: "mapped-test-docker",
+ isPresent: true,
+ name: "test-docker",
+ status: InstanceState.Unknown,
+ type: InstanceType.Docker,
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("instances").textContent).toContain("\"status\":\"running\"");
+ });
+ });
+
+ it("should not rewrite instances when docker polling returns the same snapshot", async () => {
+ mockUseInstancesState([
+ {
+ componentId: "mapped-test-docker",
+ image: "test-image",
+ name: "test-docker",
+ ports: [],
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ type: 0,
+ } as InstanceDocker,
+ ]);
+
+ (syncDockerInstances as jest.Mock).mockResolvedValueOnce([
+ {
+ componentId: "mapped-test-docker",
+ image: "test-image",
+ isPresent: true,
+ name: "test-docker",
+ ports: [],
+ status: InstanceState.Running,
+ type: InstanceType.Docker,
+ },
+ ]);
+
+ const renderCounter = { current: 0 };
+
+ const RenderCounter = () => {
+ renderCounter.current += 1;
+ return {renderCounter.current}
;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("render-count").textContent).toBe("1");
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("render-count").textContent).toBe("1");
+ });
+
+ it("should resolve docker component names before polling and backfill componentId", async () => {
+ mockUseInstancesState([
+ { name: "Test Docker", status: InstanceState.Unknown, type: 0 } as InstanceDocker,
+ ]);
+
+ (syncDockerInstances as jest.Mock).mockResolvedValueOnce([
+ {
+ componentId: "mapped-test-docker",
+ image: "test-image",
+ isPresent: true,
+ name: "Test Docker",
+ ports: [],
+ status: InstanceState.Running,
+ type: InstanceType.Docker,
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ const instances = screen.getByTestId("instances").textContent ?? "";
+ expect(instances).toContain("\"componentId\":\"mapped-test-docker\"");
+ expect(instances).toContain("\"status\":\"running\"");
+ });
+
+ expect(syncDockerInstances).toHaveBeenCalledWith(["Test Docker"]);
+ });
+
+ it("should remove docker instances missing from docker api", async () => {
+ mockUseInstancesState([
+ { name: "test-docker", status: InstanceState.Running, type: 0 } as InstanceDocker,
+ ]);
+
+ (syncDockerInstances as jest.Mock).mockResolvedValueOnce([
+ {
+ isPresent: false,
+ name: "test-docker",
+ status: InstanceState.Unknown,
+ type: InstanceType.Docker,
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("instances").textContent).not.toContain("test-docker");
+ });
+
+ expect(syncDockerInstances).toHaveBeenCalledWith(["test-docker"]);
});
it("should log an error if an error is thrown when adding an instance", async () => {
@@ -253,7 +481,7 @@ describe("InstancesContextProvider", () => {
});
const instances = screen.getByTestId("instances");
- expect(instances.textContent).toContain("test-docker");
+ expect(instances.textContent).not.toContain("test-docker");
expect(consoleErrorMock).toHaveBeenCalledWith(
"Create Container Error: ",
expect.any(Error)
@@ -261,6 +489,93 @@ describe("InstancesContextProvider", () => {
consoleErrorMock.mockRestore();
});
+ it("should pass variable-backed docker env to createContainer for server-side resolution", async () => {
+ mockUseInstancesState([]);
+ (getComponentName as jest.Mock).mockResolvedValue("test-docker");
+ (getVariable as jest.Mock).mockResolvedValue("token-from-lab-variables");
+
+ const TestDockerEnvComponent = () => {
+ const { addInstance } = useInstancesContext();
+ return (
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ screen.getByText("Add Docker With Env").click();
+ });
+
+ expect(createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ env: [{ name: "TOKEN", isVariable: true, value: "token-from-lab-variables" }],
+ })
+ );
+ expect(getVariable).toHaveBeenCalledWith("TOKEN");
+ });
+
+ it("should persist resolved host and container ports to docker instance state after creation", async () => {
+ mockUseInstancesState([]);
+ (getComponentName as jest.Mock).mockResolvedValue("test-docker");
+ (getContainerPorts as jest.Mock).mockResolvedValue([
+ { containerPort: 80, hostPort: 49153 },
+ ]);
+
+ const TestDockerPortComponent = () => {
+ const { addInstance, instances } = useInstancesContext();
+ return (
+ <>
+
+ {JSON.stringify(instances)}
+ >
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ await act(async () => {
+ screen.getByText("Add Docker With Ports").click();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("instances-with-ports").textContent).toContain("\"hostPort\":49153");
+ expect(screen.getByTestId("instances-with-ports").textContent).toContain("\"containerPort\":80");
+ });
+
+ expect(getContainerPorts).toHaveBeenCalledWith("test-docker", "test-docker");
+ });
+
test.each(["Docker", "K8s", "Udf"])(
"should remove a %s instance",
async (instanceType) => {
diff --git a/src/app/contexts/instances.tsx b/src/app/contexts/instances.tsx
index 90ab075..ef0b18d 100644
--- a/src/app/contexts/instances.tsx
+++ b/src/app/contexts/instances.tsx
@@ -1,24 +1,144 @@
"use client";
-import { createContext, useContext, ReactNode } from "react";
+import { createContext, useContext, ReactNode, useCallback, useEffect, useRef } from "react";
import {
createContainer,
+ getContainerPorts,
removeContainer,
} from "@/app/lib/docker-lib";
import {
DockerPortMapping,
+ InstanceDockerEnv,
Instance,
InstanceDocker,
InstanceK8s,
+ InstanceState,
InstanceType,
InstanceUdf,
Protocol,
} from "@/lib/types";
-import { getComponentName } from "@/lib/variables";
+import { getComponentName, getVariable, setClientVariable, setVariable } from "@/lib/variables";
import { useInstances } from "@/lib/client-variables";
+import { syncDockerInstances } from "@/app/lib/docker-instance-sync";
+import type { DockerSyncItem } from "@/app/lib/docker-instance-sync";
const isContainerDoesNotExistError = (error: unknown) =>
error instanceof Error && error.message.includes("no such container");
+const isRecoverableDockerState = (state: InstanceState) =>
+ state === InstanceState.Unknown ||
+ state === InstanceState.Exited ||
+ state === InstanceState.Dead;
+
+const resolveDockerEnvValues = async (
+ env: InstanceDockerEnv[]
+): Promise => {
+ return await Promise.all(
+ env.map(async (entry) => {
+ if (!entry.isVariable || (entry.value ?? "") !== "") {
+ return entry;
+ }
+
+ const resolvedValue = await getVariable(entry.name);
+ if (resolvedValue === null || resolvedValue === undefined) {
+ return entry;
+ }
+
+ return {
+ ...entry,
+ value: String(resolvedValue),
+ };
+ })
+ );
+};
+
+function reconcileDockerInstances(
+ prevInstances: Instance[],
+ statusByName: Map,
+ snapshotByName: Map,
+ missingDockerInstanceNames: Set
+): { nextInstances: Instance[]; hasChanges: boolean } {
+ let hasChanges = false;
+
+ const isSameDockerSnapshot = (
+ instance: InstanceDocker,
+ nextStatus: InstanceState,
+ nextSnapshot: InstanceDocker | undefined,
+ nextComponentId: string | undefined
+ ) => {
+ const currentStatus = instance.status ?? InstanceState.Unknown;
+
+ if (nextSnapshot === undefined) {
+ return currentStatus === nextStatus && instance.componentId === nextComponentId;
+ }
+
+ return (
+ currentStatus === nextStatus &&
+ instance.componentId === nextComponentId &&
+ instance.image === nextSnapshot.image &&
+ instance.name === nextSnapshot.name &&
+ JSON.stringify(instance.ports ?? []) === JSON.stringify(nextSnapshot.ports ?? []) &&
+ JSON.stringify(instance.env ?? []) === JSON.stringify(nextSnapshot.env ?? []) &&
+ JSON.stringify(instance.attrs ?? []) === JSON.stringify(nextSnapshot.attrs ?? []) &&
+ instance.stoppedByUser === nextSnapshot.stoppedByUser
+ );
+ };
+
+ const nextInstances = prevInstances
+ .filter((instance) => {
+ if (!isInstanceDocker(instance) || !missingDockerInstanceNames.has(instance.name)) {
+ return true;
+ }
+
+ hasChanges = true;
+ return false;
+ })
+ .map((instance) => {
+ if (!isInstanceDocker(instance)) {
+ return instance;
+ }
+
+ const currentStatus = instance.status ?? InstanceState.Unknown;
+ const nextStatus = statusByName.get(instance.name) ?? InstanceState.Unknown;
+ const effectiveStatus = nextStatus === InstanceState.Unknown ? currentStatus : nextStatus;
+ const nextSnapshot: InstanceDocker | undefined = snapshotByName.get(instance.name);
+ const nextComponentId = nextSnapshot?.componentId;
+
+ if (isSameDockerSnapshot(instance, effectiveStatus, nextSnapshot, nextComponentId)) {
+ return instance;
+ }
+
+ if (nextSnapshot !== undefined) {
+ hasChanges = true;
+ return {
+ ...instance,
+ ...nextSnapshot,
+ ...(nextComponentId ? { componentId: nextComponentId } : {}),
+ status: effectiveStatus,
+ ...(effectiveStatus === InstanceState.Running ? { stoppedByUser: false } : {}),
+ };
+ }
+
+ if (instance.componentId === undefined) {
+ hasChanges = true;
+ return {
+ ...instance,
+ componentId: nextComponentId,
+ status: effectiveStatus,
+ ...(effectiveStatus === InstanceState.Running ? { stoppedByUser: false } : {}),
+ };
+ }
+
+ hasChanges = true;
+ return {
+ ...instance,
+ status: effectiveStatus,
+ ...(effectiveStatus === InstanceState.Running ? { stoppedByUser: false } : {}),
+ };
+ });
+
+ return { nextInstances, hasChanges };
+}
+
/**
* Type guard to check if an instance is of type Docker.
* @param {Instance} instance - The instance to check.
@@ -67,8 +187,10 @@ type InstancesContextType = {
| InstanceK8s[]
| InstanceUdf[]
| null;
- addInstance: (instance: Instance | InstanceDocker | InstanceK8s) => void;
- removeInstance: (instance: Instance | InstanceDocker | InstanceK8s) => void;
+ addInstance: (instance: Instance | InstanceDocker | InstanceK8s) => Promise;
+ removeInstance: (instance: Instance | InstanceDocker | InstanceK8s) => Promise;
+ setDockerStoppedByUser: (instanceName: string, stoppedByUser: boolean) => void;
+ upsertDockerInstance: (instance: InstanceDocker) => void;
};
/**
@@ -87,31 +209,185 @@ const InstancesContextProvider = ({
children,
}: InstancesContextProviderProps) => {
const [instances, setInstances] = useInstances();
+ const instancesRef = useRef(instances);
+ const isSyncingRef = useRef(false);
+
+ useEffect(() => {
+ instancesRef.current = instances;
+ }, [instances]);
+
+ const removeInstanceFromState = useCallback((instanceName: string) => {
+ setInstances((prevInstances) =>
+ prevInstances.filter((i) => i.name !== instanceName)
+ );
+ }, [setInstances]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ const syncDockerInstanceState = async () => {
+ const currentInstances = instancesRef.current;
+
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") {
+ return;
+ }
+
+ if (isCancelled || isSyncingRef.current || currentInstances.length === 0) {
+ return;
+ }
+
+ const dockerInstances = currentInstances.filter(isInstanceDocker);
+ if (dockerInstances.length === 0) {
+ return;
+ }
+
+ isSyncingRef.current = true;
+
+ try {
+ const syncEntries = await syncDockerInstances(dockerInstances.map((instance) => instance.name));
+
+ if (isCancelled) {
+ return;
+ }
+
+ const statusByName = new Map(
+ syncEntries.map(({ name, status }) => [name, status] as const)
+ );
+ const snapshotEntries: Array = syncEntries
+ .filter(
+ (entry): entry is DockerSyncItem & {
+ componentId: string;
+ image: string;
+ isPresent: true;
+ ports: InstanceDocker["ports"];
+ status: typeof InstanceState.Running;
+ } =>
+ entry.isPresent &&
+ entry.status === InstanceState.Running &&
+ typeof entry.componentId === "string" &&
+ entry.componentId.length > 0 &&
+ typeof entry.image === "string" &&
+ entry.image.length > 0 &&
+ Array.isArray(entry.ports)
+ )
+ .map((entry) => [
+ entry.name,
+ {
+ attrs: undefined,
+ componentId: entry.componentId,
+ env: undefined,
+ image: entry.image,
+ name: entry.name,
+ ports: entry.ports,
+ status: entry.status,
+ stoppedByUser: false,
+ type: InstanceType.Docker,
+ },
+ ] as const);
+ const snapshotByName = new Map(snapshotEntries);
+ const missingDockerInstanceNames = new Set(
+ syncEntries
+ .filter(({ isPresent }) => !isPresent)
+ .map(({ name }) => name)
+ );
+
+ syncEntries.forEach((entry) => {
+ if (entry.isPresent && typeof entry.componentId === "string" && entry.componentId.length > 0) {
+ setClientVariable(entry.name, entry.componentId);
+ }
+ });
+
+ setInstances((prevInstances) => {
+ const { nextInstances, hasChanges } = reconcileDockerInstances(
+ prevInstances,
+ statusByName,
+ snapshotByName,
+ missingDockerInstanceNames
+ );
+
+ return hasChanges ? nextInstances : prevInstances;
+ });
+ } finally {
+ isSyncingRef.current = false;
+ }
+ };
+
+ void syncDockerInstanceState();
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === "visible") {
+ void syncDockerInstanceState();
+ }
+ };
+
+ if (typeof document !== "undefined") {
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ }
+
+ const intervalId = setInterval(syncDockerInstanceState, 5000);
+
+ return () => {
+ isCancelled = true;
+ clearInterval(intervalId);
+ if (typeof document !== "undefined") {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ }
+ };
+ }, [setInstances]);
/**
* Function to add an instance.
* @param {Instance} instance - The instance to add.
* @returns {Promise} - A promise that resolves when the instance is added.
*/
- const addInstance = async (
+ const addInstance = useCallback(async (
instance: T
- ): Promise => {
- if (instances !== null && instances.find((i) => i.name === instance.name)) {
- console.error("Instance already exists");
- return;
+ ): Promise => {
+ let resolvedDockerPorts: DockerPortMapping[] | undefined;
+
+ const existingInstance = instances !== null
+ ? instances.find((i) => i.name === instance.name)
+ : undefined;
+
+ if (existingInstance) {
+ if (existingInstance.type === InstanceType.Docker) {
+ const status = existingInstance.status ?? InstanceState.Unknown;
+ if (isRecoverableDockerState(status)) {
+ removeInstanceFromState(existingInstance.name);
+ } else {
+ return new Error("Instance already exists");
+ }
+ } else {
+ return new Error("Instance already exists");
+ }
}
instance.componentId = await getComponentName(instance.name);
if (isInstanceDocker(instance)) {
try {
console.log("Creating docker instance");
+ const resolvedEnv = await resolveDockerEnvValues(instance.env ?? []);
await createContainer({
+ attrs: instance.attrs,
+ componentId: instance.componentId,
+ env: resolvedEnv,
image: instance.image,
name: instance.name,
ports: instance.ports,
} as InstanceDocker);
+
+ const runtimePortMappings = await getContainerPorts(
+ instance.name,
+ instance.componentId
+ );
+ if (runtimePortMappings.length > 0) {
+ resolvedDockerPorts = runtimePortMappings;
+ }
+
+ await setVariable(instance.name, instance.componentId || "");
} catch (error) {
console.error("Create Container Error: ", error);
+ return error instanceof Error ? error : new Error("Create container failed");
}
} else if (isInstanceK8s(instance)) {
// TODO: Implement this
@@ -120,22 +396,37 @@ const InstancesContextProvider = ({
// TODO: Implement this
console.log("Creating udf instance");
}
- setInstances((prevInstances) => [...prevInstances, instance]);
+
+ const nextInstance: Instance = isInstanceDocker(instance)
+ ? {
+ ...instance,
+ ...(resolvedDockerPorts ? { ports: resolvedDockerPorts } : {}),
+ status: InstanceState.Running,
+ stoppedByUser: false,
+ }
+ : instance;
+
+ setInstances((prevInstances) => [
+ ...prevInstances,
+ nextInstance,
+ ]);
console.log("Instances: ", instances);
- };
+ return null;
+ }, [instances, setInstances, removeInstanceFromState]);
/**
* Function to remove an instance.
* @param {Instance} instance - The instance to remove.
* @returns {Promise} - A promise that resolves when the instance is removed.
*/
- const removeInstance = async (
+ const removeInstance = useCallback(async (
instance: T
): Promise => {
if (instance.type === InstanceType.Docker) {
console.log("Removing docker instance");
try {
await removeContainer(instance.name);
+ await setVariable(instance.name, "");
removeInstanceFromState(instance.name);
} catch (error: unknown) {
console.error("Remove Container Error: ", error);
@@ -153,21 +444,44 @@ const InstancesContextProvider = ({
}
removeInstanceFromState(instance.name);
- };
+ }, [removeInstanceFromState]);
/**
* Function to remove an instance from the state.
* @param {string} instanceName - The name of the instance to remove.
*/
- const removeInstanceFromState = (instanceName: string) => {
+ const setDockerStoppedByUser = useCallback((instanceName: string, stoppedByUser: boolean) => {
setInstances((prevInstances) =>
- prevInstances.filter((i) => i.name !== instanceName)
+ prevInstances.map((instance) => {
+ if (!isInstanceDocker(instance) || instance.name !== instanceName) {
+ return instance;
+ }
+
+ if (instance.stoppedByUser === stoppedByUser) {
+ return instance;
+ }
+
+ return {
+ ...instance,
+ stoppedByUser,
+ };
+ })
);
- };
+ }, [setInstances]);
+
+ const upsertDockerInstance = useCallback((instance: InstanceDocker) => {
+ setInstances((prevInstances) => {
+ const nextInstances = prevInstances.filter(
+ (currentInstance) => !(isInstanceDocker(currentInstance) && currentInstance.name === instance.name)
+ );
+
+ return [...nextInstances, instance];
+ });
+ }, [setInstances]);
return (
{children}
diff --git a/src/app/docs/author-docs.mdx b/src/app/docs/author-docs.mdx
new file mode 100644
index 0000000..54ff652
--- /dev/null
+++ b/src/app/docs/author-docs.mdx
@@ -0,0 +1,463 @@
+---
+title: Lab Author Docs
+description: Outlines the JSX components that are available and how to use them.
+order: 5
+---
+
+{/* markdownlint-disable */}
+
+## APICheck
+
+The **APICheck** allows the lab author to check an API endpoint with an optional path and HTTP response code.
+The component will also look up the target Docker component's ephemeral networking information if it's the target of the API check.
+
+**Note**: you must supply either a **componentName** or a **url**.
+
+| Variable | Description | Required |
+|----------------------|----------------------------------------------------------------------------------------------------------|----------|
+| **componentName** | The name of the component to check. | Yes |
+| **path** | The path to append to the component for the API check. | No |
+| **targetStatusCode** | The expected HTTP status code from the API check. | No |
+| **url** | The URL to check. | Yes |
+| **tlsComponent** | If a component name is specified, use TLS (https) to connect to the component. Default is no TLS (http). | No |
+
+### Examples
+
+Calling APICheck with a component name:
+
+```jsx
+
+```
+
+
+
+Calling APICheck with a component name and a path:
+
+```jsx
+
+```
+
+
+
+Calling APICheck with a component name via TLS:
+
+```jsx
+
+```
+
+
+
+Calling APICheck with a url:
+
+ ```jsx
+
+```
+
+
+
+
+---
+
+## APIHeaderCheck
+
+The **APIHeaderCheck** allows the lab author to check an API endpoint's headers with an optional path and HTTP response code.
+The component will also look up the target Docker component's ephemeral networking information if it's the target of the API check.
+
+**Note**: you must supply either a **componentName** or a **url**.
+
+| Variable | Description | Required |
+|----------------------|----------------------------------------------------------------------------------------------------------|----------|
+| **componentName** | The name of the component to check. | Yes |
+| **headerName** | The header name | Yes |
+| **headerValue** | The header value | Yes |
+| **path** | The path to append to the component for the API check. | No |
+| **targetStatusCode** | The expected HTTP status code from the API check. | No |
+| **url** | The URL to check. | Yes |
+| **tlsComponent** | If a component name is specified, use TLS (https) to connect to the component. Default is no TLS (http). | No |
+
+### Examples
+
+Calling APICheck with a component name:
+
+```jsx
+
+ ```
+
+
+Calling APIHeaderCheck with a component name via TLS:
+
+```jsx
+
+ ```
+
+
+Calling APICheck with a url:
+
+```jsx
+
+```
+
+
+
+
+---
+
+## APIResponseCheck
+
+The **APIResponseCheck** allows the lab author to check an API endpoint's response with an optional path and HTTP response code.
+The component will also look up the target Docker component's ephemeral networking information if it's the target of the API check.
+
+**Note**: you must supply either a **componentName** or a **url**.
+
+| Variable | Description | Required |
+|----------------------|----------------------------------------------------------------------------------------------------------|----------|
+| **componentName** | The name of the component to check. | Yes |
+| **path** | The path to append to the component for the API check. | No |
+| **searchString** | A string to look for in the response. | Yes |
+| **targetStatusCode** | The expected HTTP status code from the API check. | No |
+| **url** | The URL to check. | Yes |
+| **tlsComponent** | If a component name is specified, use TLS (https) to connect to the component. Default is no TLS (http). | No |
+
+
+### Examples
+
+Calling APICheck with a component name:
+
+```jsx
+
+ ```
+
+
+Calling APICheck with a component name via TLS:
+
+```jsx
+
+ ```
+
+
+Calling APICheck with a url:
+
+```jsx
+
+```
+
+
+
+
+---
+
+## Container for API Checks
+
+The framework uses the **APICheckContainer** wrapper component to help correctly display all individual **ApiCheck**, **APIHeaderCheck**, and **APIResponseCheck** components in the lab guide. This is useful if you need to group together more than one of the preceding components.
+
+### Examples
+
+Grouping more than one check component:
+
+```jsx
+
+
+
+
+
+ ```
+
+
+
+
+
+
+
+
+---
+
+## Docker Component
+
+The lab framework allows you to deploy docker containers within the lab guide.
+
+The framework uses the **DockerContainer** wrapper component to help correctly display all individual **Docker** components in the lab guide.
+
+The **Docker** component is responsible for managing and displaying information about the container.
+
+| Variable | Description | Required |
+|-----------------|-------------------------------------------------------|----------|
+| **name** | A unique name for the Docker container. | Yes |
+| **description** | The description of the Docker container. | No |
+| **image** | The image of the Docker container. | Yes |
+| **env** | The environment variables for the Docker container. | No |
+| **port** | The port mapping for the Docker container. | No |
+| **attrs** | Additional attributes for the Docker container. | No |
+
+### Examples
+
+```jsx
+
+ /ssl:/etc/nginx/ssl" }
+ ]}
+ />
+
+
+```
+
+
+
+
+
+
+
+
+---
+
+## GetVariable
+
+The **GetVariable** component allows a lab author to insert a dynamic variable into the lab guide.
+
+The method will look for the variable in the following locations:
+
+- Environment Variable
+- Redis database
+- LabInfo API (inside UDF)
+- UDF Metadata API (inside UDF)
+
+| Variable | Description | Required |
+|-------------|----------------------|----------|
+| **name** | The variable name. | Yes |
+
+### Example
+
+```mdx
+Test variable from LabInfo API:
+```
+
+Test variable from LabInfo API:
+
+
+---
+
+## InputVariable
+
+The **InputVariable** component allows a lab author to insert a variable into the Redis database for reference later.
+
+| Variable | Description | Required |
+|-------------|----------------------|----------|
+| **name** | The variable name. | Yes |
+| **value** | The variable value. | No |
+
+### Example
+
+No default value:
+
+```jsx
+
+ ```
+
+
+With default value:
+
+```jsx
+
+ ```
+
+
+
+---
+
+## UDFComponent
+
+The **UDFComponent** provides a tile with a button that will open the web shell for the UDF lab component.
+
+| Variable | Description | Required |
+|-------------|----------------------|----------|
+| **name** | The component name. | Yes |
+
+
+### Example
+
+```jsx
+
+```
+
+
+
+
+---
+
+## CreateCertificate
+
+The **CreateCertificate** provides a tile with a button that will create PEM-formatted certificates and keys.
+
+| Variable | Description | Required |
+|-----------------|--------------------------------------------------------|----------|
+| **commonName** | The common name to use when creating the certificate. | Yes |
+
+
+### Example
+
+```jsx
+
+```
+
+
\ No newline at end of file
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
index 718d6fe..da2a205 100644
Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ
diff --git a/src/app/globals.css b/src/app/globals.css
index 10d9f6f..052156f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -25,13 +25,13 @@ p {
@apply text-base m-4 font-sans;
}
ul {
- @apply list-disc list-inside m-4;
+ @apply list-disc list-outside m-4 pl-5;
}
li {
@apply mb-2;
}
li p {
- @apply inline;
+ @apply inline m-0;
}
table {
@apply m-4 table-auto;
@@ -52,7 +52,25 @@ p img {
@apply scale-75;
}
ol {
- @apply list-decimal list-inside pl-5;
+ @apply m-4 pl-0;
+ counter-reset: ordered-list;
+ list-style: none;
+}
+
+ol > li {
+ counter-increment: ordered-list;
+ position: relative;
+ padding-left: 1.4rem;
+}
+
+ol > li::before {
+ content: counter(ordered-list) ".";
+ position: absolute;
+ left: 0;
+ width: 0.8rem;
+ color: inherit;
+ font-weight: 500;
+ text-align: right;
}
.hidden {
display: none;
@@ -62,6 +80,17 @@ ol {
display: none;
}
}
+
+.toc-container ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.toc-container li {
+ margin-bottom: 0;
+}
+
.active {
font-weight: bold;
color: blue; /* Adjust styles as needed */
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index a91c560..f6cf81a 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,11 @@
import type { Metadata } from "next";
+import { Suspense } from "react";
+import { Header } from "@/app/components/header";
+import Loading from "./loading";
import "./globals.css";
+export const dynamic = "force-dynamic";
+
export const metadata: Metadata = {
title: "Lab Framework",
description: "Lab Framework site",
@@ -13,7 +18,10 @@ export default function RootLayout({
}>) {
return (
- {children}
+
+
+ }>{children}
+
);
}
diff --git a/src/app/lib/client-variable-resolver.ts b/src/app/lib/client-variable-resolver.ts
new file mode 100644
index 0000000..7bf8b16
--- /dev/null
+++ b/src/app/lib/client-variable-resolver.ts
@@ -0,0 +1,111 @@
+import { getCandidateKeys } from "@/lib/variable-keys";
+import { LOCAL_VARIABLES_STORAGE_KEY } from "@/lib/variables";
+
+const inflightVariableRequests = new Map>();
+
+function setLocalVariableValue(name: string, value: string): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const raw = localStorage.getItem(LOCAL_VARIABLES_STORAGE_KEY);
+ let variableMap: Record = {};
+
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ variableMap = parsed as Record;
+ }
+ } catch {
+ variableMap = {};
+ }
+ }
+
+ if (variableMap[name] === value) {
+ return;
+ }
+
+ variableMap[name] = value;
+ localStorage.setItem(LOCAL_VARIABLES_STORAGE_KEY, JSON.stringify(variableMap));
+ window.dispatchEvent(new CustomEvent("local-storage-change", { detail: { key: name } }));
+}
+
+function getLocalVariableValue(name: string): unknown | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ const raw = localStorage.getItem(LOCAL_VARIABLES_STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return null;
+ }
+
+ const variableMap = parsed as Record;
+ for (const key of getCandidateKeys(name)) {
+ if (Object.prototype.hasOwnProperty.call(variableMap, key)) {
+ return variableMap[key] ?? null;
+ }
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export async function resolveClientVariable(name: string): Promise {
+ const localValue = getLocalVariableValue(name);
+ if (localValue !== null && localValue !== undefined) {
+ return localValue as T;
+ }
+
+ const inflightRequest = inflightVariableRequests.get(name);
+ if (inflightRequest) {
+ return (await inflightRequest) as T | null;
+ }
+
+ const request = (async () => {
+ const response = await fetch("/api/variables/resolve", {
+ body: JSON.stringify({ name }),
+ cache: "no-store",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const bodyText = await response.text();
+ if (bodyText.trim().length === 0) {
+ return null;
+ }
+
+ let resolvedValue: T | null = null;
+ try {
+ const data = JSON.parse(bodyText) as { value?: T | null };
+ resolvedValue = data.value ?? null;
+ } catch {
+ resolvedValue = null;
+ }
+
+ if (typeof resolvedValue === "string") {
+ setLocalVariableValue(name, resolvedValue);
+ }
+ return resolvedValue;
+ })().finally(() => {
+ inflightVariableRequests.delete(name);
+ });
+
+ inflightVariableRequests.set(name, request);
+ return (await request) as T | null;
+}
\ No newline at end of file
diff --git a/src/app/lib/docker-instance-sync.ts b/src/app/lib/docker-instance-sync.ts
new file mode 100644
index 0000000..c9b4e0d
--- /dev/null
+++ b/src/app/lib/docker-instance-sync.ts
@@ -0,0 +1,42 @@
+import { DockerPortMapping, InstanceState, InstanceType } from "@/lib/types";
+
+export type DockerSyncItem = {
+ componentId?: string;
+ image?: string;
+ isPresent: boolean;
+ name: string;
+ ports?: DockerPortMapping[];
+ status: InstanceState;
+ type: InstanceType.Docker;
+};
+
+type DockerSyncResponse = {
+ instances: DockerSyncItem[];
+};
+
+export async function syncDockerInstances(names: string[]): Promise {
+ const response = await fetch("/api/docker/instances/sync", {
+ body: JSON.stringify({ names }),
+ cache: "no-store",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const bodyText = await response.text();
+ if (bodyText.trim().length === 0) {
+ return [];
+ }
+
+ try {
+ const data = JSON.parse(bodyText) as DockerSyncResponse;
+ return Array.isArray(data.instances) ? data.instances : [];
+ } catch {
+ return [];
+ }
+}
\ No newline at end of file
diff --git a/src/app/lib/docker-lib.test.ts b/src/app/lib/docker-lib.test.ts
index 58421db..b767922 100644
--- a/src/app/lib/docker-lib.test.ts
+++ b/src/app/lib/docker-lib.test.ts
@@ -1,26 +1,42 @@
import { act } from "@testing-library/react";
import {
createContainer,
+ getContainerPorts,
+ getContainerLogs,
removeContainer,
stopContainer,
getContainerStatus
} from "@/app/lib/docker-lib";
import { getInstanceDeploymentName } from "@/app/lib/utils";
+import { getEnvVariable } from "@/lib/variables";
import { DockerPortMapping } from "@/lib/types";
import { Protocol } from "@/lib/types";
jest.mock("@/app/lib/utils");
+jest.mock("@/lib/variables", () => ({
+ getEnvVariable: jest.fn(),
+}));
const mockContainer = {
inspect: jest.fn().mockResolvedValue({ State: { Status: "running" } }),
+ logs: jest.fn().mockResolvedValue(Buffer.from("log-line\n")),
start: jest.fn(),
stop: jest.fn(),
remove: jest.fn(),
};
+const mockImage = {
+ inspect: jest.fn().mockResolvedValue({ Id: "image-id" }),
+};
+
const mockDockerode = {
createContainer: jest.fn().mockReturnValue(mockContainer),
getContainer: jest.fn().mockReturnValue(mockContainer),
+ getImage: jest.fn().mockReturnValue(mockImage),
+ modem: {
+ followProgress: jest.fn((stream, cb) => cb(null)),
+ },
+ pull: jest.fn((image, cb) => cb(null, { on: jest.fn() })),
};
jest.mock('dockerode', () => {
@@ -32,12 +48,32 @@ jest.mock('dockerode', () => {
);
describe("Docker Library", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getEnvVariable as jest.Mock).mockImplementation(async (key: string) => {
+ if (key === "DEPLOYMENT_IDENTIFIER") {
+ return "testpet";
+ }
+ if (key === "test-name") {
+ return "mapped-test-name";
+ }
+ if (key === "test-container-id") {
+ return "mapped-test-container-id";
+ }
+ return null;
+ });
+ mockImage.inspect.mockResolvedValue({ Id: "image-id" });
+ mockDockerode.pull.mockImplementation((image, cb) => cb(null, { on: jest.fn() }));
+ mockDockerode.modem.followProgress.mockImplementation((stream, cb) => cb(null));
+ });
describe("createContainer", () => {
it("should create and start a Docker container with default port settings", async () => {
await act(async () => {
await createContainer({
+ attrs: [{ name: "volume", value: "/host:/container" }],
+ env: [{ name: "TOKEN", value: "secret" }],
image: "test-image",
name: "test-name",
ports: [{ containerPort: 80, hostPort: 8080 }],
@@ -45,14 +81,133 @@ describe("Docker Library", () => {
});
expect(mockDockerode.createContainer).toHaveBeenCalledWith({
+ Env: ["TOKEN=secret"],
+ Hostname: "mapped-test-name",
Image: "test-image",
- name: "mock-test-name",
+ name: "mapped-test-name",
HostConfig: {
+ Binds: ["/host:/container"],
+ NetworkMode: "lab-framework",
PortBindings: {
"80/tcp": [{ HostPort: "8080" }],
},
},
});
+ expect(mockDockerode.getImage).toHaveBeenCalledWith("test-image");
+ expect(mockDockerode.pull).not.toHaveBeenCalled();
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should pull the image if it is not available locally", async () => {
+ mockImage.inspect.mockRejectedValueOnce(new Error("No such image: test-image"));
+
+ await createContainer({
+ attrs: [],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ });
+
+ expect(mockDockerode.pull).toHaveBeenCalledWith("test-image", expect.any(Function));
+ expect(mockDockerode.modem.followProgress).toHaveBeenCalled();
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should delete an existing stopped container before creating a new one", async () => {
+ mockContainer.inspect.mockResolvedValueOnce({ State: { Status: "exited" } });
+
+ await createContainer({
+ attrs: [],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ });
+
+ expect(mockDockerode.getContainer).toHaveBeenCalledWith("mapped-test-name");
+ expect(mockContainer.remove).toHaveBeenCalledTimes(1);
+ expect(mockDockerode.createContainer).toHaveBeenCalledTimes(1);
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should raise a clear auth error if image pull is unauthorized", async () => {
+ mockImage.inspect.mockRejectedValueOnce(new Error("No such image: test-image"));
+ mockDockerode.pull.mockImplementationOnce((image, cb) =>
+ cb(new Error("unauthorized: authentication required"), undefined)
+ );
+
+ await expect(
+ createContainer({
+ attrs: [],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ })
+ ).rejects.toThrow(
+ "Unable to pull image test-image. Docker registry authentication is required for this image."
+ );
+ });
+
+ it("should recreate existing container when name conflict occurs and existing container is running", async () => {
+ const conflictError = Object.assign(
+ new Error("Conflict. The container name is already in use"),
+ { statusCode: 409 }
+ );
+ mockDockerode.createContainer.mockRejectedValueOnce(conflictError);
+ mockContainer.inspect.mockResolvedValueOnce({ State: { Status: "running" } });
+
+ await expect(
+ createContainer({
+ attrs: [],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ })
+ ).resolves.toBeUndefined();
+
+ expect(mockDockerode.getContainer).toHaveBeenCalledWith("mapped-test-name");
+ expect(mockContainer.stop).toHaveBeenCalled();
+ expect(mockContainer.remove).toHaveBeenCalled();
+ expect(mockDockerode.createContainer).toHaveBeenCalledTimes(2);
+ expect(mockDockerode.createContainer).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ Env: [],
+ })
+ );
+ expect(mockDockerode.createContainer).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ Env: [],
+ })
+ );
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should recreate existing container when name conflict occurs and container is exited", async () => {
+ const conflictError = Object.assign(
+ new Error("Conflict. The container name is already in use"),
+ { statusCode: 409 }
+ );
+ mockDockerode.createContainer.mockRejectedValueOnce(conflictError);
+ mockContainer.inspect.mockResolvedValueOnce({ State: { Status: "exited" } });
+
+ await expect(
+ createContainer({
+ attrs: [],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ })
+ ).resolves.toBeUndefined();
+
+ expect(mockDockerode.getContainer).toHaveBeenCalledWith("mapped-test-name");
+ expect(mockContainer.remove).toHaveBeenCalled();
+ expect(mockDockerode.createContainer).toHaveBeenCalledTimes(2);
expect(mockContainer.start).toHaveBeenCalled();
});
});
@@ -60,6 +215,8 @@ describe("Docker Library", () => {
describe("createContainer", () => {
it("should create and start a Docker container with custom port settings", async () => {
await createContainer({
+ attrs: [],
+ env: [],
image: "test-image",
name: "test-name",
ports: [
@@ -69,9 +226,13 @@ describe("Docker Library", () => {
});
expect(mockDockerode.createContainer).toHaveBeenCalledWith({
+ Env: [],
+ Hostname: "mapped-test-name",
Image: "test-image",
- name: "mock-test-name",
+ name: "mapped-test-name",
HostConfig: {
+ Binds: [],
+ NetworkMode: "lab-framework",
PortBindings: {
"81/tcp": [{ HostPort: "8081" }],
"82/udp": [{ HostPort: "8082" }],
@@ -80,6 +241,182 @@ describe("Docker Library", () => {
});
expect(mockContainer.start).toHaveBeenCalled();
});
+
+ it("should request a random host port when hostPort is omitted", async () => {
+ await createContainer({
+ attrs: [],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80 }],
+ });
+
+ expect(mockDockerode.createContainer).toHaveBeenCalledWith({
+ Env: [],
+ Hostname: "mapped-test-name",
+ Image: "test-image",
+ name: "mapped-test-name",
+ HostConfig: {
+ Binds: [],
+ NetworkMode: "lab-framework",
+ PortBindings: {
+ "80/tcp": [{ HostPort: "" }],
+ },
+ },
+ });
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should resolve process.env placeholders in attrs volume values", async () => {
+ process.env.CONTAINER_MOUNT_BASE_DIR = "/tmp/lab";
+
+ await createContainer({
+ attrs: [
+ {
+ name: "volume",
+ value: "${process.env[\"CONTAINER_MOUNT_BASE_DIR\"]}/ssl:/etc/nginx/ssl",
+ },
+ ],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80 }],
+ });
+
+ expect(mockDockerode.createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ HostConfig: expect.objectContaining({
+ Binds: ["/tmp/lab/ssl:/etc/nginx/ssl"],
+ }),
+ })
+ );
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should map supported attrs into docker create config", async () => {
+ await createContainer({
+ attrs: [
+ { name: "hostname", value: "nginx-plus-local" },
+ { name: "network", value: "custom-bridge" },
+ { name: "restart", value: "unless-stopped" },
+ { name: "label", value: "lab.role=nginx" },
+ ],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80 }],
+ });
+
+ expect(mockDockerode.createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Hostname: "nginx-plus-local",
+ Labels: {
+ "lab.role": "nginx",
+ },
+ HostConfig: expect.objectContaining({
+ NetworkMode: "custom-bridge",
+ RestartPolicy: { Name: "unless-stopped" },
+ }),
+ })
+ );
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should preserve unknown attrs as labels", async () => {
+ await createContainer({
+ attrs: [{ name: "ipc", value: "host" }],
+ env: [],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80 }],
+ });
+
+ expect(mockDockerode.createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Labels: {
+ "lab-framework.attr.0.ipc": "host",
+ },
+ })
+ );
+ expect(mockContainer.start).toHaveBeenCalled();
+ });
+
+ it("should resolve secret env vars server-side before creating a container", async () => {
+ (getEnvVariable as jest.Mock).mockImplementation(async (key: string) => {
+ if (key === "DEPLOYMENT_IDENTIFIER") {
+ return "testpet";
+ }
+ if (key === "NGINX_LICENSE_JWT") {
+ return "jwt-from-server";
+ }
+ return null;
+ });
+
+ await createContainer({
+ attrs: [],
+ env: [{ name: "NGINX_LICENSE_JWT", isVariable: true, isSecret: true }],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ });
+
+ expect(mockDockerode.createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Env: ["NGINX_LICENSE_JWT=jwt-from-server"],
+ })
+ );
+ });
+
+ it("should fail container creation when a required secret env var is missing", async () => {
+ (getEnvVariable as jest.Mock).mockImplementation(async (key: string) => {
+ if (key === "DEPLOYMENT_IDENTIFIER") {
+ return "testpet";
+ }
+ return null;
+ });
+
+ await expect(
+ createContainer({
+ attrs: [],
+ env: [{ name: "NGINX_LICENSE_JWT", isVariable: true, isSecret: true }],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ })
+ ).rejects.toThrow("Missing required secret environment variable: NGINX_LICENSE_JWT");
+ });
+
+ it("should use provided env value for a secret variable when server env is missing", async () => {
+ (getEnvVariable as jest.Mock).mockImplementation(async (key: string) => {
+ if (key === "DEPLOYMENT_IDENTIFIER") {
+ return "testpet";
+ }
+ return null;
+ });
+
+ await expect(
+ createContainer({
+ attrs: [],
+ env: [
+ {
+ name: "NGINX_AGENT_COMMAND_AUTH_TOKEN",
+ isVariable: true,
+ isSecret: true,
+ value: "token-from-browser",
+ },
+ ],
+ image: "test-image",
+ name: "test-name",
+ ports: [{ containerPort: 80, hostPort: 8080 }],
+ })
+ ).resolves.toBeUndefined();
+
+ expect(mockDockerode.createContainer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Env: ["NGINX_AGENT_COMMAND_AUTH_TOKEN=token-from-browser"],
+ })
+ );
+ });
});
describe("stopContainer", () => {
@@ -87,7 +424,7 @@ describe("Docker Library", () => {
await stopContainer("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.stop).toHaveBeenCalled();
});
@@ -98,7 +435,7 @@ describe("Docker Library", () => {
await removeContainer("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.stop).toHaveBeenCalled();
expect(mockContainer.remove).toHaveBeenCalled();
@@ -110,7 +447,7 @@ describe("Docker Library", () => {
const status = await getContainerStatus("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.inspect).toHaveBeenCalled();
expect(status).toBe("running");
@@ -122,7 +459,7 @@ describe("Docker Library", () => {
const status = await getContainerStatus("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.inspect).toHaveBeenCalled();
expect(status).toBe("exited");
@@ -132,7 +469,7 @@ describe("Docker Library", () => {
const status = await getContainerStatus("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.inspect).toHaveBeenCalled();
expect(status).toBe("running");
@@ -144,7 +481,7 @@ describe("Docker Library", () => {
const status = await getContainerStatus("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.inspect).toHaveBeenCalled();
expect(status).toBe("exited");
@@ -156,7 +493,7 @@ describe("Docker Library", () => {
const status = await getContainerStatus("test-container-id");
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
);
expect(mockContainer.inspect).toHaveBeenCalled();
expect(status).toBe("unknown");
@@ -169,9 +506,103 @@ describe("Docker Library", () => {
await expect(getContainerStatus("test-container-id")).rejects.toThrow(error);
expect(mockDockerode.getContainer).toHaveBeenCalledWith(
- "mock-test-container-id"
+ "mapped-test-container-id"
+ );
+ expect(mockContainer.inspect).toHaveBeenCalled();
+ });
+
+ it("should resolve nginx-plus-1 to the normalized deployment name used by the lab", async () => {
+ (getInstanceDeploymentName as jest.Mock).mockImplementationOnce(
+ ({ name, deploymentIdentifier }) =>
+ name === "nginx-plus-1"
+ ? `${deploymentIdentifier}-nginxplus1`
+ : `mock-${name}`
+ );
+
+ const status = await getContainerStatus("nginx-plus-1");
+
+ expect(mockDockerode.getContainer).toHaveBeenCalledWith(
+ "testpet-nginxplus1"
);
expect(mockContainer.inspect).toHaveBeenCalled();
+ expect(status).toBe("running");
+ });
+ });
+
+ describe("getContainerLogs", () => {
+ it("should return container logs as text", async () => {
+ const logs = await getContainerLogs("test-container-id");
+
+ expect(mockDockerode.getContainer).toHaveBeenCalledWith("mapped-test-container-id");
+ expect(mockContainer.logs).toHaveBeenCalledWith({
+ follow: false,
+ stderr: true,
+ stdout: true,
+ tail: 500,
+ });
+ expect(logs).toContain("log-line");
+ });
+ });
+
+ describe("getContainerPorts", () => {
+ it("should deduplicate duplicate host port bindings for the same container port", async () => {
+ mockContainer.inspect.mockResolvedValueOnce({
+ NetworkSettings: {
+ Ports: {
+ "80/tcp": [
+ { HostIp: "0.0.0.0", HostPort: "51697" },
+ { HostIp: "::", HostPort: "51697" },
+ ],
+ },
+ },
+ });
+
+ const ports = await getContainerPorts("test-container-id");
+
+ expect(ports).toEqual([
+ { containerPort: 80, hostPort: 51697, protocol: Protocol.Tcp },
+ ]);
+ });
+ });
+
+ describe("getDockerInstanceSnapshot", () => {
+ it("should return a running instance snapshot with ports", async () => {
+ mockContainer.inspect.mockResolvedValue({
+ State: { Status: "running" },
+ Config: { Image: "nginx:latest" },
+ NetworkSettings: {
+ Ports: {
+ "80/tcp": [{ HostIp: "0.0.0.0", HostPort: "62226" }],
+ },
+ },
+ });
+
+ const { getDockerInstanceSnapshot } = await import("./docker-lib");
+ const snapshot = await getDockerInstanceSnapshot("test-container-id");
+
+ expect(snapshot).toEqual(
+ expect.objectContaining({
+ name: "test-container-id",
+ componentId: "mapped-test-container-id",
+ image: "nginx:latest",
+ status: "running",
+ })
+ );
+ expect(snapshot?.ports).toEqual([
+ { containerPort: 80, hostPort: 62226, protocol: Protocol.Tcp },
+ ]);
+ });
+
+ it("should return null for a non-running container", async () => {
+ mockContainer.inspect.mockResolvedValueOnce({
+ State: { Status: "exited" },
+ Config: { Image: "nginx:latest" },
+ });
+
+ const { getDockerInstanceSnapshot } = await import("./docker-lib");
+ const snapshot = await getDockerInstanceSnapshot("test-container-id");
+
+ expect(snapshot).toBeNull();
});
});
});
diff --git a/src/app/lib/docker-lib.ts b/src/app/lib/docker-lib.ts
index 015b89d..69188de 100644
--- a/src/app/lib/docker-lib.ts
+++ b/src/app/lib/docker-lib.ts
@@ -1,11 +1,279 @@
"use server";
+import { exec as execCallback } from "child_process";
import Docker from "dockerode";
+import { promisify } from "util";
import { getInstanceDeploymentName } from "./utils";
+import { getEnvVariable } from "@/lib/variables";
import {
+ DockerAttribute,
+ InstanceDockerEnv,
InstanceDocker,
DockerPortMapping
} from "@/lib/types";
-import { InstanceState, Protocol } from "@/lib/types";
+import { InstanceState, InstanceType as DockerInstanceType, Protocol } from "@/lib/types";
+
+const execAsync = promisify(execCallback);
+
+type DockerConnectionConfig = {
+ dockerApiUrl?: string | null;
+ networkMode?: string | null;
+ deploymentIdentifier?: string | null;
+};
+
+async function getDockerConnectionConfig(): Promise {
+ const [dockerApiUrl, networkModeRaw, deploymentIdentifier] = await Promise.all([
+ getEnvVariable("DOCKER_API_URL"),
+ getEnvVariable("CONTAINER_DOCKER_NETWORK"),
+ getEnvVariable("DEPLOYMENT_IDENTIFIER"),
+ ]);
+
+ const networkMode =
+ typeof networkModeRaw === "string" && networkModeRaw.trim().length > 0
+ ? networkModeRaw
+ : "lab-framework";
+
+ return { dockerApiUrl, networkMode, deploymentIdentifier };
+}
+
+function getDeploymentNameFromIdentifier(name: string, deploymentIdentifier?: string | null): string {
+ if (!deploymentIdentifier || deploymentIdentifier.trim().length === 0) {
+ // In local/dev flows DEPLOYMENT_IDENTIFIER may be unset; fall back to the raw instance name.
+ return name;
+ }
+ return getInstanceDeploymentName({ name, deploymentIdentifier });
+}
+
+async function resolveDeploymentName(
+ name: string,
+ deploymentIdentifier?: string | null,
+ componentId?: string | null
+): Promise {
+ if (componentId && componentId.trim().length > 0) {
+ return componentId;
+ }
+
+ const mappedInstanceName = await getEnvVariable(name).catch(() => null);
+ if (typeof mappedInstanceName === "string" && mappedInstanceName.trim().length > 0) {
+ return mappedInstanceName;
+ }
+
+ return getDeploymentNameFromIdentifier(name, deploymentIdentifier);
+}
+
+function createDockerClient(dockerApiUrl?: string | null): Docker {
+ if (!dockerApiUrl) {
+ return new Docker();
+ }
+
+ try {
+ const normalizedUrl = dockerApiUrl.startsWith("tcp://")
+ ? dockerApiUrl.replace("tcp://", "http://")
+ : dockerApiUrl;
+ const parsed = new URL(normalizedUrl);
+ return new Docker({
+ host: parsed.hostname,
+ port: Number(parsed.port || (parsed.protocol === "https:" ? "443" : "80")),
+ protocol: parsed.protocol.replace(":", "") as "http" | "https",
+ });
+ } catch {
+ return new Docker();
+ }
+}
+
+async function ensureImageAvailable(docker: Docker, image: string): Promise {
+ try {
+ await docker.getImage(image).inspect();
+ return;
+ } catch (error) {
+ if (!(error instanceof Error) || !error.message.includes("No such image")) {
+ throw error;
+ }
+ }
+
+ await pullImage(docker, image);
+}
+
+async function pullImage(docker: Docker, image: string): Promise {
+ const stream = await new Promise((resolve, reject) => {
+ docker.pull(image, (error: Error | null, pullStream?: NodeJS.ReadableStream) => {
+ if (error) {
+ reject(formatImagePullError(image, error));
+ return;
+ }
+
+ if (!pullStream) {
+ reject(new Error(`Docker did not return a pull stream for image ${image}`));
+ return;
+ }
+
+ resolve(pullStream as NodeJS.ReadableStream);
+ });
+ });
+
+ await new Promise((resolve, reject) => {
+ docker.modem.followProgress(stream, (error: Error | null) => {
+ if (error) {
+ reject(formatImagePullError(image, error));
+ return;
+ }
+ resolve();
+ });
+ });
+}
+
+function formatImagePullError(image: string, error: unknown): Error {
+ const message = error instanceof Error ? error.message : String(error);
+
+ if (/unauthorized|authentication required|pull access denied/i.test(message)) {
+ return new Error(
+ `Unable to pull image ${image}. Docker registry authentication is required for this image.`
+ );
+ }
+
+ return new Error(`Unable to pull image ${image}: ${message}`);
+}
+
+function isContainerNameConflictError(error: unknown): boolean {
+ if (!(error instanceof Error)) {
+ return false;
+ }
+
+ const statusCode = (error as Error & { statusCode?: number }).statusCode;
+ if (statusCode === 409) {
+ return true;
+ }
+
+ return /already in use|conflict/i.test(error.message);
+}
+
+async function ensureExistingContainerRunning(
+ docker: Docker,
+ name: string,
+ deploymentIdentifier?: string | null
+): Promise {
+ const deploymentName = await resolveDeploymentName(name, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
+ const data = await container.inspect();
+ if (data.State?.Status !== InstanceState.Running) {
+ await container.start();
+ }
+}
+
+async function removeExistingContainer(
+ docker: Docker,
+ name: string,
+ deploymentIdentifier?: string | null
+): Promise {
+ const deploymentName = await resolveDeploymentName(name, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
+
+ try {
+ const data = await container.inspect();
+ if (data.State?.Status === InstanceState.Running) {
+ await container.stop();
+ }
+ await container.remove();
+ } catch (error) {
+ if (!isContainerDoesNotExistError(error)) {
+ throw error;
+ }
+ }
+}
+
+async function removeStoppedExistingContainer(
+ docker: Docker,
+ deploymentName: string
+): Promise {
+ const container = docker.getContainer(deploymentName);
+
+ try {
+ const data = await container.inspect();
+ if (data.State?.Status !== InstanceState.Running) {
+ await container.remove();
+ }
+ } catch (error) {
+ if (!isContainerDoesNotExistError(error)) {
+ throw error;
+ }
+ }
+}
+
+async function resolveContainerEnvVars(env: InstanceDockerEnv[]): Promise {
+ return await Promise.all(
+ env.map(async (entry) => {
+ if (!entry.isVariable) {
+ return entry;
+ }
+
+ const providedValue =
+ entry.value === null || entry.value === undefined ? undefined : String(entry.value);
+ const processEnvValue =
+ process.env[entry.name] ?? process.env[entry.name.toUpperCase()];
+ const resolvedRaw =
+ processEnvValue !== undefined
+ ? processEnvValue
+ : (await getEnvVariable(entry.name)) ?? providedValue;
+ const resolvedValue =
+ resolvedRaw === null || resolvedRaw === undefined ? undefined : String(resolvedRaw);
+
+ if (entry.isSecret && resolvedValue === undefined) {
+ throw new Error(`Missing required secret environment variable: ${entry.name}`);
+ }
+
+ return {
+ ...entry,
+ value: resolvedValue ?? entry.value ?? "",
+ };
+ })
+ );
+}
+
+function buildContainerConfig({
+ attrs,
+ deploymentName,
+ envVars,
+ image,
+ mappedPorts,
+ networkMode,
+}: {
+ attrs: DockerAttribute[];
+ deploymentName: string;
+ envVars: string[];
+ image: string;
+ mappedPorts: { [key: string]: { HostPort: string }[] };
+ networkMode?: string | null;
+}) {
+ const {
+ binds,
+ hostname,
+ labels,
+ networkModeOverride,
+ restartPolicy,
+ } = mapAttrsToDockerConfig(attrs);
+ const effectiveNetworkMode = networkModeOverride ?? networkMode;
+ const effectiveHostname = hostname ?? deploymentName;
+
+ return {
+ _debugAttrs: {
+ binds,
+ hostname,
+ labels,
+ networkModeOverride,
+ restartPolicy,
+ },
+ Env: [...envVars],
+ Image: image,
+ name: deploymentName,
+ ...(effectiveHostname ? { Hostname: effectiveHostname } : {}),
+ ...(Object.keys(labels).length > 0 ? { Labels: labels } : {}),
+ HostConfig: {
+ Binds: [...binds],
+ ...(effectiveNetworkMode ? { NetworkMode: effectiveNetworkMode } : {}),
+ ...(restartPolicy ? { RestartPolicy: { Name: restartPolicy } } : {}),
+ ...(Object.keys(mappedPorts).length > 0 ? { PortBindings: mappedPorts } : {}),
+ },
+ };
+}
/**
* Creates and starts a Docker container.
@@ -18,23 +286,80 @@ import { InstanceState, Protocol } from "@/lib/types";
*/
// const createContainer = async ({ image, name, port }: createContainerType): Promise => {
const createContainer = async ({
+ attrs,
+ componentId,
+ env,
image,
name,
ports,
}: Omit): Promise => {
- const docker = new Docker();
+ if ((attrs ?? []).length > 0) {
+ console.log("createContainer incoming attrs:", {
+ name,
+ attrs,
+ });
+ }
+
+ const { dockerApiUrl, networkMode, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(name, deploymentIdentifier, componentId);
+ await ensureImageAvailable(docker, image);
+ await removeStoppedExistingContainer(docker, deploymentName);
const defaultProtocol = Protocol.Tcp;
- const mappedPorts = mapPorts(
- ports ?? [{ containerPort: 80, hostPort: 8080, protocol: defaultProtocol }],
- defaultProtocol
- );
- const container = await docker.createContainer({
- Image: image,
- name: getInstanceDeploymentName({ name }),
- HostConfig: {
- PortBindings: mappedPorts,
- },
+ const mappedPorts = mapPorts(ports ?? [], defaultProtocol);
+ const resolvedEnv = await resolveContainerEnvVars(env ?? []);
+ const envVars = mapEnvVars(resolvedEnv);
+ const containerConfig = buildContainerConfig({
+ attrs: attrs ?? [],
+ deploymentName,
+ envVars,
+ image,
+ mappedPorts,
+ networkMode,
});
+
+ const debugAttrs = (containerConfig as { _debugAttrs?: unknown })._debugAttrs;
+ if ((attrs ?? []).length > 0) {
+ console.log("Docker attrs resolution summary:", {
+ deploymentName,
+ requestedCount: (attrs ?? []).length,
+ debugAttrs,
+ });
+ }
+
+ const dockerCreateConfig = { ...(containerConfig as Record) };
+ delete dockerCreateConfig._debugAttrs;
+
+ if ((env ?? []).length > 0 && envVars.length === 0) {
+ throw new Error("Docker env resolution failed: no environment variables were generated");
+ }
+ if ((env ?? []).length > 0) {
+ const envResolutionSummary = resolvedEnv.map((entry) => ({
+ name: entry.name,
+ hasValue: (entry.value ?? "") !== "",
+ isSecret: entry.isSecret === true,
+ isVariable: entry.isVariable === true,
+ }));
+ console.log("Docker env resolution summary:", {
+ deploymentName,
+ requestedCount: (env ?? []).length,
+ resolvedCount: resolvedEnv.length,
+ dockerEnvPayloadCount: envVars.length,
+ envResolutionSummary,
+ });
+ }
+ let container: Awaited>;
+ try {
+ container = await docker.createContainer(dockerCreateConfig as Docker.ContainerCreateOptions);
+ } catch (error) {
+ if (!isContainerNameConflictError(error)) {
+ throw error;
+ }
+
+ await removeExistingContainer(docker, name, deploymentIdentifier);
+ container = await docker.createContainer(dockerCreateConfig as Docker.ContainerCreateOptions);
+ }
+
await container.start();
};
@@ -45,10 +370,10 @@ const createContainer = async ({
* @returns {Promise} - A promise that resolves when the container is stopped.
*/
const stopContainer = async (containerId: string): Promise => {
- const docker = new Docker();
- const container = docker.getContainer(
- getInstanceDeploymentName({ name: containerId })
- );
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
await container.stop();
};
@@ -59,11 +384,11 @@ const stopContainer = async (containerId: string): Promise => {
* @returns {Promise} - A promise that resolves when the container is removed.
*/
const removeContainer = async (containerId: string): Promise => {
- const docker = new Docker();
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
const status = await getContainerStatus(containerId);
- const container = docker.getContainer(
- getInstanceDeploymentName({ name: containerId })
- );
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
if (status === InstanceState.Running) {
await container.stop();
@@ -77,16 +402,79 @@ const mapPorts = (ports: DockerPortMapping[], defaultProtocol: Protocol) =>
acc: { [key: string]: { HostPort: string }[] },
{ containerPort, hostPort, protocol }
) => {
- if (hostPort !== undefined) {
- acc[`${containerPort}/${protocol ?? defaultProtocol}`] = [
- { HostPort: hostPort.toString() },
- ];
- }
+ acc[`${containerPort}/${protocol ?? defaultProtocol}`] = [
+ { HostPort: hostPort?.toString() ?? "" },
+ ];
return acc;
},
{}
);
+const mapEnvVars = (env: InstanceDockerEnv[]) =>
+ env.map(({ name, value = "" }) => `${name}=${value}`);
+
+const resolveAttrValueTemplates = (value: string): string =>
+ value.replace(/\$\{process\.env\[(?:"([^"]+)"|'([^']+)')\]\}/g, (_match, dqKey?: string, sqKey?: string) => {
+ const envKey = dqKey ?? sqKey;
+ if (!envKey) {
+ return "";
+ }
+ return process.env[envKey] ?? "";
+ });
+
+const mapAttrsToDockerConfig = (attrs: DockerAttribute[]) => {
+ const binds: string[] = [];
+ const labels: Record = {};
+ let hostname: string | undefined;
+ let networkModeOverride: string | undefined;
+ let restartPolicy: string | undefined;
+
+ attrs.forEach(({ name, value }, index) => {
+ const normalizedName = name.trim().toLowerCase();
+ const resolvedValue = resolveAttrValueTemplates(value);
+
+ if (normalizedName === "volume") {
+ binds.push(resolvedValue);
+ return;
+ }
+
+ if (normalizedName === "hostname") {
+ hostname = resolvedValue;
+ return;
+ }
+
+ if (normalizedName === "network") {
+ networkModeOverride = resolvedValue;
+ return;
+ }
+
+ if (normalizedName === "restart") {
+ restartPolicy = resolvedValue;
+ return;
+ }
+
+ if (normalizedName === "label") {
+ const separatorIndex = resolvedValue.indexOf("=");
+ if (separatorIndex > 0) {
+ const labelKey = resolvedValue.slice(0, separatorIndex);
+ const labelValue = resolvedValue.slice(separatorIndex + 1);
+ labels[labelKey] = labelValue;
+ }
+ return;
+ }
+
+ labels[`lab-framework.attr.${index}.${normalizedName}`] = resolvedValue;
+ });
+
+ return {
+ binds,
+ hostname,
+ labels,
+ networkModeOverride,
+ restartPolicy,
+ };
+};
+
/**
* Gets the current status of a Docker container.
*
@@ -94,10 +482,10 @@ const mapPorts = (ports: DockerPortMapping[], defaultProtocol: Protocol) =>
* @returns {Promise} - A promise that resolves with the container's status.
*/
const getContainerStatus = async (containerId: string): Promise => {
- const docker = new Docker();
- const container = docker.getContainer(
- getInstanceDeploymentName({ name: containerId })
- );
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
try {
const data = await container.inspect();
const retVal = data.State.Status as InstanceState || InstanceState.Unknown;
@@ -108,6 +496,152 @@ const getContainerStatus = async (containerId: string): Promise =
}
};
+export async function getDockerInstanceSnapshot(containerId: string): Promise {
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
+
+ try {
+ const data = await container.inspect();
+ const status = (data.State?.Status as InstanceState | undefined) ?? InstanceState.Unknown;
+ if (status !== InstanceState.Running) {
+ return null;
+ }
+
+ return {
+ type: DockerInstanceType.Docker,
+ name: containerId,
+ componentId: deploymentName,
+ image: data.Config?.Image ?? "",
+ status,
+ ports: await getContainerPorts(containerId, deploymentName),
+ };
+ } catch (error) {
+ if (isContainerDoesNotExistError(error)) {
+ return null;
+ }
+
+ throw error;
+ }
+}
+
+const isContainerPresent = async (containerId: string): Promise => {
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
+
+ try {
+ await container.inspect();
+ return true;
+ } catch (error) {
+ if (isContainerDoesNotExistError(error)) {
+ return false;
+ }
+ throw error;
+ }
+};
+
+const getContainerLogs = async (containerId: string): Promise => {
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const container = docker.getContainer(deploymentName);
+ const logs = await container.logs({
+ follow: false,
+ stderr: true,
+ stdout: true,
+ tail: 500,
+ });
+
+ return Buffer.isBuffer(logs) ? logs.toString("utf-8") : String(logs);
+};
+
+const getContainerPorts = async (
+ containerId: string,
+ componentId?: string | null
+): Promise => {
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const docker = createDockerClient(dockerApiUrl);
+ const deploymentName = await resolveDeploymentName(
+ containerId,
+ deploymentIdentifier,
+ componentId
+ );
+ const container = docker.getContainer(deploymentName);
+
+ try {
+ const data = await container.inspect();
+ const portBindings = data.NetworkSettings?.Ports ?? {};
+
+ const resolvedPorts: DockerPortMapping[] = [];
+ const seenPortMappings = new Set();
+
+ const pushUniquePortMapping = (portMapping: DockerPortMapping) => {
+ const key = `${portMapping.containerPort}-${portMapping.protocol ?? Protocol.Tcp}-${portMapping.hostPort ?? ""}`;
+ if (seenPortMappings.has(key)) {
+ return;
+ }
+ seenPortMappings.add(key);
+ resolvedPorts.push(portMapping);
+ };
+
+ for (const [containerPortWithProto, bindings] of Object.entries(portBindings)) {
+ const [containerPortRaw, protoRaw] = containerPortWithProto.split("/");
+ const containerPort = Number(containerPortRaw);
+ const protocol = protoRaw?.toLowerCase() === Protocol.Udp ? Protocol.Udp : Protocol.Tcp;
+
+ if (Number.isNaN(containerPort)) {
+ continue;
+ }
+
+ if (!bindings || bindings.length === 0) {
+ pushUniquePortMapping({ containerPort, protocol });
+ continue;
+ }
+
+ for (const binding of bindings) {
+ const hostPort = Number(binding.HostPort);
+ pushUniquePortMapping({
+ containerPort,
+ hostPort: Number.isNaN(hostPort) ? undefined : hostPort,
+ protocol,
+ });
+ }
+ }
+
+ return resolvedPorts;
+ } catch (error) {
+ if (isContainerDoesNotExistError(error)) {
+ return [];
+ }
+ throw error;
+ }
+};
+
+const execShellCommand = async (containerId: string, command: string): Promise => {
+ const { dockerApiUrl, deploymentIdentifier } = await getDockerConnectionConfig();
+ const deploymentName = await resolveDeploymentName(containerId, deploymentIdentifier);
+ const hostArgs = dockerApiUrl ? `-H ${dockerApiUrl} ` : "";
+ const escapedCommand = command.replace(/'/g, `'"'"'`);
+ const cliCommand = `docker ${hostArgs}exec ${deploymentName} sh -lc '${escapedCommand}'`;
+
+ try {
+ const { stdout, stderr } = await execAsync(cliCommand);
+ // Many valid commands (for example nginx -t/-T) write success text to stderr.
+ return [stdout, stderr].filter((chunk) => chunk && chunk.trim().length > 0).join("\n").trim();
+ } catch (error) {
+ const err = error as Error & { stderr?: string; stdout?: string };
+ const details = [err.stderr, err.stdout]
+ .filter((chunk): chunk is string => typeof chunk === "string" && chunk.trim().length > 0)
+ .join("\n")
+ .trim();
+
+ throw new Error(details || err.message || "Command failed");
+ }
+};
+
/**
* Checks if the given error indicates that a Docker container does not exist.
*
@@ -119,6 +653,10 @@ const isContainerDoesNotExistError = (error: unknown) =>
export {
createContainer,
+ execShellCommand,
+ getContainerLogs,
+ getContainerPorts,
+ isContainerPresent,
removeContainer,
stopContainer,
getContainerStatus
diff --git a/src/app/lib/use-local-storage.ts b/src/app/lib/use-local-storage.ts
index b110daa..00e06af 100644
--- a/src/app/lib/use-local-storage.ts
+++ b/src/app/lib/use-local-storage.ts
@@ -1,5 +1,23 @@
"use client"
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
+
+function readStoredValue(key: string, fallback: T): T {
+ try {
+ const value = localStorage.getItem(key)
+ if (value) {
+ return JSON.parse(value) as T
+ }
+ } catch {
+ // Fall through to fallback below.
+ }
+
+ localStorage.setItem(key, JSON.stringify(fallback));
+ return fallback;
+}
+
+function persistStoredValue(key: string, value: T): void {
+ localStorage.setItem(key, JSON.stringify(value));
+}
/**
* Custom hook to manage state synchronized with localStorage.
@@ -14,38 +32,30 @@ import { useState } from "react";
* setValue('newValue');
*/
const useLocalStorage = (key: string, defaultValue: T): [T, (valueOrFn: T | ((val: T) => T)) => void] => {
+ const defaultValueRef = useRef(defaultValue);
+ const [localStorageValue, setLocalStorageValue] = useState(defaultValueRef.current)
- // Create state variable to store localStorage value in state
- const [localStorageValue, setLocalStorageValue] = useState(() => {
- try {
- const value = localStorage.getItem(key)
- if (value) {
- // If value is already present in localStorage then return it
- return JSON.parse(value)
- } else {
- // Else set default value in localStorage and then return it
- localStorage.setItem(key, JSON.stringify(defaultValue));
- return defaultValue
- }
- } catch {
- // If an error is caught during reading or parsing of an existing value, set default value in localStorage and then return it
- localStorage.setItem(key, JSON.stringify(defaultValue));
- return defaultValue
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
}
- })
+
+ setLocalStorageValue(readStoredValue(key, defaultValueRef.current));
+ }, [key]);
// this method update our localStorage and our state
const setLocalStorageStateValue = (valueOrFn: T | ((val: T) => T)) => {
- let newValue;
- if (typeof valueOrFn === 'function') {
- const fn = valueOrFn as (val: T) => T;
- newValue = fn(localStorageValue)
- }
- else {
- newValue = valueOrFn;
- }
- localStorage.setItem(key, JSON.stringify(newValue));
- setLocalStorageValue(newValue)
+ setLocalStorageValue((prevValue) => {
+ const newValue = typeof valueOrFn === "function"
+ ? (valueOrFn as (val: T) => T)(prevValue)
+ : valueOrFn;
+
+ if (typeof window !== "undefined") {
+ persistStoredValue(key, newValue);
+ }
+
+ return newValue;
+ });
}
return [localStorageValue, setLocalStorageStateValue]
}
diff --git a/src/app/lib/utils.test.ts b/src/app/lib/utils.test.ts
index b3d6d46..2ab5ac2 100644
--- a/src/app/lib/utils.test.ts
+++ b/src/app/lib/utils.test.ts
@@ -14,27 +14,22 @@ describe("Utils Library", () => {
});
describe("getInstanceDeploymentName", () => {
- it("should generate a deployment name with default petname", () => {
- const result = getInstanceDeploymentName({ name: "MyInstance" });
- expect(result).toBe("testing-myinstance");
- });
-
- it("should generate a deployment name with provided petname", () => {
+ it("should generate a deployment name with provided deployment identifier", () => {
const result = getInstanceDeploymentName({
name: "MyInstance",
- petname: "custom",
+ deploymentIdentifier: "custom",
});
expect(result).toBe("custom-myinstance");
});
- it("should replace non-alphanumeric characters with hyphens", () => {
- const result = getInstanceDeploymentName({ name: "My@Instance!" });
- expect(result).toBe("testing-my-instance-");
+ it("should strip non-alphanumeric characters from the name", () => {
+ const result = getInstanceDeploymentName({ name: "My@Instance!", deploymentIdentifier: "custom" });
+ expect(result).toBe("custom-myinstance");
});
it("should convert the name to lowercase", () => {
- const result = getInstanceDeploymentName({ name: "MyInstance" });
- expect(result).toBe("testing-myinstance");
+ const result = getInstanceDeploymentName({ name: "MyInstance", deploymentIdentifier: "custom" });
+ expect(result).toBe("custom-myinstance");
});
});
});
diff --git a/src/app/lib/utils.ts b/src/app/lib/utils.ts
index 1c0675f..1a8e7da 100644
--- a/src/app/lib/utils.ts
+++ b/src/app/lib/utils.ts
@@ -12,7 +12,7 @@ export function mergeClasses(...classes: string[]): string {
type InstanceDeploymentNameType = {
name: string;
- petname?: string;
+ deploymentIdentifier: string;
};
/**
@@ -20,17 +20,22 @@ type InstanceDeploymentNameType = {
*
* @param {InstanceDeploymentNameType} param0 - The instance deployment name parameters.
* @param {string} param0.name - The base name of the instance.
- * @param {string} [param0.petname='testing'] - An optional pet name to prefix the deployment name.
+ * @param {string} param0.deploymentIdentifier - The identifier prefix for the deployment name.
* @returns {string} - The normalized deployment name.
*/
-export function getInstanceDeploymentName({ name, petname = 'testing' }: InstanceDeploymentNameType): string {
+export function getInstanceDeploymentName(
+ params: InstanceDeploymentNameType
+): string {
+
+ const { name } = params;
+ const { deploymentIdentifier } = params;
/**
* Replaces all non-alphanumeric characters in the name with hyphens and converts the string to lowercase.
* This ensures the deployment name is suitable for Docker and Kubernetes, which require lowercase
* alphanumeric characters and hyphens.
*/
- const deploymentName = name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
+ const deploymentName = name.replace(/[^a-z0-9]/gi, "").toLowerCase();
- return `${petname}-${deploymentName}`;
+ return `${deploymentIdentifier}-${deploymentName}`;
}
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..a5b2419
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,15 @@
+export default function Loading() {
+ return (
+
+
+
+ Loading
+
+
+
Loading...
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 5f27d69..3d068de 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,20 +1,45 @@
+import Link from "next/link";
import Image from "next/image";
+import { getIndexDocs } from "@/lib/mdxUtils";
+import { getDeploymentIdentifier } from "@/lib/variables";
+
+function getMetaString(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+export default async function Home() {
+ const deploymentIdentifier = await getDeploymentIdentifier();
+ const docs = await getIndexDocs();
-export default function Home() {
return (
Welcome to Lab Framework
The following are a list of pages in this lab.
+
+ {docs.map((doc) => {
+ const title = getMetaString(doc.documentData.metadata?.title, doc.name);
+ const description = getMetaString(doc.documentData.metadata?.description);
+
+ return (
+ -
+ {title}
+ {description ? ` - ${description}` : ""}
+
+ );
+ })}
+
+ {deploymentIdentifier ?? ""}
);
}
diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx
index df28eb2..de6ac02 100644
--- a/src/app/test/page.tsx
+++ b/src/app/test/page.tsx
@@ -1,34 +1,25 @@
-import React from "react";
-import { InstancesContextProvider } from "@/app/contexts/instances";
-import { DockerInstance } from "@/app/components/docker";
-import { Card } from "@/app/components/card";
-import { APICheck } from "../components/api-check";
-
export const dynamic = "force-dynamic";
-const RoutePage: React.FC = () => {
+import { getMdxContent } from "@/lib/mdxUtils";
+
+const DOCUMENT_NAME = "author-docs.mdx";
+
+const RoutePage = async () => {
+ const { content, frontmatter } = await getMdxContent(DOCUMENT_NAME);
+
return (
<>
-
-
-
Welcome to the Test Page
-
-
-
-
-
-
+
+
+ {frontmatter.title ?? DOCUMENT_NAME}
+
+ {frontmatter.description && (
+
+ {frontmatter.description}
+
+ )}
+
+ {content}
>
);
};
diff --git a/src/lib/api-base.test.tsx b/src/lib/api-base.test.tsx
index de74b23..87dcc5b 100644
--- a/src/lib/api-base.test.tsx
+++ b/src/lib/api-base.test.tsx
@@ -1,12 +1,27 @@
import { render, fireEvent, waitFor, act } from "@testing-library/react";
import { APIBase } from "./api-base";
import { checkAPI } from "@/lib/check-api";
+import { getVariable } from "./variables";
+import { getDockerInstanceSnapshot } from "@/app/lib/docker-lib";
jest.mock("@/lib/check-api", () => ({
checkAPI: jest.fn(),
}));
+jest.mock("./variables", () => ({
+ getVariable: jest.fn(),
+}));
+
+jest.mock("@/app/lib/docker-lib", () => ({
+ getDockerInstanceSnapshot: jest.fn(),
+}));
+
describe("APIBase Component", () => {
+ beforeEach(() => {
+ (getVariable as jest.Mock).mockResolvedValue(null);
+ (getDockerInstanceSnapshot as jest.Mock).mockResolvedValue(null);
+ });
+
it("should render the button and initial state correctly", () => {
const { getByText, container } = render();
expect(getByText("Check")).toBeInTheDocument();
@@ -36,7 +51,7 @@ describe("APIBase Component", () => {
await waitFor(() => {
expect(
- getByText("Error: The API check failed: HTTP error 500", { exact: false })
+ getByText("Error: The API check failed", { exact: false })
).toBeInTheDocument();
});
});
@@ -65,4 +80,53 @@ describe("APIBase Component", () => {
expect(container.querySelector(".bg-red-50")).toBeInTheDocument();
});
});
+
+ it("should forward tlsComponent to checkAPI", async () => {
+ (checkAPI as jest.Mock).mockResolvedValueOnce(true);
+ const { getByText } = render();
+
+ await act(async () => {
+ fireEvent.click(getByText("Check"));
+ });
+
+ await waitFor(() => {
+ expect(checkAPI).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tlsComponent: true,
+ url: "http://localhost",
+ })
+ );
+ });
+ });
+
+ it("should fall back to resolved componentId when local instance is missing", async () => {
+ (checkAPI as jest.Mock).mockResolvedValueOnce(true);
+ (getDockerInstanceSnapshot as jest.Mock).mockResolvedValueOnce({
+ type: 0,
+ name: "nginx-plus-1",
+ componentId: "happy-sloth-nginxplus1",
+ image: "nginx:latest",
+ status: "running",
+ ports: [{ containerPort: 80, hostPort: 62226, protocol: "tcp" }],
+ });
+
+ const { getByText } = render();
+
+ await act(async () => {
+ fireEvent.click(getByText("Check"));
+ });
+
+ await waitFor(() => {
+ expect(checkAPI).toHaveBeenCalledWith(
+ expect.objectContaining({
+ component: expect.objectContaining({
+ name: "nginx-plus-1",
+ componentId: "happy-sloth-nginxplus1",
+ }),
+ path: "/nginx_status",
+ url: null,
+ })
+ );
+ });
+ });
});
diff --git a/src/lib/api-base.tsx b/src/lib/api-base.tsx
index 1e46c10..81ee782 100644
--- a/src/lib/api-base.tsx
+++ b/src/lib/api-base.tsx
@@ -1,9 +1,10 @@
"use client";
import { checkAPI } from "@/lib/check-api";
import { useState } from "react";
-import { ensureError } from "./utils";
+import { ensureError, isTruthyBooleanLike } from "./utils";
import { useInstances } from "./client-variables";
-import { Instance } from "./types";
+import { Instance, InstanceState, InstanceType } from "./types";
+import { resolveClientVariable } from "@/app/lib/client-variable-resolver";
/**
* APIBase component
@@ -40,20 +41,35 @@ export function APIBase({
path?: string;
targetStatusCode?: number;
url?: string | null;
- tlsComponent?: boolean;
+ tlsComponent?: boolean | string;
}) {
const [state, setState] = useState<{
status: boolean | null;
error: string | null;
}>({ status: null, error: null });
- const [instances] = useInstances();
+ const [instances, setInstances] = useInstances();
+
+ const resolvedTlsComponent = isTruthyBooleanLike(tlsComponent);
function getComponentData(name: string): Promise {
const instance = instances.find((i) => i.name === name);
return Promise.resolve(instance ? (instance as unknown as T) : null);
}
+ function upsertInstance(nextInstance: Instance): void {
+ setInstances((prevInstances) => {
+ const existingIndex = prevInstances.findIndex((instance) => instance.name === nextInstance.name);
+ if (existingIndex === -1) {
+ return [...prevInstances, nextInstance];
+ }
+
+ const updatedInstances = [...prevInstances];
+ updatedInstances[existingIndex] = nextInstance;
+ return updatedInstances;
+ });
+ }
+
/**
* Handles the API check and updates the state accordingly
*
@@ -62,22 +78,55 @@ export function APIBase({
*/
const handleCheck = async () => {
try {
+ const resolvedComponent = componentName
+ ? await getComponentData(componentName)
+ : null;
+
+ const dockerSnapshot =
+ resolvedComponent === null && componentName && url === null
+ ? await import("@/app/lib/docker-lib").then(({ getDockerInstanceSnapshot }) => getDockerInstanceSnapshot(componentName))
+ : null;
+
+ if (dockerSnapshot !== null) {
+ upsertInstance(dockerSnapshot);
+ }
+
+ const fallbackComponentId =
+ resolvedComponent === null && dockerSnapshot === null && componentName && url === null
+ ? await resolveClientVariable(componentName)
+ : null;
+
+ const componentForCheck =
+ resolvedComponent ??
+ dockerSnapshot ??
+ (typeof fallbackComponentId === "string" && fallbackComponentId.length > 0
+ ? ({
+ name: componentName,
+ componentId: fallbackComponentId,
+ image: "",
+ status: InstanceState.Unknown,
+ type: InstanceType.Docker,
+ } as Instance)
+ : null);
+
+ if (componentForCheck !== null && componentName) {
+ upsertInstance(componentForCheck);
+ }
+
await checkAPI({
- component: componentName
- ? await getComponentData(componentName)
- : null,
+ component: componentForCheck,
headerName,
headerValue,
searchString,
path,
targetStatusCode,
url,
- tlsComponent,
+ tlsComponent: resolvedTlsComponent,
});
setState({ status: true, error: null });
} catch (error) {
- const err = ensureError(error);
- const errorMessage = `The API check failed: ${err.message}`;
+ ensureError(error);
+ const errorMessage = "The API check failed";
setState({ status: false, error: errorMessage });
}
};
@@ -85,23 +134,24 @@ export function APIBase({
return (
{state.status && (
-
+
Status: 200
)}
{state.error && (
-
+
Error: {state.error}
)}
diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts
new file mode 100644
index 0000000..68397f8
--- /dev/null
+++ b/src/lib/certificates.ts
@@ -0,0 +1,22 @@
+import createCert from "create-cert";
+
+type CertificatePair = {
+ cert: string;
+ key: string;
+};
+
+function normalizeCommonName(commonName: string): string {
+ const value = commonName.trim();
+ if (!value) {
+ throw new Error("commonName must not be empty");
+ }
+ if (value.length > 255) {
+ throw new Error("commonName exceeds 255 characters");
+ }
+ return value;
+}
+
+export async function createCertificate(commonName: string, days = 7): Promise {
+ const normalizedCommonName = normalizeCommonName(commonName);
+ return await createCert({ commonName: normalizedCommonName, days });
+}
diff --git a/src/lib/check-api.test.ts b/src/lib/check-api.test.ts
index 480bc89..ea6f34b 100644
--- a/src/lib/check-api.test.ts
+++ b/src/lib/check-api.test.ts
@@ -8,10 +8,6 @@ jest.mock("./variables", () => ({
getVariable: jest.fn(),
}));
-jest.mock("@/app/contexts/instances", () => ({
- isInstanceDocker: jest.fn().mockReturnValue(true),
-}));
-
describe("checkAPI", () => {
beforeEach(() => {
fetchMock.resetMocks();
@@ -80,19 +76,17 @@ describe("checkAPI", () => {
});
it("should use TLS if tlsComponent is true", async () => {
- (getComponentName as jest.Mock).mockResolvedValue("test-component");
- (getVariable as jest.Mock).mockResolvedValue({ host: "localhost", ports: { host: 3000 } });
fetchMock.mockResponseOnce("", { status: 200 });
const result = await checkAPI({
component: {
name: "test-component",
- ports: [{ containerPort: 3030, hostPort: 3000, protocol: "tcp" }]
+ ports: [{ containerPort: 443, hostPort: 3443, protocol: "tcp" }]
} as Instance,
tlsComponent: true
});
expect(result).toBe(true);
- expect(fetchMock).toHaveBeenCalledWith("https://host.docker.internal:3000/", { mode: "cors", cache: "no-store" });
+ expect(fetchMock).toHaveBeenCalledWith("https://host.docker.internal:3443/", { mode: "cors", cache: "no-store" });
});
it("should append the path to the URL", async () => {
@@ -110,4 +104,130 @@ describe("checkAPI", () => {
expect(result).toBe(true);
expect(fetchMock).toHaveBeenCalledWith("http://host.docker.internal:3000/api/test", { mode: "cors", cache: "no-store" });
});
+
+ it("should prefer container port 443 host mapping when tlsComponent is true", async () => {
+ fetchMock.mockResponseOnce("", { status: 200 });
+
+ const result = await checkAPI({
+ component: {
+ name: "test-component",
+ ports: [
+ { containerPort: 80, hostPort: 8080, protocol: "tcp" },
+ { containerPort: 443, hostPort: 8443, protocol: "tcp" },
+ ]
+ } as Instance,
+ tlsComponent: true,
+ });
+
+ expect(result).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith("https://host.docker.internal:8443/", { mode: "cors", cache: "no-store" });
+ });
+
+ it("should fail when tlsComponent is true and no container port 443 mapping exists", async () => {
+ await expect(
+ checkAPI({
+ component: {
+ name: "test-component",
+ ports: [{ containerPort: 80, hostPort: 8080, protocol: "tcp" }],
+ } as Instance,
+ tlsComponent: true,
+ })
+ ).rejects.toThrow("TLS requires container port 443 to be host-mapped or a valid component id for container-network access");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("should use container network endpoint on 443 when tlsComponent is true and 443 is not host-mapped", async () => {
+ fetchMock.mockResponseOnce("", { status: 200 });
+
+ const result = await checkAPI({
+ component: {
+ componentId: "testing-test-component",
+ image: "nginx:latest",
+ name: "test-component",
+ ports: [{ containerPort: 80, hostPort: 8080, protocol: "tcp" }],
+ type: 0,
+ } as Instance,
+ tlsComponent: true,
+ });
+
+ expect(result).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith("https://testing-test-component:443/", { mode: "cors", cache: "no-store" });
+ });
+
+ it("should throw actionable TLS message when fallback endpoint is unreachable", async () => {
+ fetchMock.mockRejectOnce(new Error("fetch failed"));
+
+ await expect(
+ checkAPI({
+ component: {
+ componentId: "testing-test-component",
+ image: "nginx:latest",
+ name: "test-component",
+ ports: [{ containerPort: 80, hostPort: 8080, protocol: "tcp" }],
+ type: 0,
+ } as Instance,
+ tlsComponent: true,
+ })
+ ).rejects.toThrow(
+ "Failed API request: TLS endpoint is not reachable. Map container port 443 to a host port, or ensure the component id is reachable from the app container network"
+ );
+ });
+
+ it("should prefer container port 80 host mapping when tlsComponent is false", async () => {
+ fetchMock.mockResponseOnce("", { status: 200 });
+
+ const result = await checkAPI({
+ component: {
+ name: "test-component",
+ ports: [
+ { containerPort: 443, hostPort: 8443, protocol: "tcp" },
+ { containerPort: 80, hostPort: 8080, protocol: "tcp" },
+ ]
+ } as Instance,
+ tlsComponent: false,
+ });
+
+ expect(result).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith("http://host.docker.internal:8080/", { mode: "cors", cache: "no-store" });
+ });
+
+ it("should fail immediately when host-mapped endpoint returns 403", async () => {
+ fetchMock.mockResponseOnce("forbidden", { status: 403 });
+
+ await expect(
+ checkAPI({
+ component: {
+ componentId: "testing-test-component",
+ image: "nginx:latest",
+ name: "test-component",
+ ports: [{ containerPort: 80, hostPort: 8080, protocol: "tcp" }],
+ type: 0,
+ } as Instance,
+ path: "/nginx_status",
+ tlsComponent: false,
+ })
+ ).rejects.toThrow("Failed API request: HTTP error 403: ");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith("http://host.docker.internal:8080/nginx_status", { mode: "cors", cache: "no-store" });
+ });
+
+ it("should prefer host-mapped endpoint when componentId exists and tlsComponent is false", async () => {
+ fetchMock.mockResponseOnce("ok", { status: 200 });
+
+ const result = await checkAPI({
+ component: {
+ componentId: "testing-test-component",
+ image: "nginx:latest",
+ name: "test-component",
+ ports: [{ containerPort: 80, hostPort: 8080, protocol: "tcp" }],
+ type: 0,
+ } as Instance,
+ path: "/nginx_status",
+ tlsComponent: false,
+ });
+
+ expect(result).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith("http://host.docker.internal:8080/nginx_status", { mode: "cors", cache: "no-store" });
+ });
});
\ No newline at end of file
diff --git a/src/lib/check-api.ts b/src/lib/check-api.ts
index bcaf564..5a06da1 100644
--- a/src/lib/check-api.ts
+++ b/src/lib/check-api.ts
@@ -1,8 +1,53 @@
"use server"
-import { isInstanceDocker } from "@/app/contexts/instances";
-import { Instance } from "./types";
+import { Instance, InstanceDocker, InstanceType } from "./types";
import { ensureError } from "./utils";
+function isDockerInstance(component: Instance): component is InstanceDocker {
+ return (
+ component.type === InstanceType.Docker ||
+ "image" in component ||
+ "ports" in component
+ );
+}
+
+function hasDockerHostPort(component: Instance): component is InstanceDocker {
+ return (
+ "ports" in component &&
+ Array.isArray(component.ports) &&
+ component.ports.some((port) => port.hostPort !== undefined)
+ );
+}
+
+function getPreferredHostPort(component: InstanceDocker, tls: boolean): number | undefined {
+ const preferredContainerPort = tls ? 443 : 80;
+ const preferredPort = component.ports?.find(
+ (port) => port.containerPort === preferredContainerPort && port.hostPort !== undefined
+ );
+
+ if (preferredPort?.hostPort !== undefined) {
+ return preferredPort.hostPort;
+ }
+
+ if (tls) {
+ return undefined;
+ }
+
+ return component.ports?.find((port) => port.hostPort !== undefined)?.hostPort;
+}
+
+function getPreferredContainerPort(component: InstanceDocker, tls: boolean): number | undefined {
+ const preferredContainerPort = tls ? 443 : 80;
+ const preferredPort = component.ports?.find(
+ (port) => port.containerPort === preferredContainerPort
+ );
+
+ if (preferredPort?.containerPort !== undefined) {
+ return preferredPort.containerPort;
+ }
+
+ return component.ports?.find((port) => port.containerPort !== undefined)?.containerPort;
+}
+
/**
* Retrieves the URL for a given component.
*
@@ -11,15 +56,45 @@ import { ensureError } from "./utils";
* @param {string} component - The component to check.
* @param {boolean} tls - A True value will use TLS (https) to connect to the component. A False value is no TLS (http).
* @returns {Promise} - The full URL of the component.
- * @throws {Error} - Throws an error if petname or component data is missing or invalid.
+ * @throws {Error} - Throws an error if component data is missing or invalid.
*/
async function getComponentUrl(component: Instance, tls: boolean) {
const protocol = tls ? "https" : "http";
if (!component) throw new Error("Component data is missing or invalid");
- if (isInstanceDocker(component) && component.ports && component.ports.length > 0) {
- return `${protocol}://host.docker.internal:${component.ports[0].hostPort}`;
+ if (tls && isDockerInstance(component)) {
+ const tlsHostPort = component.ports?.find(
+ (port) => port.containerPort === 443 && port.hostPort !== undefined
+ )?.hostPort;
+
+ if (tlsHostPort === undefined) {
+ // If 443 is not host-mapped, use container network DNS when a component id exists.
+ if (typeof component.componentId === "string" && component.componentId.length > 0) {
+ return `${protocol}://${component.componentId}:443`;
+ }
+
+ throw new Error("TLS requires container port 443 to be host-mapped or a valid component id for container-network access");
+ }
+
+ return `${protocol}://host.docker.internal:${tlsHostPort}`;
+ }
+
+ if (hasDockerHostPort(component)) {
+ const hostPort = getPreferredHostPort(component, tls);
+ if (hostPort !== undefined) {
+ return `${protocol}://host.docker.internal:${hostPort}`;
+ }
+ }
+
+ if (isDockerInstance(component)) {
+ const hasComponentId = typeof component.componentId === "string" && component.componentId.length > 0;
+ if (hasComponentId) {
+ const containerPort = getPreferredContainerPort(component, tls);
+ if (containerPort !== undefined) {
+ return `${protocol}://${component.componentId}:${containerPort}`;
+ }
+ }
}
return `${protocol}://${component?.componentId}`;
@@ -82,7 +157,8 @@ export async function checkAPI({
try {
console.log(`Calling API Check at: ${url}`)
// @ts-expect-error TS2769
- const response = await fetch(url, { mode: "cors", cache: "no-store" }); // url will never be null here. adding a conditional would cause unreachable code here.
+ let response = await fetch(url, { mode: "cors", cache: "no-store" }); // url will never be null here. adding a conditional would cause unreachable code here.
+
if (response.status != targetStatusCode) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
@@ -103,6 +179,17 @@ export async function checkAPI({
return true;
} catch (error) {
+ if (
+ tlsComponent &&
+ component !== null &&
+ isDockerInstance(component) &&
+ component.ports?.some((port) => port.containerPort === 443 && port.hostPort !== undefined) !== true
+ ) {
+ throw new Error(
+ "Failed API request: TLS endpoint is not reachable. Map container port 443 to a host port, or ensure the component id is reachable from the app container network"
+ );
+ }
+
const err = ensureError(error)
throw new Error(`Failed API request: ${err.message}`);
}
diff --git a/src/lib/client-variables.ts b/src/lib/client-variables.ts
index 949c4ab..951d1f9 100644
--- a/src/lib/client-variables.ts
+++ b/src/lib/client-variables.ts
@@ -3,9 +3,11 @@
import useLocalStorage from "@/app/lib/use-local-storage";
import { Instance } from "@/lib/types";
+export const LOCAL_INSTANCES_STORAGE_KEY = "LAB_INSTANCES";
+
export function useInstances(): [Instance[], React.Dispatch>] {
return useLocalStorage(
- "instances",
+ LOCAL_INSTANCES_STORAGE_KEY,
[]
);
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 18d5003..8da0460 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,5 +1,3 @@
-export const REDIS_CACHE_SECONDS = 60;
-export const REDIS_URL = "redis://redis:6379";
export const UDF_DEPLOYMENT_API_URL = "http://metadata.udf/deployment";
-export const PETNAME_API_URL = "http://host.docker.internal:5123/petname";
+export const DEPLOYMENT_IDENTIFIER_API_URL = "http://host.docker.internal:5123/petname";
export const LABINFO_API_URL = "http://host.docker.internal:5123/labinfo";
diff --git a/src/lib/deployment-identifier-action.test.ts b/src/lib/deployment-identifier-action.test.ts
new file mode 100644
index 0000000..a2d0472
--- /dev/null
+++ b/src/lib/deployment-identifier-action.test.ts
@@ -0,0 +1,50 @@
+import { fetchDeploymentIdentifierServer } from "./deployment-identifier-action";
+
+const originalEnv = { ...process.env };
+
+describe("fetchDeploymentIdentifierServer", () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it("returns petname from the service payload by default", async () => {
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ petname: "dep-123" }),
+ });
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await expect(fetchDeploymentIdentifierServer()).resolves.toBe("dep-123");
+ expect(fetchMock).toHaveBeenCalledWith(
+ "http://host.docker.internal:5123/petname",
+ { cache: "no-store" }
+ );
+ });
+
+ it("honors an explicit response field override", async () => {
+ process.env.DEPLOYMENT_IDENTIFIER_RESPONSE_FIELD = "petname";
+
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ petname: "dep-456" }),
+ });
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await expect(fetchDeploymentIdentifierServer()).resolves.toBe("dep-456");
+ });
+
+ it("returns null when the expected field is missing", async () => {
+ const fetchMock = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ otherField: "dep-789" }),
+ });
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await expect(fetchDeploymentIdentifierServer()).resolves.toBeNull();
+ });
+});
diff --git a/src/lib/deployment-identifier-action.ts b/src/lib/deployment-identifier-action.ts
new file mode 100644
index 0000000..a517f4a
--- /dev/null
+++ b/src/lib/deployment-identifier-action.ts
@@ -0,0 +1,27 @@
+"use server";
+
+import { DEPLOYMENT_IDENTIFIER_API_URL } from "./constants";
+import { DeploymentIdentifier } from "./types";
+
+function getDeploymentIdentifierResponseField(): string {
+ return process.env.DEPLOYMENT_IDENTIFIER_RESPONSE_FIELD?.trim() || "petname";
+}
+
+export async function fetchDeploymentIdentifierServer(): Promise {
+ try {
+ const response = await fetch(DEPLOYMENT_IDENTIFIER_API_URL, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to retrieve deployment identifier from ${DEPLOYMENT_IDENTIFIER_API_URL}`
+ );
+ }
+
+ const payload = (await response.json()) as Record;
+ const value = payload[getDeploymentIdentifierResponseField()];
+
+ return typeof value === "string" ? value : null;
+ } catch (error) {
+ console.error("Error fetching deployment identifier:", error);
+ return null;
+ }
+}
diff --git a/src/lib/mdxComponents.tsx b/src/lib/mdxComponents.tsx
index 14e182a..defa263 100644
--- a/src/lib/mdxComponents.tsx
+++ b/src/lib/mdxComponents.tsx
@@ -2,21 +2,20 @@ import React from "react";
import { APICheck } from "@/app/components/api-check";
import { APIHeaderCheck } from "@/app/components/api-header-check";
import { APIResponseCheck } from "@/app/components/api-response-check";
-// import { CodeBlock } from "@/app/components/codeblock";
-import { DockerInstance } from "@/app/components/docker";
-// import { GetVariable } from "@/app/components/get-variable";
-// import { InputVariable } from "@/app/components/input-var";
-// import { SetVariable } from "@/app/components/set-variable";
-// import UDFComponent from "@/app/components/udf-component";
-// import { UdfDeploymentMetadata } from "@/app/components/udf-deployment-metadata";
+import { CodeBlock } from "@/app/components/codeblock";
+import { Docker, DockerInstance } from "@/app/components/docker";
+import { DockerContainer } from "@/app/components/docker-container";
+import { GetVariable } from "@/app/components/get-variable";
+import ImageModalClient from "@/app/components/image-modal-client";
+import { InputVariable } from "@/app/components/input-var";
+import { SetVariable } from "@/app/components/set-variable";
+import { CreateCertificate } from "@/app/components/create-certificate";
+import UDFComponent from "@/app/components/udf-component";
+import { UdfDeploymentMetadata } from "@/app/components/udf-deployment-metadata";
// import { Collapsible } from "@/app/components/collapsible";
// import DockerContainer from "@/app/components/docker-container";
import { APICheckContainer } from "@/app/components/api-check-container";
-// import dynamic from 'next/dynamic';
-// import { CreateCertificate } from "@/app/components/create-certificate";
-
-// const ImageModalClient = dynamic(() => import('@/app/components/image-modal-client'), { ssr: false });
// type MDXComponentProps = {
// children?: React.ReactNode;
@@ -24,21 +23,21 @@ import { APICheckContainer } from "@/app/components/api-check-container";
// };
type MDXComponentProps = React.JSX.IntrinsicAttributes & React.ClassAttributes & React.HTMLAttributes
-
const MDXComponents = {
APICheck,
APICheckContainer,
APIHeaderCheck,
APIResponseCheck,
- // CodeBlock,
- // CreateCertificate,
+ CodeBlock,
+ CreateCertificate,
+ Docker,
DockerInstance,
- // DockerContainer,
- // GetVariable,
- // InputVariable,
- // UDFComponent,
- // UdfDeploymentMetadata,
- // SetVariable,
+ DockerContainer,
+ GetVariable,
+ InputVariable,
+ SetVariable,
+ UDFComponent,
+ UdfDeploymentMetadata,
// Collapsible,
h1: (props: MDXComponentProps) => (
@@ -52,18 +51,21 @@ const MDXComponents = {
h4: (props: MDXComponentProps) => (
),
- // pre: ({ children }: { children: React.ReactNode }) => (
- //
- // {React.Children.map(children, child => {
- // if (React.isValidElement(child)) {
- // return React.cloneElement(child as React.ReactElement, { isBlock: true });
- // }
- // return child;
- // })}
- //
- // ),
- // code: CodeBlock,
- // img: ImageModalClient
+ pre: ({ children }: { children: React.ReactNode }) => (
+ <>
+ {React.Children.map(children, (child) => {
+ if (React.isValidElement(child)) {
+ return React.cloneElement(
+ child as React.ReactElement<{ isBlock?: boolean }>,
+ { isBlock: true }
+ );
+ }
+ return child;
+ })}
+ >
+ ),
+ code: CodeBlock,
+ img: ImageModalClient,
};
export default MDXComponents;
diff --git a/src/lib/mdxUtils.ts b/src/lib/mdxUtils.ts
index b7a926b..18e2e44 100644
--- a/src/lib/mdxUtils.ts
+++ b/src/lib/mdxUtils.ts
@@ -8,14 +8,176 @@ import { getVariable, getEnvVariable } from "./variables"
import MDXComponents from "@/lib/mdxComponents"
import matter from "gray-matter";
import { delay } from "@/lib/utils"
-import { Document, DocumentData, Frontmatter, GitHubFile } from "./types";
+import { Document, DocumentData, Frontmatter, GitHubFile, MdxFrontmatter } from "./types";
import { PluggableList } from "unified";
+type RemoteDocumentCacheStore = typeof globalThis & {
+ __remoteDocumentCache?: Map;
+ __remoteDocumentInflight?: Map>;
+ __remoteDocsIndexCache?: Map;
+ __remoteDocsIndexInflight?: Map>;
+};
+
+function getRemoteDocumentCacheStore(): Required> {
+ const store = globalThis as RemoteDocumentCacheStore;
+ store.__remoteDocumentCache ??= new Map();
+ store.__remoteDocumentInflight ??= new Map>();
+ return {
+ __remoteDocumentCache: store.__remoteDocumentCache,
+ __remoteDocumentInflight: store.__remoteDocumentInflight,
+ };
+}
+
+function getRemoteDocsIndexCacheStore(): Required> {
+ const store = globalThis as RemoteDocumentCacheStore;
+ store.__remoteDocsIndexCache ??= new Map();
+ store.__remoteDocsIndexInflight ??= new Map>();
+ return {
+ __remoteDocsIndexCache: store.__remoteDocsIndexCache,
+ __remoteDocsIndexInflight: store.__remoteDocsIndexInflight,
+ };
+}
+
+function parseDockerArrayExpression(
+ expression: string,
+ keyParsers: Record,
+ booleanKeys: Set = new Set()
+): Array> | null {
+ // Support one level of nested braces for template literal expressions like ${process.env["PETNAME"]}.
+ const objectMatches = expression.match(/\{(?:[^{}]|\{[^{}]*\})*\}/g);
+ if (!objectMatches || objectMatches.length === 0) {
+ return null;
+ }
+
+ const parsed = objectMatches.map((entry) => {
+ const parsedEntry: Record = {};
+
+ for (const [key, matcher] of Object.entries(keyParsers)) {
+ const match = entry.match(matcher);
+ if (!match) {
+ continue;
+ }
+
+ const capturedValue = match.slice(1).find((group) => group !== undefined);
+ if (capturedValue === undefined) {
+ continue;
+ }
+
+ if (
+ booleanKeys.has(key) &&
+ (capturedValue === "true" || capturedValue === "false")
+ ) {
+ parsedEntry[key] = capturedValue === "true";
+ } else {
+ parsedEntry[key] = capturedValue;
+ }
+ }
+
+ if (typeof parsedEntry.name !== "string" || parsedEntry.name.length === 0) {
+ return null;
+ }
+
+ return parsedEntry;
+ });
+
+ if (parsed.some((entry) => entry === null)) {
+ return null;
+ }
+
+ return parsed as Array>;
+}
+
+function parseDockerAttrsArrayExpression(expression: string): Array> | null {
+ const attrPattern = /\{\s*name\s*:\s*"([^"]+)"\s*,\s*value\s*:\s*(?:"([^"]*)"|`([^`]*)`)\s*\}/g;
+ const parsed: Array> = [];
+ let match: RegExpExecArray | null = null;
+
+ while ((match = attrPattern.exec(expression)) !== null) {
+ const name = match[1];
+ const value = match[2] ?? match[3] ?? "";
+ if (!name) {
+ continue;
+ }
+ parsed.push({ name, value });
+ }
+
+ return parsed.length > 0 ? parsed : null;
+}
+
+function normalizeDockerMdxProps(source: string): string {
+ // Match env/attrs array props only when the closing ]} is followed by another prop or tag close.
+ const dockerArrayPropPattern = (propName: string) => new RegExp(
+ `${propName}=\\{\\[([\\s\\S]*?)\\]\\}(?=\\s+[a-zA-Z_][\\w-]*=|\\s*\\/?>)`,
+ "g"
+ );
+
+ let normalizedSource = source.replace(dockerArrayPropPattern("env"), (fullMatch, envBody: string) => {
+ const parsed = parseDockerArrayExpression(envBody, {
+ name: /name\s*:\s*"([^"]+)"/,
+ value: /value\s*:\s*(?:"([^"]*)"|`([^`]*)`)/,
+ isVariable: /isVariable\s*:\s*(true|false)/,
+ isSecret: /isSecret\s*:\s*(true|false)/,
+ }, new Set(["isVariable", "isSecret"]));
+ if (!parsed) {
+ return fullMatch;
+ }
+
+ const encoded = JSON.stringify(parsed).replace(/"/g, """);
+ return `env=\"${encoded}\"`;
+ });
+
+ normalizedSource = normalizedSource.replace(dockerArrayPropPattern("attrs"), (fullMatch, attrsBody: string) => {
+ const parsed = parseDockerAttrsArrayExpression(attrsBody);
+ if (!parsed) {
+ return fullMatch;
+ }
+
+ const encoded = JSON.stringify(parsed).replace(/"/g, """);
+ return `attrs=\"${encoded}\"`;
+ });
+
+ return normalizedSource;
+}
+
+function normalizeApiCheckTlsProps(source: string): string {
+ const apiCheckTagPattern = /<(APICheck|APIHeaderCheck|APIResponseCheck)([\s\S]*?)\/>/g;
+
+ return source.replace(apiCheckTagPattern, (fullMatch, componentName: string, rawProps: string) => {
+ const normalizedProps = rawProps
+ .replace(/\stlsComponent\s*=\s*\{\s*true\s*\}/gi, " tlsComponent=\"true\"")
+ .replace(/\stlsComponent\s*=\s*\"true\"/gi, " tlsComponent=\"true\"");
+
+ return `<${componentName}${normalizedProps}/>`;
+ });
+}
+
export const LOCAL_DOCS_PATH = path.join(process.cwd(), "src/app/docs");
+function normalizeMdxFrontmatter(frontmatter: unknown): MdxFrontmatter {
+ const raw = frontmatter && typeof frontmatter === "object" && !Array.isArray(frontmatter)
+ ? frontmatter as Record
+ : {};
+
+ return {
+ ...raw,
+ title: typeof raw.title === "string" ? raw.title : undefined,
+ description: typeof raw.description === "string" ? raw.description : undefined,
+ order: typeof raw.order === "number" ? raw.order : undefined,
+ };
+}
+
/**
* Returns all document-loading related settings.
- * @returns {Promise} object containing named constants.
+ * @returns {Promise<{
+ * remoteDocsRepoServer: string | null;
+ * remoteDocsRepoApiServer: string | null;
+ * remoteDocsRepoOwner: string | null;
+ * remoteDocsRepoName: string | null;
+ * remoteDocsRepoBranch: string | null;
+ * remoteDocsRepoMediaPath: string;
+ * remoteDocsRepoPath: string | null;
+ * remoteDocsRepoCacheSeconds: number;
+ * }>} Object containing named constants.
*/
async function getDocsSettings() {
// these values need to be pulled from ENV, to avoid remote code execution
@@ -34,7 +196,7 @@ async function getDocsSettings() {
* Compiles MD(X) document content.
* @param {string} documentName - The file name of the document to compile.
*
- * @returns {Promise>>} the compiled MDX.
+ * @returns {Promise>} the compiled MDX.
* @throws {Error} If the the document cannot be compiled.
*/
export async function getMdxContent(documentName: string) {
@@ -48,10 +210,10 @@ export async function getMdxContent(documentName: string) {
if (docsSettings.remoteDocsRepoServer) remarkPlugins.push([imgLinks, { absolutePath: `${docsSettings.remoteDocsRepoServer}/${docsSettings.remoteDocsRepoOwner}/${docsSettings.remoteDocsRepoName}/${docsSettings.remoteDocsRepoBranch}/${docsSettings.remoteDocsRepoMediaPath}` }])
const documentUrl = `${docsSettings.remoteDocsRepoServer}/${docsSettings.remoteDocsRepoOwner}/${docsSettings.remoteDocsRepoName}/${docsSettings.remoteDocsRepoBranch}/${docsSettings.remoteDocsRepoPath}/${documentName}`
- if (docsSettings.remoteDocsRepoServer) console.log(`Fetching remote document: ${documentUrl}, caching for ${docsSettings.remoteDocsRepoCacheSeconds} seconds.`)
const sourceDocument = docsSettings.remoteDocsRepoServer ? await getRemoteDocument(documentUrl, docsSettings.remoteDocsRepoCacheSeconds) : await getLocalDocument(documentName)
- return await compileMDX({
- source: sourceDocument,
+ const normalizedSourceDocument = normalizeApiCheckTlsProps(normalizeDockerMdxProps(sourceDocument))
+ const compiled = await compileMDX({
+ source: normalizedSourceDocument,
components: MDXComponents,
options: {
mdxOptions: {
@@ -63,6 +225,11 @@ export async function getMdxContent(documentName: string) {
},
},
})
+
+ return {
+ ...compiled,
+ frontmatter: normalizeMdxFrontmatter(compiled.frontmatter),
+ };
}
@@ -87,6 +254,12 @@ async function getLocalDocument(documentName: string) {
* @throws {Error} If the document cannot be fetched.
*/
async function getRemoteDocument(url: string, cacheSeconds: number) {
+ const { __remoteDocumentCache: remoteDocumentCache, __remoteDocumentInflight: remoteDocumentInflight } = getRemoteDocumentCacheStore();
+
+ const inflightDocument = remoteDocumentInflight.get(url);
+ if (inflightDocument) {
+ return inflightDocument;
+ }
const options = {
method: "GET",
@@ -99,19 +272,38 @@ async function getRemoteDocument(url: string, cacheSeconds: number) {
revalidate: cacheSeconds
}
}
- const res = await fetch(url, options)
+ const remoteDocumentPromise = (async () => {
+ const res = await fetch(url, options)
- if (!res.ok) {
- // This will activate the closest `error.js` Error Boundary
- throw new Error("Failed to fetch data")
- }
- return res.text()
+ if (!res.ok) {
+ const cachedDocument = remoteDocumentCache.get(url);
+ if (cachedDocument !== undefined) {
+ return cachedDocument;
+ }
+ return `# Remote Document Unavailable\n\nUnable to fetch ${url}.`;
+ }
+
+ const documentText = await res.text()
+ remoteDocumentCache.set(url, documentText);
+ return documentText
+ })().catch((error) => {
+ const cachedDocument = remoteDocumentCache.get(url);
+ if (cachedDocument !== undefined) {
+ return cachedDocument;
+ }
+ return `# Remote Document Unavailable\n\n${error instanceof Error ? error.message : "Unable to fetch remote document."}`;
+ }).finally(() => {
+ remoteDocumentInflight.delete(url);
+ });
+
+ remoteDocumentInflight.set(url, remoteDocumentPromise);
+ return remoteDocumentPromise;
}
/**
* Gets a list of MD(X) documents from a local or remote source depending on whether a repo is specified in the env vars.
- * @returns {Promise} array of document data sorted by order metadata in frontmatter
+ * @returns {Promise} Array of document data sorted by order metadata in frontmatter.
*/
export async function getIndexDocs() {
if (await getEnvVariable("SIMULATE_LOAD_DELAY")) await delay(5000);
@@ -122,9 +314,10 @@ export async function getIndexDocs() {
/**
* Gets a list of MD(X) documents from a remote source, GitHub.
* @param {number} cacheSeconds - The number of seconds to cache the fetched content in nextjs.
- * @returns {Promise} array of document data sorted by order metadata in frontmatter
+ * @returns {Promise} Array of document data sorted by order metadata in frontmatter.
*/
async function getGitHubDocs(cacheSeconds: number) {
+ const { __remoteDocsIndexCache: remoteDocsIndexCache, __remoteDocsIndexInflight: remoteDocsIndexInflight } = getRemoteDocsIndexCacheStore();
const docsSettings = await getDocsSettings();
@@ -141,31 +334,53 @@ async function getGitHubDocs(cacheSeconds: number) {
revalidate: cacheSeconds
}
}
- console.log(`Fetching remote index: ${url}, caching for ${docsSettings.remoteDocsRepoCacheSeconds} seconds.`)
- const res = await fetch(url, fetchOptions)
-
- if (!res.ok) {
- // This will activate the closest `error.js` Error Boundary
- throw new Error("Failed to fetch data")
+ const inflightDocs = remoteDocsIndexInflight.get(url);
+ if (inflightDocs) {
+ return inflightDocs;
}
- const body = await res.json()
+ const remoteDocsPromise = (async () => {
+ const res = await fetch(url, fetchOptions)
- const files: GitHubFile[] = body
- .filter((file: GitHubFile) => /\.mdx?$/.test(file.name) && file.type === "file")
- .map((file: GitHubFile) => ({ name: file.name, url: file.url }));
+ if (!res.ok) {
+ const cachedDocs = remoteDocsIndexCache.get(url);
+ if (cachedDocs !== undefined) {
+ return cachedDocs;
+ }
+ return [];
+ }
- const docs = await Promise.all(files.map(async (file: GitHubFile) => ({ name: file.name, location: file.url, documentData: await getGitHubFileContent(file.url, fetchOptions) })
- )
- );
- return sortDocumentsByOrder(docs)
+ const body = await res.json()
+
+ const files: GitHubFile[] = body
+ .filter((file: GitHubFile) => /\.mdx?$/.test(file.name) && file.type === "file")
+ .map((file: GitHubFile) => ({ name: file.name, url: file.url }));
+
+ const docs = await Promise.all(files.map(async (file: GitHubFile) => ({ name: file.name, location: file.url, documentData: await getGitHubFileContent(file.url, fetchOptions) })
+ )
+ );
+ const sortedDocs = sortDocumentsByOrder(docs)
+ remoteDocsIndexCache.set(url, sortedDocs);
+ return sortedDocs
+ })().catch(() => {
+ const cachedDocs = remoteDocsIndexCache.get(url);
+ if (cachedDocs !== undefined) {
+ return cachedDocs;
+ }
+ return [];
+ }).finally(() => {
+ remoteDocsIndexInflight.delete(url);
+ });
+
+ remoteDocsIndexInflight.set(url, remoteDocsPromise);
+ return remoteDocsPromise;
}
/**
* Fetches document from GitHub using API to return its frontmatter.
* @param {string} url to fetch the document from.
* @param {RequestInit} options to be used by the fetch operation.
- * @returns {Promise} The document content and frontmatter.
+ * @returns {Promise} The document content and frontmatter.
*/
async function getGitHubFileContent(url: string, options: RequestInit): Promise {
const response = await fetch(url, options)
@@ -177,7 +392,7 @@ async function getGitHubFileContent(url: string, options: RequestInit): Promise<
/**
* Gets a list of MD(X) documents from the local file system.
* @param {string} docsPath the file system path to scan for MD(X) documents.
- * @returns {Promise} array of document data sorted by order metadata in frontmatter
+ * @returns {Promise} Array of document data sorted by order metadata in frontmatter.
*/
async function getLocalDocs(docsPath: string) {
diff --git a/src/lib/object-utils.test.ts b/src/lib/object-utils.test.ts
new file mode 100644
index 0000000..22d8990
--- /dev/null
+++ b/src/lib/object-utils.test.ts
@@ -0,0 +1,22 @@
+import { findValueByKey } from "./object-utils";
+
+describe("object-utils", () => {
+ describe("findValueByKey", () => {
+ it("returns a top-level value", () => {
+ expect(findValueByKey({ a: "x" }, "a")).toBe("x");
+ });
+
+ it("returns a nested value", () => {
+ const data = { a: { b: { c: 3 } } };
+ expect(findValueByKey(data, "c")).toBe(3);
+ });
+
+ it("returns null for missing key", () => {
+ expect(findValueByKey({ a: 1 }, "z")).toBeNull();
+ });
+
+ it("returns null for non-object input", () => {
+ expect(findValueByKey("bad" as unknown as Record, "a")).toBeNull();
+ });
+ });
+});
diff --git a/src/lib/object-utils.ts b/src/lib/object-utils.ts
new file mode 100644
index 0000000..74aa05a
--- /dev/null
+++ b/src/lib/object-utils.ts
@@ -0,0 +1,20 @@
+export function findValueByKey(obj: Record, key: string): unknown | null {
+ if (obj == null || typeof obj !== "object") {
+ return null;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ return obj[key];
+ }
+
+ for (const nestedKey in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, nestedKey)) {
+ const result = findValueByKey(obj[nestedKey] as Record, key);
+ if (result !== null) {
+ return result;
+ }
+ }
+ }
+
+ return null;
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index d6ba8a5..225d2a4 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -1,7 +1,7 @@
/**
- * Represents a pet name which can be a string or null.
+ * Generic deployment identifier fragment used in generated resource names.
*/
-export type Petname = string | null;
+export type DeploymentIdentifier = string | null;
/**
* Information about a component.
@@ -53,6 +53,17 @@ export type Frontmatter = {
metadata: Record;
}
+/**
+ * Typed frontmatter returned from MDX compilation.
+ * Additional keys are allowed, but common display fields are typed.
+ */
+export type MdxFrontmatter = {
+ title?: string;
+ description?: string;
+ order?: number;
+ [key: string]: unknown;
+}
+
/**
* Represents a file on GitHub.
* @property {string} name - The name of the file.
@@ -123,6 +134,7 @@ interface InstanceBase {
name: string;
componentId?: string;
status?: InstanceState;
+ stoppedByUser?: boolean;
}
/**
@@ -132,13 +144,18 @@ interface InstanceBase {
* @property {boolean} [isVariable] - Whether it is a variable.
* @property {boolean} [isSecret] - Whether it is a secret.
*/
-interface InstanceDockerEnv {
+export interface InstanceDockerEnv {
name: string;
value?: string;
isVariable?: boolean;
isSecret?: boolean;
}
+export interface DockerAttribute {
+ name: string;
+ value: string;
+}
+
/**
* Interface for Docker ports.
* @property {number} containerPort - The port inside the container.
@@ -168,6 +185,7 @@ export interface InstanceDocker extends InstanceBase {
image: string;
ports?: DockerPortMapping[];
env?: InstanceDockerEnv[];
+ attrs?: DockerAttribute[];
}
/**
diff --git a/src/lib/udf-action.ts b/src/lib/udf-action.ts
new file mode 100644
index 0000000..6a6ccd6
--- /dev/null
+++ b/src/lib/udf-action.ts
@@ -0,0 +1,62 @@
+"use server";
+
+import { LABINFO_API_URL, UDF_DEPLOYMENT_API_URL } from "./constants";
+import { findValueByKey } from "./object-utils";
+
+async function fetchInfo(url: string, variableName: string): Promise {
+ try {
+ const response = await fetch(url, { mode: "cors", cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const data = await response.json();
+ return findValueByKey(data, variableName) as T | null;
+ } catch (error) {
+ console.error("Error fetching lab info:", error);
+ return null;
+ }
+}
+
+export async function fetchLabInfoServer(variableName: string): Promise {
+ return await fetchInfo(LABINFO_API_URL, variableName);
+}
+
+export async function fetchUDFInfoServer(variableName: string): Promise {
+ return await fetchInfo(UDF_DEPLOYMENT_API_URL, variableName);
+}
+
+export async function fetchUdfComponentWebShellServer(componentName: string | null = null): Promise {
+ if (componentName === null) {
+ console.error("fetchUdfComponentWebShell Error: name must be defined");
+ return null;
+ }
+
+ const response = await fetch(UDF_DEPLOYMENT_API_URL, { mode: "cors" });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const jsonResponse = await response.json();
+ const components = jsonResponse.deployment.components;
+
+ let host = "";
+
+ for (const component of components) {
+ if (component.name === componentName) {
+ const httpsMethods = component.accessMethods.https;
+ for (const method of httpsMethods) {
+ if (method.label === "Web Shell") {
+ host = method.host;
+ }
+ }
+ }
+ }
+
+ if (host === "") {
+ console.error(`Web Shell host not found for ${componentName}`);
+ return null;
+ }
+
+ return `https://${host}`;
+}
diff --git a/src/lib/udf.test.ts b/src/lib/udf.test.ts
index 534e242..2ebfd4f 100644
--- a/src/lib/udf.test.ts
+++ b/src/lib/udf.test.ts
@@ -5,25 +5,34 @@ import {
fetchUdfComponentWebShell,
} from "@/lib/udf";
-import fetchMock from "jest-fetch-mock";
+import {
+ fetchLabInfoServer,
+ fetchUDFInfoServer,
+ fetchUdfComponentWebShellServer,
+} from "@/lib/udf-action";
+
+jest.mock("@/lib/udf-action", () => ({
+ fetchLabInfoServer: jest.fn(),
+ fetchUDFInfoServer: jest.fn(),
+ fetchUdfComponentWebShellServer: jest.fn(),
+}));
describe("udf", () => {
beforeEach(() => {
- fetchMock.resetMocks();
+ jest.clearAllMocks();
});
describe("fetchLabInfo", () => {
it("should fetch and return the lab info", async () => {
- const mockResponse = { key: "value" };
- fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
+ (fetchLabInfoServer as jest.Mock).mockResolvedValue("value");
const result = await fetchLabInfo("key");
expect(result).toBe("value");
});
it("should return null on bad http fetch response", async () => {
- fetchMock.mockRejectOnce(new Error("HTTP error message"));
+ (fetchLabInfoServer as jest.Mock).mockResolvedValue(null);
const result = await fetchLabInfo("key");
expect(result).toBeNull();
@@ -33,18 +42,14 @@ describe("udf", () => {
describe("fetchUDFInfo", () => {
it("should fetch and return the UDF info", async () => {
- const mockResponse = { key: "value" };
- fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
+ (fetchUDFInfoServer as jest.Mock).mockResolvedValue("value");
const result = await fetchUDFInfo("key");
expect(result).toBe("value");
});
it("should return null on bad http fetch response", async () => {
- fetchMock.mockResponseOnce("{}", {
- status: 500,
- headers: { "content-type": "application/json" },
- });
+ (fetchUDFInfoServer as jest.Mock).mockResolvedValue(null);
const result = await fetchUDFInfo("key");
expect(result).toBeNull();
@@ -53,57 +58,29 @@ describe("udf", () => {
describe("fetchUdfComponentWebShell", () => {
it("should fetch and return the Web Shell URL", async () => {
- const mockResponse = {
- deployment: {
- components: [
- {
- name: "componentName",
- accessMethods: {
- https: [{ label: "Web Shell", host: "example.com" }],
- },
- },
- ],
- },
- };
- fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
+ (fetchUdfComponentWebShellServer as jest.Mock).mockResolvedValue("https://example.com");
const result = await fetchUdfComponentWebShell("componentName");
expect(result).toBe("https://example.com");
});
it("should return null if the component name is null", async () => {
+ (fetchUdfComponentWebShellServer as jest.Mock).mockResolvedValue(null);
const result = await fetchUdfComponentWebShell(null);
expect(result).toBeNull();
});
it("should return null if the Web Shell host is not found", async () => {
- const mockResponse = {
- deployment: {
- components: [
- {
- name: "componentName",
- accessMethods: {
- https: [{ label: "Other", host: "example.com" }],
- },
- },
- ],
- },
- };
- fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
+ (fetchUdfComponentWebShellServer as jest.Mock).mockResolvedValue(null);
const result = await fetchUdfComponentWebShell("componentName");
expect(result).toBeNull();
});
it("should return null on bad http fetch response", async () => {
- fetchMock.mockResponseOnce("{}", {
- status: 500,
- headers: { "content-type": "application/json" },
- });
-
- expect(async () => {
- await fetchUdfComponentWebShell("key");
- }).rejects.toThrow(new Error("HTTP error! status: 500"));
+ (fetchUdfComponentWebShellServer as jest.Mock).mockRejectedValue(new Error("HTTP error! status: 500"));
+
+ await expect(fetchUdfComponentWebShell("key")).rejects.toThrow(new Error("HTTP error! status: 500"));
});
});
diff --git a/src/lib/udf.ts b/src/lib/udf.ts
index 8f8f4fa..9512736 100644
--- a/src/lib/udf.ts
+++ b/src/lib/udf.ts
@@ -3,7 +3,12 @@
* The fetch methods will obtain the requested variable from the desired api
*/
-import { LABINFO_API_URL, UDF_DEPLOYMENT_API_URL } from "./constants";
+import {
+ fetchLabInfoServer,
+ fetchUDFInfoServer,
+ fetchUdfComponentWebShellServer,
+} from "./udf-action";
+import { findValueByKey as findNestedValueByKey } from "./object-utils";
/**
* Recursively searches a JSON object for a key that matches the specified key and returns its value.
@@ -25,58 +30,24 @@ import { LABINFO_API_URL, UDF_DEPLOYMENT_API_URL } from "./constants";
* const value = findValueByKey(data, "e"); // returns 3
*/
export function findValueByKey(obj: Record, key: string): string | null {
- if (obj == null || typeof obj !== "object") {
- return null;
- }
-
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
- const value = obj[key];
- return typeof value === "string" ? value : null;
- }
-
- for (const k in obj) {
- if (Object.prototype.hasOwnProperty.call(obj, k)) {
- const result = findValueByKey(obj[k] as Record, key);
- if (result !== null) {
- return result;
- }
- }
- }
-
- return null;
+ const value = findNestedValueByKey(obj, key);
+ return typeof value === "string" ? value : null;
}
/**
* Pull metadata from the labinfo API
*
* @param {string} variableName - The name of the variable to look up
- * @returns {Promise} - The value of the variable from the API response, or null if not found
+ * @returns {Promise} - The value of the variable from the API response, or null if not found
*/
-async function fetchInfo(url: string, variableName: string): Promise {
- try {
- const response = await fetch(url, { mode: "cors", cache: "no-store" });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
-
- // Search for the variableName in the response body
- const variableValue = findValueByKey(data, variableName);
- return variableValue;
- } catch (error) {
- console.error("Error fetching lab info:", error);
- return null;
- }
-}
-
/**
* Fetches lab information from the LABINFO API.
*
* @param {string} variableName - The name of the variable to look up.
- * @returns {Promise} - The value of the variable from the API response.
+ * @returns {Promise} - The value of the variable from the API response.
*/
-export async function fetchLabInfo(variableName: string): Promise {
- const variableValue = await fetchInfo(LABINFO_API_URL, variableName);
+export async function fetchLabInfo(variableName: string): Promise {
+ const variableValue = await fetchLabInfoServer(variableName);
if (variableValue === undefined || variableValue === null) {
return null;
}
@@ -88,59 +59,19 @@ export async function fetchLabInfo(variableName: string): Promise
* Fetches UDF information from the UDF API.
*
* @param {string} variableName - The name of the variable to look up.
- * @returns {Promise} - The value of the variable from the API response.
+ * @returns {Promise} - The value of the variable from the API response.
*/
-export async function fetchUDFInfo(variableName: string): Promise {
- return await fetchInfo(UDF_DEPLOYMENT_API_URL, variableName);
+export async function fetchUDFInfo(variableName: string): Promise {
+ return await fetchUDFInfoServer(variableName);
}
/**
- * Get Web Shell URL for UDF component
+ * Gets the Web Shell URL for a UDF component.
*
- * @param {string} componentName - The UDF component"s name
- * @returns {Promise}
+ * @param {string | null} componentName - The UDF component's name.
+ * @returns {Promise} The web shell URL, if found.
*
*/
export async function fetchUdfComponentWebShell(componentName: string | null = null): Promise {
- if (componentName === null) {
- console.error("fetchUdfComponentWebShell Error: name must be defined");
- return null;
- }
-
- console.debug("fetchUdfComponentWebShell:componentName: ", componentName);
-
- // get udf api payload
- const response = await fetch(UDF_DEPLOYMENT_API_URL, { mode: "cors" });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const jsonResponse = await response.json();
-
- const components = jsonResponse.deployment.components;
-
- let host = "";
-
- for (const component of components) {
- if (component.name === componentName) {
- const httpsMethods = component.accessMethods.https;
- for (const method of httpsMethods) {
- if (method.label === "Web Shell") {
- host = method.host
- }
- }
- }
- }
-
- if (host === "") {
- console.error(`Web Shell host not found for ${componentName}`);
- return null;
- }
-
- const url = `https://${host}`;
-
- console.log("fetchUdfComponentWebShell:url: ", url);
-
- return url;
+ return await fetchUdfComponentWebShellServer(componentName);
}
diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts
index 40e6cc4..8d5a602 100644
--- a/src/lib/utils.test.ts
+++ b/src/lib/utils.test.ts
@@ -1,5 +1,6 @@
import { delay } from '@/lib/utils';
import { ensureError } from '@/lib/utils';
+import { isTruthyBooleanLike } from '@/lib/utils';
describe('delay', () => {
jest.useFakeTimers();
@@ -40,3 +41,23 @@ describe('ensureError', () => {
expect(result.message).toBe('This value was thrown as is, not through an Error: [Unable to stringify the thrown value]');
});
});
+
+describe('isTruthyBooleanLike', () => {
+ it('returns true for supported truthy values', () => {
+ expect(isTruthyBooleanLike(true)).toBe(true);
+ expect(isTruthyBooleanLike(1)).toBe(true);
+ expect(isTruthyBooleanLike('true')).toBe(true);
+ expect(isTruthyBooleanLike('{true}')).toBe(true);
+ expect(isTruthyBooleanLike('1')).toBe(true);
+ expect(isTruthyBooleanLike('')).toBe(true);
+ });
+
+ it('returns false for other values', () => {
+ expect(isTruthyBooleanLike(false)).toBe(false);
+ expect(isTruthyBooleanLike(0)).toBe(false);
+ expect(isTruthyBooleanLike('false')).toBe(false);
+ expect(isTruthyBooleanLike('0')).toBe(false);
+ expect(isTruthyBooleanLike(null)).toBe(false);
+ expect(isTruthyBooleanLike(undefined)).toBe(false);
+ });
+});
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 41df4e9..d86f629 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -25,3 +25,19 @@ export function ensureError(value: unknown): Error {
const error = new Error(`This value was thrown as is, not through an Error: ${stringified}`)
return error
}
+
+/**
+ * Returns true when a value represents an enabled boolean-like flag.
+ */
+export function isTruthyBooleanLike(value: unknown): boolean {
+ if (value === true || value === 1) {
+ return true;
+ }
+
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ return normalized === "true" || normalized === "{true}" || normalized === "1" || normalized === "";
+ }
+
+ return false;
+}
diff --git a/src/lib/variable-keys.ts b/src/lib/variable-keys.ts
new file mode 100644
index 0000000..61f057a
--- /dev/null
+++ b/src/lib/variable-keys.ts
@@ -0,0 +1,15 @@
+export const DEPLOYMENT_IDENTIFIER_KEY = "DEPLOYMENT_IDENTIFIER";
+
+export function getCandidateKeys(name: string): string[] {
+ const raw = name.trim();
+ if (!raw) {
+ return [];
+ }
+
+ if (raw.toLowerCase() === "petname") {
+ return ["petname", "PETNAME", DEPLOYMENT_IDENTIFIER_KEY];
+ }
+
+ const upper = raw.toUpperCase();
+ return upper === raw ? [raw] : [raw, upper];
+}
diff --git a/src/lib/variables-action.ts b/src/lib/variables-action.ts
new file mode 100644
index 0000000..22de6ee
--- /dev/null
+++ b/src/lib/variables-action.ts
@@ -0,0 +1,79 @@
+"use server";
+
+import { DeploymentIdentifier } from "./types";
+import { fetchDeploymentIdentifierServer } from "./deployment-identifier-action";
+import { fetchLabInfoServer, fetchUDFInfoServer } from "./udf-action";
+import { DEPLOYMENT_IDENTIFIER_KEY, getCandidateKeys } from "./variable-keys";
+
+const parseMaybeJson = (value: unknown): T => {
+ if (typeof value !== "string") {
+ return value as T;
+ }
+
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return value as T;
+ }
+};
+
+export async function getEnvVariableServer(name: string): Promise {
+ const value = process.env[name];
+ if (value === undefined) {
+ return null;
+ }
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return value as unknown as T;
+ }
+}
+
+export async function setEnvVariableServer(key: string, value: string): Promise {
+ process.env[key] = value;
+}
+
+export async function getDeploymentIdentifierServer(): Promise {
+ const deploymentIdentifier = await getEnvVariableServer(DEPLOYMENT_IDENTIFIER_KEY);
+ if (deploymentIdentifier) {
+ return deploymentIdentifier;
+ }
+
+ const fetchedDeploymentIdentifier = await fetchDeploymentIdentifierServer();
+ if (!fetchedDeploymentIdentifier) {
+ return null;
+ }
+
+ await setEnvVariableServer(DEPLOYMENT_IDENTIFIER_KEY, fetchedDeploymentIdentifier);
+ return fetchedDeploymentIdentifier;
+}
+
+export async function getVariableServer(name: string): Promise {
+ const candidateKeys = getCandidateKeys(name);
+
+ for (const key of candidateKeys) {
+ if (key === DEPLOYMENT_IDENTIFIER_KEY) {
+ const deploymentIdentifier = await getDeploymentIdentifierServer();
+ if (deploymentIdentifier) {
+ return deploymentIdentifier as T;
+ }
+ }
+
+ const envValue = await getEnvVariableServer(key);
+ if (envValue) {
+ return envValue;
+ }
+
+ const labInfoValue = await fetchLabInfoServer(key);
+ if (labInfoValue !== null && labInfoValue !== undefined) {
+ return parseMaybeJson(labInfoValue);
+ }
+
+ const udfValue = await fetchUDFInfoServer(key);
+ if (udfValue !== null && udfValue !== undefined) {
+ return parseMaybeJson(udfValue);
+ }
+ }
+
+ return null;
+}
diff --git a/src/lib/variables.test.ts b/src/lib/variables.test.ts
index 79eed8e..3c9230f 100644
--- a/src/lib/variables.test.ts
+++ b/src/lib/variables.test.ts
@@ -1,33 +1,48 @@
import {
getComponentName,
getEnvVariable,
+ getDeploymentIdentifier,
setEnvVariable,
- getPetname,
getVariable,
setVariable,
} from "@/lib/variables";
-import { fetchLabInfo, fetchUDFInfo } from "@/lib/udf";
-import fetchMock from "jest-fetch-mock";
+import {
+ getEnvVariableServer,
+ getDeploymentIdentifierServer,
+ getVariableServer,
+ setEnvVariableServer,
+} from "@/lib/variables-action";
const originalEnv = { ...process.env };
-
-jest.mock("@/lib/udf", () => ({
- fetchLabInfo: jest.fn(),
- fetchUDFInfo: jest.fn(),
-}));
-
-jest.mock("@/lib/variables", () => ({
- ...jest.requireActual("@/lib/variables"),
- getPetName: jest.fn().mockImplementation(() => Promise.resolve("testpet")),
+jest.mock("@/lib/variables-action", () => ({
+ getEnvVariableServer: jest.fn(),
+ getDeploymentIdentifierServer: jest.fn(),
+ getVariableServer: jest.fn(),
+ setEnvVariableServer: jest.fn(),
}));
describe("variables", () => {
beforeEach(() => {
jest.resetAllMocks();
+ localStorage.clear();
process.env = {
...originalEnv,
- PETNAME: "testpet",
+ DEPLOYMENT_IDENTIFIER: "testenv",
};
+ (getEnvVariableServer as jest.Mock).mockImplementation(async (name: string) => {
+ const value = process.env[name];
+ return value === undefined ? null : value;
+ });
+ (setEnvVariableServer as jest.Mock).mockImplementation(async (key: string, value: string) => {
+ process.env[key] = value;
+ });
+ (getDeploymentIdentifierServer as jest.Mock).mockImplementation(
+ async () => process.env.DEPLOYMENT_IDENTIFIER ?? null
+ );
+ (getVariableServer as jest.Mock).mockImplementation(async (name: string) => {
+ const upper = name.toUpperCase();
+ return process.env[name] ?? process.env[upper] ?? null;
+ });
});
afterEach(() => {
@@ -35,32 +50,37 @@ describe("variables", () => {
});
describe("getComponentName", () => {
- it("should return a normalized component name prefixed with petname", async () => {
- const mockResponse = { PETNAME: "testpet" };
- fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
+ it("should return a normalized component name prefixed with deployment identifier", async () => {
+ (getDeploymentIdentifierServer as jest.Mock).mockResolvedValue("testenv");
const result = await getComponentName("My Component");
- expect(result).toBe("testpet-my-component");
+ expect(result).toBe("testenv-my-component");
});
- it("should throw an error if petname is not retrieved", async () => {
+ it("should use a fallback deployment identifier when retrieval fails in browser", async () => {
process.env = {
...originalEnv,
};
- jest.mock("@/lib/variables", () => ({
- ...jest.requireActual("@/lib/variables"),
- getPetName: jest.fn().mockImplementation(() => Promise.resolve(null)),
- }));
+ (getDeploymentIdentifierServer as jest.Mock).mockResolvedValue(null);
- fetchMock.mockResponseOnce("{}", {
- status: 500,
- headers: { "content-type": "application/json" },
- });
+ const result = await getComponentName("My Component");
+ expect(result).toMatch(/^lab-[a-z0-9]{6}-my-component$/);
+ });
- await expect(getComponentName("My Component")).rejects.toThrow(
- "Error getting component name: My Component"
- );
+ it("should not warn when no deployment identifier is available", async () => {
+ const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
+ process.env = {
+ ...originalEnv,
+ };
+
+ (getDeploymentIdentifierServer as jest.Mock).mockResolvedValue(null);
+
+ const result = await getComponentName("My Component");
+
+ expect(result).toMatch(/^lab-[a-z0-9]{6}-my-component$/);
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ consoleWarnSpy.mockRestore();
});
});
@@ -84,61 +104,104 @@ describe("variables", () => {
});
});
- describe("getPetname", () => {
- beforeEach(() => {
- fetchMock.resetMocks();
+ describe("getDeploymentIdentifier", () => {
+ it("should return deployment identifier from LAB_VARIABLES when top-level key is missing", async () => {
+ delete process.env.DEPLOYMENT_IDENTIFIER;
+ localStorage.setItem("LAB_VARIABLES", JSON.stringify({ DEPLOYMENT_IDENTIFIER: "map-id" }));
+
+ const result = await getDeploymentIdentifier();
+
+ expect(result).toBe("map-id");
+ expect(localStorage.getItem("DEPLOYMENT_IDENTIFIER")).toBe(JSON.stringify("map-id"));
+ expect(getDeploymentIdentifierServer).not.toHaveBeenCalled();
});
- it("should return a petname from the environment variable if it exists", async () => {
- process.env.PETNAME = "envpet";
- const result = await getPetname();
- expect(result).toBe("envpet");
+ it("should return a deployment identifier from the environment variable if it exists", async () => {
+ process.env.DEPLOYMENT_IDENTIFIER = "env-id";
+ const result = await getDeploymentIdentifier();
+ expect(result).toBe("env-id");
});
- it("should fetch a petname from the external service if not in environment", async () => {
- delete process.env.PETNAME;
+ it("should fetch a deployment identifier from the external service if not in environment", async () => {
+ delete process.env.DEPLOYMENT_IDENTIFIER;
- const mockResponse = { petname: "fetchedpet" };
- fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
+ (getDeploymentIdentifierServer as jest.Mock).mockImplementation(async () => {
+ process.env.DEPLOYMENT_IDENTIFIER = "fetched-id";
+ return "fetched-id";
+ });
- const result = await getPetname();
- expect(result).toBe("fetchedpet");
- expect(process.env.PETNAME).toBe("fetchedpet");
+ const result = await getDeploymentIdentifier();
+ expect(result).toBe("fetched-id");
+ expect(process.env.DEPLOYMENT_IDENTIFIER).toBe("fetched-id");
});
- it("should return null if fetching petname fails", async () => {
- delete process.env.PETNAME;
+ it("should return a local fallback deployment identifier when fetch fails in browser", async () => {
+ delete process.env.DEPLOYMENT_IDENTIFIER;
- fetchMock.mockResponseOnce("{}", {
- status: 500,
- headers: { "content-type": "application/json" },
- });
+ (getDeploymentIdentifierServer as jest.Mock).mockResolvedValue(null);
- const result = await getPetname();
- expect(result).toBeNull();
+ const result = await getDeploymentIdentifier();
+ expect(typeof result).toBe("string");
+ expect(result).toMatch(/^lab-/);
+ expect(localStorage.getItem("DEPLOYMENT_IDENTIFIER")).toBe(JSON.stringify(result));
});
});
describe("getVariable", () => {
+ it("should resolve localStorage keys case-insensitively", async () => {
+ localStorage.setItem("DEPLOYMENT_IDENTIFIER", JSON.stringify("upper_value"));
+
+ const result = await getVariable("deployment_identifier");
+ expect(result).toBe("upper_value");
+ });
+
+ it("should resolve petname alias to deployment identifier", async () => {
+ process.env.DEPLOYMENT_IDENTIFIER = "alias_value";
+
+ const result = await getVariable("petname");
+ expect(result).toBe("alias_value");
+ });
+
+ it("should return value from LAB_VARIABLES before other sources", async () => {
+ localStorage.setItem(
+ "LAB_VARIABLES",
+ JSON.stringify({ TEST_VAR: "local_value" })
+ );
+ process.env.TEST_VAR = "env_value";
+
+ const result = await getVariable("TEST_VAR");
+ expect(result).toBe("local_value");
+ expect(getVariableServer).not.toHaveBeenCalled();
+ });
+
+ it("should ignore top-level localStorage variable keys", async () => {
+ localStorage.setItem("TEST_VAR", JSON.stringify("stale_value"));
+ process.env.TEST_VAR = "env_value";
+
+ const result = await getVariable("TEST_VAR");
+ expect(result).toBe("env_value");
+ });
+
it("should return the value from the first source that has it", async () => {
- (fetchLabInfo as jest.Mock).mockResolvedValueOnce(null);
- (fetchUDFInfo as jest.Mock).mockResolvedValueOnce("udf_value");
+ (getVariableServer as jest.Mock).mockResolvedValueOnce("udf_value");
const result = await getVariable("TEST_VAR");
expect(result).toBe("udf_value");
});
it("should return null if no source has the value", async () => {
- (fetchLabInfo as jest.Mock).mockResolvedValueOnce(null);
- (fetchUDFInfo as jest.Mock).mockResolvedValueOnce(null);
+ (getVariableServer as jest.Mock).mockResolvedValueOnce(null);
const result = await getVariable("NON_EXISTENT_VAR");
expect(result).toBeNull();
});
});
describe("setVariable", () => {
- it("should set the value of a variable in the env storage", async () => {
+ it("should set the value of a variable in localStorage", async () => {
await setVariable("TEST_VAR", "new_value");
- expect(process.env.TEST_VAR).toBe("new_value");
+ expect(localStorage.getItem("TEST_VAR")).toBeNull();
+ expect(localStorage.getItem("LAB_VARIABLES")).toBe(
+ JSON.stringify({ TEST_VAR: "new_value" })
+ );
});
});
});
diff --git a/src/lib/variables.ts b/src/lib/variables.ts
index 95b49aa..6d8e4f1 100644
--- a/src/lib/variables.ts
+++ b/src/lib/variables.ts
@@ -1,23 +1,137 @@
-"use server"
-
/**
- * Utility functions for interacting with environment variables and Redis.
+ * Utility functions for deployment identifier and variable resolution
+ * across browser storage, environment variables, and server sources.
*/
-import { PETNAME_API_URL } from "./constants";
-import { Petname } from "./types";
-import { fetchLabInfo, fetchUDFInfo } from "./udf";
+import { DeploymentIdentifier } from "./types";
+import {
+ getEnvVariableServer,
+ getDeploymentIdentifierServer,
+ getVariableServer,
+ setEnvVariableServer,
+} from "./variables-action";
+import { DEPLOYMENT_IDENTIFIER_KEY, getCandidateKeys } from "./variable-keys";
+
+export const LOCAL_VARIABLES_STORAGE_KEY = "LAB_VARIABLES";
+
+function isBrowserEnvironment(): boolean {
+ return typeof window !== "undefined";
+}
+
+function notifyLocalStorageChange(key: string): void {
+ if (!isBrowserEnvironment()) {
+ return;
+ }
+
+ window.dispatchEvent(
+ new CustomEvent("local-storage-change", {
+ detail: { key },
+ })
+ );
+}
+
+function getLocalStorageString(key: string): string | null {
+ if (!isBrowserEnvironment()) {
+ return null;
+ }
+
+ const value = localStorage.getItem(key);
+ if (value === null) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ return typeof parsed === "string" ? parsed : null;
+ } catch {
+ return value;
+ }
+}
+
+function setLocalStorageString(key: string, value: string): void {
+ if (!isBrowserEnvironment()) {
+ return;
+ }
+ localStorage.setItem(key, JSON.stringify(value));
+ notifyLocalStorageChange(key);
+}
+
+function getLocalVariablesMap(): Record {
+ if (!isBrowserEnvironment()) {
+ return {};
+ }
+
+ const raw = localStorage.getItem(LOCAL_VARIABLES_STORAGE_KEY);
+ if (!raw) {
+ return {};
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+
+ return Object.entries(parsed as Record).reduce>(
+ (acc, [entryKey, entryValue]) => {
+ if (typeof entryValue === "string") {
+ acc[entryKey] = entryValue;
+ }
+ return acc;
+ },
+ {}
+ );
+ } catch {
+ return {};
+ }
+}
+
+function setLocalVariablesMap(values: Record): void {
+ if (!isBrowserEnvironment()) {
+ return;
+ }
+
+ localStorage.setItem(LOCAL_VARIABLES_STORAGE_KEY, JSON.stringify(values));
+ notifyLocalStorageChange(LOCAL_VARIABLES_STORAGE_KEY);
+}
+
+export function setClientVariable(key: string, value: string): void {
+ if (!isBrowserEnvironment()) {
+ return;
+ }
+
+ const variableMap = getLocalVariablesMap();
+ if (variableMap[key] === value) {
+ return;
+ }
+
+ variableMap[key] = value;
+ setLocalVariablesMap(variableMap);
+ notifyLocalStorageChange(key);
+}
+
+function createFallbackDeploymentIdentifier(): string {
+ return `lab-${Math.random().toString(36).slice(2, 8)}`;
+}
/**
- * Returns a normalized component name prefixed with petname
+ * Returns a normalized component name prefixed with a deployment identifier
*
* @param {string} name - a name
* @return {Promise} - a unique and normalized component name
*/
export async function getComponentName(name: string): Promise {
- const petname = await getPetname();
- if (!petname) throw new Error(`Error getting component name: ${name}`)
- return petname + "-" + name.replace(/[^a-zA-Z0-9 ]/g, "").replace(/ /g, "-").toLowerCase();
+ const normalizedName = name
+ .replace(/[^a-zA-Z0-9 ]/g, "")
+ .replace(/ /g, "-")
+ .toLowerCase();
+
+ const deploymentIdentifier = await getDeploymentIdentifier();
+ if (!deploymentIdentifier) {
+ return normalizedName;
+ }
+
+ return deploymentIdentifier + "-" + normalizedName;
}
@@ -30,15 +144,7 @@ export async function getComponentName(name: string): Promise {
* @returns {Promise} The value of the environment variable, parsed as JSON if possible, or null if it does not exist
*/
export async function getEnvVariable(name: string): Promise {
- const value = process.env[name];
- if (value === undefined) {
- return null;
- }
- try {
- return JSON.parse(value) as T;
- } catch {
- return value as unknown as T;
- }
+ return await getEnvVariableServer(name);
}
/**
@@ -49,40 +155,76 @@ export async function getEnvVariable(name: string): Promise}
*/
export async function setEnvVariable(key: string, value: string) {
- process.env[key] = value;
+ await setEnvVariableServer(key, value);
}
/**
- * Retrieves a random pet name from the UDF pet name service.
+ * Retrieves a deployment identifier from environment/services with local fallback.
*
- * @returns {Promise} A random pet name
+ * @returns {Promise} A deployment identifier
*/
-export async function getPetname(): Promise {
- const petnameKey = "petname";
- let petname = await getEnvVariable(petnameKey.toUpperCase());
- if (petname) {
- return petname;
+export async function getDeploymentIdentifier(): Promise {
+ if (isBrowserEnvironment()) {
+ const localDeploymentIdentifier = getLocalStorageString(DEPLOYMENT_IDENTIFIER_KEY);
+ if (localDeploymentIdentifier) {
+ return localDeploymentIdentifier;
+ }
+
+ const variableMap = getLocalVariablesMap();
+ const mappedDeploymentIdentifier =
+ variableMap[DEPLOYMENT_IDENTIFIER_KEY] ??
+ variableMap.PETNAME ??
+ variableMap.petname;
+
+ if (mappedDeploymentIdentifier) {
+ setLocalStorageString(DEPLOYMENT_IDENTIFIER_KEY, mappedDeploymentIdentifier);
+ return mappedDeploymentIdentifier;
+ }
+ }
+
+ let deploymentIdentifier: DeploymentIdentifier = null;
+ try {
+ deploymentIdentifier = await getEnvVariableServer(DEPLOYMENT_IDENTIFIER_KEY);
+ } catch {
+ deploymentIdentifier = null;
}
+
+ if (deploymentIdentifier) {
+ if (isBrowserEnvironment()) {
+ setLocalStorageString(DEPLOYMENT_IDENTIFIER_KEY, deploymentIdentifier);
+ }
+ return deploymentIdentifier;
+ }
+
try {
- // Fetch pet name from external service
- const response = await fetch(PETNAME_API_URL, { cache: "no-store" });
- if (!response.ok) {
- throw new Error(`Failed to retrieve petname from ${PETNAME_API_URL}`);
+ deploymentIdentifier = await getDeploymentIdentifierServer();
+ if (!deploymentIdentifier) {
+ if (isBrowserEnvironment()) {
+ const fallbackDeploymentIdentifier = createFallbackDeploymentIdentifier();
+ setLocalStorageString(DEPLOYMENT_IDENTIFIER_KEY, fallbackDeploymentIdentifier);
+ return fallbackDeploymentIdentifier;
+ }
+ return null;
}
- const petData = await response.json();
- petname = petData[petnameKey] as string;
- await setEnvVariable(petnameKey.toUpperCase(), petname);
- return petname;
+ if (isBrowserEnvironment()) {
+ setLocalStorageString(DEPLOYMENT_IDENTIFIER_KEY, deploymentIdentifier);
+ }
+ return deploymentIdentifier;
} catch (error) {
- console.error("Error fetching pet name:", error);
+ if (isBrowserEnvironment()) {
+ const fallbackDeploymentIdentifier = createFallbackDeploymentIdentifier();
+ setLocalStorageString(DEPLOYMENT_IDENTIFIER_KEY, fallbackDeploymentIdentifier);
+ return fallbackDeploymentIdentifier;
+ }
+ console.error("Error fetching deployment identifier:", error);
+ return null;
}
- return null;
}
/**
* Retrieves a variable by first checking the following sources:
+ * - Browser LocalStorage
* - environment variables
- * - Redis
* - LabInfo API
* - UDF API
*
@@ -90,27 +232,48 @@ export async function getPetname(): Promise {
* @returns {Promise} The value of the variable, or null if it does not exist
*/
export async function getVariable(name: string): Promise {
- const sources = [getEnvVariable, fetchLabInfo, fetchUDFInfo];
- for (const source of sources) {
- const value = await source(name);
- if (value) {
- try {
- const json = JSON.parse(value);
- return json as T;
- } catch { /* not valid JSON */ }
- return value as T;
+ const candidateKeys = getCandidateKeys(name);
+
+ if (isBrowserEnvironment()) {
+ const variableMap = getLocalVariablesMap();
+
+ for (const key of candidateKeys) {
+ if (Object.prototype.hasOwnProperty.call(variableMap, key)) {
+ return variableMap[key] as unknown as T;
+ }
}
}
- return null;
+
+ if (candidateKeys.includes(DEPLOYMENT_IDENTIFIER_KEY)) {
+ const deploymentIdentifier = await getDeploymentIdentifier();
+ if (deploymentIdentifier) {
+ return deploymentIdentifier as T;
+ }
+ }
+
+ try {
+ return await getVariableServer(name);
+ } catch {
+ return null;
+ }
}
/**
- * Sets a variable in the env storage.
+ * Sets a variable in browser local storage map (client) and process env (server).
*
* @param {string} key - The key of the variable to set
* @param {string} value - The value of the variable to set
* @returns {Promise}
*/
export async function setVariable(key: string, value: string) {
- await setEnvVariable(key, value);
+ if (isBrowserEnvironment()) {
+ const variableMap = getLocalVariablesMap();
+ variableMap[key] = value;
+ setLocalVariablesMap(variableMap);
+ await setEnvVariableServer(key, value);
+ notifyLocalStorageChange(key);
+ return;
+ }
+
+ await setEnvVariableServer(key, value);
}
diff --git a/tsconfig.json b/tsconfig.json
index 8eaaca3..d9379c3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,5 @@
{
"compilerOptions": {
- "baseUrl": ".",
"lib": [
"dom",
"dom.iterable",
@@ -28,7 +27,7 @@
],
"paths": {
"@/*": [
- "src/*"
+ "./src/*"
]
},
"target": "ES2017"
diff --git a/types/create-cert/index.d.ts b/types/create-cert/index.d.ts
new file mode 100644
index 0000000..eb4e838
--- /dev/null
+++ b/types/create-cert/index.d.ts
@@ -0,0 +1,13 @@
+declare module "create-cert" {
+ export type CreateCertOptions = {
+ commonName: string;
+ days?: number;
+ };
+
+ export type CreateCertResult = {
+ cert: string;
+ key: string;
+ };
+
+ export default function createCert(options: CreateCertOptions): Promise;
+}