release: 3.4.0 (#1459)

* Release/3.3.1 (#1439)

* chore: bump version to 3.3.1

* ci: run e2e when build-images is skipped

* feat: view app logs in dashboard (#1445)

* socket events for docker logs

* auto-scroll with ref

* add log terminal with auto scroll

* remove console.logs

* increase initial lines to 25

* remove more console logs

* useSocketEmit

* emit on disconect & hide tab if not running

* change tab when not running & logs options

* logs max lines

* logs emit

* remove console logs

* refactor(logs-socket): consolidate & reduce state usage

* useTranslations in logs tab

* remove wrapLines from useEffect

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* order file & add spanish translations

* chore: fix tsc issues

---------

Co-authored-by: Jorge Montejo <jorgemon.lopez@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* autoforward ports in codespaces (#1447)

* chore(deps-dev): bump @typescript-eslint/parser from 6.21.0 to 7.11.0 (#1443)

Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.21.0 to 7.11.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.11.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: sentry config (#1448)

* chore(deps): bump the minor-patch group across 1 directory with 17 updates (#1451)

* chore(deps): bump the minor-patch group across 1 directory with 17 updates

Bumps the minor-patch group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@sentry/nextjs](https://github.com/getsentry/sentry-javascript) | `8.4.0` | `8.7.0` |
| [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) | `3.4.0` | `3.5.0` |
| [argon2](https://github.com/ranisalt/node-argon2) | `0.40.1` | `0.40.3` |
| [bullmq](https://github.com/taskforcesh/bullmq) | `5.7.12` | `5.7.14` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.51.4` | `7.51.5` |
| [sass](https://github.com/sass/dart-sass) | `1.77.2` | `1.77.3` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `20.12.12` | `20.12.13` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `7.10.0` | `7.11.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `4.2.1` | `4.3.0` |
| [eslint-plugin-jsonc](https://github.com/ota-meshi/eslint-plugin-jsonc) | `2.15.1` | `2.16.0` |
| [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) | `7.34.1` | `7.34.2` |
| [jsdom](https://github.com/jsdom/jsdom) | `24.0.0` | `24.1.0` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `5.16.0` | `5.17.3` |
| [@sentry/types](https://github.com/getsentry/sentry-javascript) | `8.4.0` | `8.7.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `8.4.0` | `8.7.0` |
| [hono](https://github.com/honojs/hono) | `4.3.11` | `4.4.0` |
| [nodemon](https://github.com/remy/nodemon) | `3.1.0` | `3.1.2` |



Updates `@sentry/nextjs` from 8.4.0 to 8.7.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.4.0...8.7.0)

Updates `@tabler/icons-react` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/tabler/tabler-icons/releases)
- [Commits](https://github.com/tabler/tabler-icons/commits/v3.5.0/packages/icons-react)

Updates `argon2` from 0.40.1 to 0.40.3
- [Release notes](https://github.com/ranisalt/node-argon2/releases)
- [Commits](https://github.com/ranisalt/node-argon2/commits)

Updates `bullmq` from 5.7.12 to 5.7.14
- [Release notes](https://github.com/taskforcesh/bullmq/releases)
- [Commits](https://github.com/taskforcesh/bullmq/compare/v5.7.12...v5.7.14)

Updates `react-hook-form` from 7.51.4 to 7.51.5
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.51.4...v7.51.5)

Updates `sass` from 1.77.2 to 1.77.3
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.77.2...1.77.3)

Updates `@types/node` from 20.12.12 to 20.12.13
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@typescript-eslint/eslint-plugin` from 7.10.0 to 7.11.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.11.0/packages/eslint-plugin)

Updates `@vitejs/plugin-react` from 4.2.1 to 4.3.0
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.0/packages/plugin-react)

Updates `eslint-plugin-jsonc` from 2.15.1 to 2.16.0
- [Release notes](https://github.com/ota-meshi/eslint-plugin-jsonc/releases)
- [Changelog](https://github.com/ota-meshi/eslint-plugin-jsonc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ota-meshi/eslint-plugin-jsonc/compare/v2.15.1...v2.16.0)

Updates `eslint-plugin-react` from 7.34.1 to 7.34.2
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.34.1...v7.34.2)

Updates `jsdom` from 24.0.0 to 24.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/24.0.0...24.1.0)

Updates `knip` from 5.16.0 to 5.17.3
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Changelog](https://github.com/webpro-nl/knip/blob/main/packages/knip/.release-it.json)
- [Commits](https://github.com/webpro-nl/knip/commits/5.17.3/packages/knip)

Updates `@sentry/types` from 8.4.0 to 8.7.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.4.0...8.7.0)

Updates `@sentry/node` from 8.4.0 to 8.7.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.4.0...8.7.0)

Updates `hono` from 4.3.11 to 4.4.0
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.3.11...v4.4.0)

Updates `nodemon` from 3.1.0 to 3.1.2
- [Release notes](https://github.com/remy/nodemon/releases)
- [Commits](https://github.com/remy/nodemon/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: "@sentry/nextjs"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@tabler/icons-react"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: argon2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: bullmq
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: react-hook-form
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: sass
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: eslint-plugin-jsonc
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: jsdom
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: knip
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@sentry/types"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@sentry/node"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: hono
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: nodemon
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: fix vite plugin typings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>

* Feat/tipi logs terminal (#1450)

* refactor: extract logs terminal ui in its own component

* feat: runtipi logs settings

* fix: runtipi dashboard logs project name

* ci: use nightly version for e2e

* feat(docker-tamplate): include addPorts and readOnly in volumes (#1456)

* chore(deps): bump the minor-patch group with 6 updates (#1454)

* chore: bump version to 3.4.0

* chore: implement bot readability feedbacks

* refactor(logs): only scroll when the last log id changes

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jorge Montejo <jorgemon.lopez@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stavros <steveiliop56@gmail.com>
This commit is contained in:
Nicolas Meienberger 2024-06-02 19:49:31 +02:00 committed by GitHub
parent 44e9334120
commit 9eb6301ada
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1352 additions and 587 deletions

View File

@ -11,5 +11,17 @@
"ms-vscode.vscode-typescript-next",
"waderyan.gitblame"
],
"postCreateCommand": "./.devcontainer/postCreateCommand.sh"
}
"postCreateCommand": "./.devcontainer/postCreateCommand.sh",
"forwardPorts": [
80,
3000
],
"portsAttributes": {
"3000": {
"label": "Runtipi"
},
"80": {
"label": "WebSocket"
}
}
}

View File

@ -104,3 +104,10 @@ jobs:
tag: nightly
rm: true
files: cli/runtipi-cli-*
e2e-tests:
needs: [update-release]
uses: './.github/workflows/e2e.yml'
secrets: inherit
with:
version: nightly

View File

@ -1,3 +1,17 @@
packages
coverage
.github
# Next files
.next
# Node modules
node_modules
packages/shared/node_modules
packages/worker/node_modules
# Tipi data
app-data
apps
logs
media
repos
state
traefik
user-config

View File

@ -11,7 +11,7 @@ ARG TIPI_VERSION
ARG LOCAL
ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
ENV TIPI_VERSION=${TIPI_VERSION}
ENV SENTRY_RELEASE=${TIPI_VERSION}
ENV LOCAL=${LOCAL}
RUN npm install pnpm -g
@ -45,8 +45,6 @@ COPY ./tests ./tests
# Sentry
COPY ./sentry.client.config.ts ./sentry.client.config.ts
COPY ./sentry.edge.config.ts ./sentry.edge.config.ts
COPY ./sentry.server.config.ts ./sentry.server.config.ts
RUN pnpm build
@ -88,7 +86,7 @@ COPY ./packages/worker/package.json ./packages/worker/package.json
COPY ./packages/worker/assets ./packages/worker/assets
# Print TIPI_VERSION to the console
RUN echo "TIPI_VERSION: ${TIPI_VERSION}"
RUN echo "TIPI_VERSION: ${SENTRY_RELEASE}"
RUN pnpm -r --filter @runtipi/worker build

View File

@ -3,6 +3,9 @@ ARG ALPINE_VERSION="3.18"
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}
ENV LOCAL=true
ENV SENTRY_RELEASE=development
RUN apk add --no-cache python3 make g++ curl git
RUN npm install pnpm pm2 -g
@ -43,8 +46,6 @@ COPY ./next.config.mjs ./next.config.mjs
# Sentry
COPY ./sentry.client.config.ts ./sentry.client.config.ts
COPY ./sentry.edge.config.ts ./sentry.edge.config.ts
COPY ./sentry.server.config.ts ./sentry.server.config.ts
COPY ./start.dev.sh ./start.sh

View File

@ -82,6 +82,7 @@ services:
- ${RUNTIPI_APP_DATA_PATH:-.}/app-data:/app-data
# Static
- ./.env:/data/.env
- ./docker-compose.dev.yml:/data/docker-compose.yml
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc:ro
networks:
@ -89,12 +90,10 @@ services:
ports:
- 3000:3000
environment:
LOCAL: true
NODE_ENV: development
TIPI_VERSION: development
WORKER_APP_DIR: /app/packages/worker
DASHBOARD_APP_DIR: /app
NEXT_PUBLIC_TIPI_VERSION: development
LOCAL: true
env_file:
- .env
labels:

View File

@ -61,8 +61,8 @@ services:
context: .
dockerfile: Dockerfile
args:
TIPI_VERSION: development
LOCAL: true
TIPI_VERSION: development
container_name: runtipi
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:5000/worker-api/healthcheck']
@ -89,13 +89,12 @@ services:
- ${RUNTIPI_APP_DATA_PATH:-.}/app-data:/app-data
# Static
- ./.env:/data/.env
- ./docker-compose.prod.yml:/data/docker-compose.yml
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc
- /proc:/host/proc:ro
environment:
NODE_ENV: production
TIPI_VERSION: development
NEXT_PUBLIC_TIPI_VERSION: development
LOCAL: true
NODE_ENV: production
networks:
- tipi_main_network
ports:

View File

@ -21,23 +21,16 @@ const nextConfig = {
},
};
export default !process.env.LOCAL === 'true'
? withSentryConfig(
nextConfig,
{
// https://github.com/getsentry/sentry-webpack-plugin#options
silent: false,
org: 'runtipi',
project: 'runtipi-dashboard',
release: process.env.TIPI_VERSION,
},
{
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
widenClientFileUpload: true,
transpileClientSDK: false,
tunnelRoute: '/errors',
hideSourceMaps: false,
disableLogger: true,
},
)
: nextConfig;
const sentryConfig = {
silent: false,
org: 'runtipi',
project: 'runtipi-dashboard',
widenClientFileUpload: true,
tunnelRoute: '/errors',
hideSourceMaps: false,
disableLogger: false,
};
const config = process.env.LOCAL !== 'true' ? withSentryConfig(nextConfig, sentryConfig) : nextConfig;
export default config;

View File

@ -1,6 +1,6 @@
{
"name": "runtipi",
"version": "3.3.2",
"version": "3.4.0",
"description": "A homeserver for everyone",
"scripts": {
"clean-containers": "docker rm -f $(docker ps -a -q)",
@ -15,8 +15,8 @@
"build": "next build --experimental-build-mode compile",
"start": "NODE_ENV=production node server.js",
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
"start:dev": "docker compose -f docker-compose.dev.yml up --build",
"start:prod": "docker compose --env-file ./.env -f docker-compose.prod.yml up --build",
"start:dev": "docker compose --project-name runtipi -f docker-compose.dev.yml up --build",
"start:prod": "docker compose --env-file ./.env --project-name runtipi -f docker-compose.prod.yml up --build",
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14",
"version": "echo $npm_package_version",
"tsc": "tsc",
@ -38,13 +38,13 @@
"@radix-ui/react-tabs": "^1.0.4",
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"@sentry/nextjs": "^8.4.0",
"@sentry/nextjs": "^8.7.0",
"@tabler/core": "1.0.0-beta20",
"@tabler/icons-react": "^3.2.0",
"argon2": "^0.40.1",
"bullmq": "^5.7.12",
"@tabler/icons-react": "^3.5.0",
"argon2": "^0.40.3",
"bullmq": "^5.7.14",
"clsx": "^2.1.0",
"drizzle-orm": "^0.30.9",
"drizzle-orm": "^0.31.0",
"fs-extra": "^11.2.0",
"geist": "^1.3.0",
"ipaddr.js": "^2.2.0",
@ -60,7 +60,7 @@
"qrcode.react": "^3.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.51.4",
"react-hook-form": "^7.51.5",
"react-hot-toast": "^2.4.1",
"react-markdown": "^9.0.1",
"react-query": "^3.39.3",
@ -70,7 +70,7 @@
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"sass": "^1.77.1",
"sass": "^1.77.4",
"semver": "^7.6.2",
"sharp": "0.33.4",
"socket.io-client": "^4.7.5",
@ -94,16 +94,16 @@
"@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash.merge": "^4.6.9",
"@types/node": "20.12.12",
"@types/node": "20.13.0",
"@types/pg": "^8.11.6",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"@types/validator": "^13.11.10",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^1.5.0",
"@vitest/ui": "^1.6.0",
"dotenv-cli": "^7.4.1",
@ -113,15 +113,15 @@
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^28.5.0",
"eslint-plugin-jest-dom": "^5.4.0",
"eslint-plugin-jsonc": "^2.15.1",
"eslint-plugin-jsonc": "^2.16.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^6.2.2",
"jest": "^29.7.0",
"jsdom": "^24.0.0",
"jsdom": "^24.1.0",
"jsonc-eslint-parser": "^2.4.0",
"knip": "^5.9.4",
"knip": "^5.17.3",
"memfs": "^4.8.2",
"msw": "^2.3.0",
"next-router-mock": "^0.9.13",

View File

@ -38,10 +38,10 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@sentry/types": "^8.4.0",
"@sentry/types": "^8.7.0",
"@types/lodash.clonedeep": "^4.5.9",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^6.21.0",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"eslint": "8.57.0",
"eslint-plugin-import": "^2.29.1",
"prettier": "^3.2.5"

View File

@ -28,10 +28,33 @@ export const socketEventSchema = z.union([
}),
}),
z.object({
type: z.literal('dummy'),
event: z.literal('dummy_event'),
type: z.literal('app-logs-init'),
event: z.literal('initLogs'),
data: z.object({
dummy: z.string(),
appId: z.string(),
maxLines: z.number().optional(),
}),
}),
z.object({
type: z.literal('app-logs'),
event: z.union([z.literal('newLogs'), z.literal('stopLogs')]),
data: z.object({
appId: z.string(),
lines: z.array(z.string()).optional(),
}),
}),
z.object({
type: z.literal('runtipi-logs-init'),
event: z.literal('initLogs'),
data: z.object({
maxLines: z.number().optional(),
}),
}),
z.object({
type: z.literal('runtipi-logs'),
event: z.union([z.literal('newLogs'), z.literal('stopLogs')]),
data: z.object({
lines: z.array(z.string()).optional(),
}),
}),
]);

View File

@ -20,15 +20,15 @@
"@faker-js/faker": "^8.4.1",
"@sentry/esbuild-plugin": "^2.17.0",
"@types/web-push": "^3.6.3",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^6.21.0",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"dotenv-cli": "^7.4.2",
"esbuild": "^0.19.4",
"eslint": "8.57.0",
"eslint-plugin-import": "^2.29.1",
"knip": "^5.15.1",
"knip": "^5.17.3",
"memfs": "^4.8.2",
"nodemon": "^3.1.0",
"nodemon": "^3.1.2",
"prettier": "^3.2.5",
"tsx": "^4.10.2",
"typescript": "^5.4.5",
@ -36,18 +36,18 @@
"vitest": "^1.6.0"
},
"dependencies": {
"@hono/node-server": "^1.11.0",
"@hono/node-server": "^1.11.2",
"@runtipi/postgres-migrations": "^5.3.0",
"@runtipi/shared": "workspace:^",
"@sentry/integrations": "^7.114.0",
"@sentry/node": "^8.4.0",
"bullmq": "^5.7.12",
"@sentry/node": "^8.7.0",
"bullmq": "^5.7.14",
"dotenv": "^16.4.5",
"hono": "^4.3.11",
"hono": "^4.4.2",
"ioredis": "^5.4.1",
"pg": "^8.11.5",
"socket.io": "^4.7.5",
"systeminformation": "^5.22.9",
"systeminformation": "^5.22.10",
"web-push": "^3.6.7",
"yaml": "^2.4.1",
"zod": "^3.23.8"

View File

@ -71,12 +71,23 @@ const serviceSchema = z
name: z.string(),
internalPort: z.number(),
isMain: z.boolean().optional(),
addPorts: z
.array(
z.object({
containerPort: z.number(),
hostPort: z.number(),
udp: z.boolean(),
tcp: z.boolean(),
}),
)
.optional(),
command: z.string().optional(),
volumes: z
.array(
z.object({
hostPath: z.string(),
containerPath: z.string(),
readOnly: z.boolean().optional(),
}),
)
.optional(),
@ -104,13 +115,31 @@ const serviceSchema = z
command: data.command,
};
let ports: string[] = [];
if (data.isMain && data.openPort) {
base.ports = [`\${APP_PORT}:${data.internalPort}`];
ports = ports.concat([`\${APP_PORT}:${data.internalPort}`]);
}
if (data.addPorts?.length) {
for (const port of data.addPorts) {
if (port.tcp) {
ports = ports.concat([`${port.hostPort}:${port.containerPort}/tcp`]);
}
if (port.udp) {
ports = ports.concat([`${port.hostPort}:${port.containerPort}/udp`]);
}
}
}
if (ports.length) {
base.ports = ports;
}
if (data.volumes?.length) {
base.volumes = data.volumes.map(
({ hostPath, containerPath }) => `${hostPath}:${containerPath}`,
({ hostPath, containerPath, readOnly }) =>
`${hostPath}:${containerPath}${readOnly ? ':ro' : ''}`,
);
}

View File

@ -1,27 +1,14 @@
import path from 'path';
import { spawn } from 'node:child_process';
import { execAsync, pathExists } from '@runtipi/shared/node';
import { sanitizePath } from '@runtipi/shared';
import { SocketEvent, sanitizePath, socketEventSchema } from '@runtipi/shared';
import { logger } from '@/lib/logger';
import { getEnv } from '@/lib/environment';
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
import { Socket } from 'socket.io';
import { SocketManager } from '@/lib/socket/SocketManager';
const composeUp = async (args: string[]) => {
logger.info(`Running docker compose with args ${args.join(' ')}`);
const { stdout, stderr } = await execAsync(`docker-compose ${args.join(' ')}`);
if (stderr && stderr.includes('Command failed:')) {
throw new Error(stderr);
}
return { stdout, stderr };
};
/**
* Helpers to execute docker compose commands
* @param {string} appId - App name
* @param {string} command - Command to execute
*/
export const compose = async (appId: string, command: string) => {
const getBaseComposeArgsApp = async (appId: string) => {
const { arch, appsRepoId } = getEnv();
const appDataDirPath = path.join(APP_DATA_DIR, sanitizePath(appId));
const appDirPath = path.join(DATA_DIR, 'apps', sanitizePath(appId));
@ -42,16 +29,163 @@ export const compose = async (appId: string, command: string) => {
}
args.push(`-f ${composeFile}`);
const commonComposeFile = path.join(DATA_DIR, 'repos', sanitizePath(appsRepoId), 'apps', 'docker-compose.common.yml');
const commonComposeFile = path.join(
DATA_DIR,
'repos',
sanitizePath(appsRepoId),
'apps',
'docker-compose.common.yml',
);
args.push(`-f ${commonComposeFile}`);
// User defined overrides
const userComposeFile = path.join(DATA_DIR, 'user-config', sanitizePath(appId), 'docker-compose.yml');
const userComposeFile = path.join(
DATA_DIR,
'user-config',
sanitizePath(appId),
'docker-compose.yml',
);
if (await pathExists(userComposeFile)) {
args.push(`--file ${userComposeFile}`);
}
return args;
};
const getBaseComposeArgsTipi = async () => {
const args: string[] = [`--env-file ${path.join(DATA_DIR, '.env')}`];
args.push(`--project-name runtipi`);
const composeFile = path.join(DATA_DIR, 'docker-compose.yml');
args.push(`-f ${composeFile}`);
// User defined overrides
const userComposeFile = path.join(DATA_DIR, 'user-config', 'tipi-compose.yml');
if (await pathExists(userComposeFile)) {
args.push(`--file ${userComposeFile}`);
}
return args;
};
/**
* Helpers to execute docker compose commands
* @param {string} appId - App name
* @param {string} command - Command to execute
*/
export const compose = async (appId: string, command: string) => {
const args = await getBaseComposeArgsApp(appId);
args.push(command);
return composeUp(args);
logger.info(`Running docker compose with args ${args.join(' ')}`);
const { stdout, stderr } = await execAsync(`docker-compose ${args.join(' ')}`);
if (stderr && stderr.includes('Command failed:')) {
throw new Error(stderr);
}
return { stdout, stderr };
};
export const handleViewRuntipiLogsEvent = async (socket: Socket, event: SocketEvent) => {
const { success, data } = socketEventSchema.safeParse(event);
if (!success) {
logger.error('Invalid viewLogs event data:', event);
return;
}
if (data.type !== 'runtipi-logs-init') {
return;
}
const { maxLines } = data.data;
const args = await getBaseComposeArgsTipi();
args.push(`logs --follow -n ${maxLines || 25}`);
const logsCommand = `docker-compose ${args.join(' ')}`;
const logs = spawn('sh', ['-c', logsCommand]);
socket.on('disconnect', () => {
logs.kill('SIGINT');
});
socket.on('runtipi-logs', (data) => {
if (data.event === 'stopLogs') {
logs.kill('SIGINT');
}
});
logs.on('error', (error) => {
logger.error('Error running logs command: ', error);
logs.kill('SIGINT');
});
logs.stdout.on('data', async (data) => {
const lines = data
.toString()
.split(/(?:\r\n|\r|\n)/g)
.filter(Boolean);
await SocketManager.emit({
type: 'runtipi-logs',
event: 'newLogs',
data: { lines },
});
});
};
export const handleViewAppLogsEvent = async (socket: Socket, event: SocketEvent) => {
const parsedEvent = socketEventSchema.safeParse(event);
if (!parsedEvent.success) {
logger.error('Invalid viewLogs event data:', event);
return;
}
if (parsedEvent.data.type !== 'app-logs-init') {
return;
}
const { appId, maxLines } = parsedEvent.data.data;
const args = await getBaseComposeArgsApp(appId);
args.push(`logs --follow -n ${maxLines || 25}`);
const logsCommand = `docker-compose ${args.join(' ')}`;
const logs = spawn('sh', ['-c', logsCommand]);
socket.on('disconnect', () => {
logs.kill('SIGINT');
});
socket.on('app-logs', (data) => {
if (data.event === 'stopLogs') {
console.log('Stopping logs');
logs.kill('SIGINT');
}
});
logs.on('error', (error) => {
logger.error('Error running logs command: ', error);
logs.kill('SIGINT');
});
logs.stdout.on('data', async (data) => {
const lines = data
.toString()
.split(/(?:\r\n|\r|\n)/g)
.filter(Boolean);
await SocketManager.emit({
type: 'app-logs',
event: 'newLogs',
data: { lines, appId },
});
});
};

View File

@ -1,6 +1,7 @@
import { SocketEvent } from '@runtipi/shared';
import { Server } from 'socket.io';
import { logger } from '../logger';
import { handleViewAppLogsEvent, handleViewRuntipiLogsEvent } from '../docker';
class SocketManager {
private io: Server | null = null;
@ -8,7 +9,9 @@ class SocketManager {
init() {
const io = new Server(5001, { cors: { origin: '*' }, path: '/worker/socket.io' });
io.on('connection', (socket) => {
io.on('connection', async (socket) => {
socket.on('app-logs-init', (event) => handleViewAppLogsEvent(socket, event));
socket.on('runtipi-logs-init', (event) => handleViewRuntipiLogsEvent(socket, event));
socket.on('disconnect', () => {});
});

File diff suppressed because it is too large Load Diff

View File

@ -21,13 +21,10 @@ const { allowErrorMonitoring } = getClientConfig();
if (allowErrorMonitoring && process.env.NODE_ENV === 'production' && process.env.LOCAL !== 'true') {
Sentry.init({
release: process.env.NEXT_PUBLIC_TIPI_VERSION,
environment: process.env.NODE_ENV,
dsn: 'https://7a73d72f886948478b55621e7b92c3c7@o4504242900238336.ingest.sentry.io/4504826587971584',
beforeSend: cleanseErrorData,
integrations: [Sentry.extraErrorDataIntegration()],
initialScope: {
tags: { version: process.env.TIPI_VERSION },
},
tracesSampleRate: 1.0,
});
}

View File

@ -1,23 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import { TipiConfig } from '@/server/core/TipiConfig';
import { cleanseErrorData } from '@runtipi/shared';
const { version, allowErrorMonitoring, NODE_ENV } = TipiConfig.getConfig();
if (allowErrorMonitoring && NODE_ENV === 'production' && process.env.LOCAL !== 'true') {
Sentry.init({
release: version,
environment: NODE_ENV,
dsn: 'https://7a73d72f886948478b55621e7b92c3c7@o4504242900238336.ingest.sentry.io/4504826587971584',
beforeSend: cleanseErrorData,
integrations: [Sentry.extraErrorDataIntegration()],
initialScope: {
tags: { version },
},
});
}

View File

@ -1,22 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
import { TipiConfig } from '@/server/core/TipiConfig';
import { cleanseErrorData } from '@runtipi/shared';
const { version, allowErrorMonitoring, NODE_ENV } = TipiConfig.getConfig();
if (allowErrorMonitoring && NODE_ENV === 'production' && process.env.LOCAL !== 'true') {
Sentry.init({
release: version,
environment: NODE_ENV,
dsn: 'https://7a73d72f886948478b55621e7b92c3c7@o4504242900238336.ingest.sentry.io/4504826587971584',
beforeSend: cleanseErrorData,
integrations: [Sentry.extraErrorDataIntegration()],
initialScope: {
tags: { version },
},
});
}

View File

@ -236,7 +236,7 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
/>
</div>
</div>
<AppDetailsTabs info={app.info} />
<AppDetailsTabs info={app.info} status={optimisticStatus}/>
</div>
);
};

View File

@ -5,12 +5,15 @@ import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Markdown } from '@/components/Markdown';
import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { AppLogs } from './AppLogs';
interface IProps {
info: AppInfo;
status: AppStatusEnum;
}
export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
export const AppDetailsTabs: React.FC<IProps> = ({ info, status }) => {
const t = useTranslations();
return (
@ -18,6 +21,9 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
<TabsList>
<TabsTrigger value="description">{t('APP_DETAILS_DESCRIPTION')}</TabsTrigger>
<TabsTrigger value="info">{t('APP_DETAILS_BASE_INFO')}</TabsTrigger>
<TabsTrigger value="logs" disabled={status === 'missing'}>
{t('APP_LOGS_TAB_TITLE')}
</TabsTrigger>
</TabsList>
<TabsContent value="description">
{info.deprecated && (
@ -74,6 +80,7 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
)}
</DataGrid>
</TabsContent>
<TabsContent value="logs">{status === 'running' && <AppLogs appId={info.id} />}</TabsContent>
</Tabs>
);
};

View File

@ -0,0 +1,38 @@
'use client';
import React, { useState } from 'react';
import { useSocket } from '@/lib/socket/useSocket';
import { LogsTerminal } from 'src/app/components/LogsTerminal/LogsTerminal';
export const AppLogs = ({ appId }: { appId: string }) => {
let nextId = 0;
const [logs, setLogs] = useState<{ id: number; text: string }[]>([]);
const [maxLines, setMaxLines] = useState<number>(300);
useSocket({
selector: { type: 'app-logs', event: 'newLogs', data: { property: 'appId', value: appId } },
onCleanup: () => setLogs([]),
emitOnConnect: { type: 'app-logs-init', event: 'initLogs', data: { appId, maxLines } },
emitOnDisconnect: { type: 'app-logs', event: 'stopLogs', data: { appId } },
onEvent: (_, data) => {
setLogs((prevLogs) => {
if (!data.lines) {
return prevLogs;
}
const newLogs = [...prevLogs, ...data.lines.map((line) => ({ id: nextId++, text: line.trim() }))];
if (newLogs.length > maxLines) {
return newLogs.slice(newLogs.length - maxLines);
}
return newLogs;
});
},
});
const updateMaxLines = (lines: number) => {
const linesToKeep = Math.max(1, lines);
setMaxLines(linesToKeep);
setLogs((currentLogs) => currentLogs.slice(currentLogs.length - linesToKeep));
};
return <LogsTerminal logs={logs} maxLines={maxLines} onMaxLinesChange={updateMaxLines} />;
};

View File

@ -0,0 +1,38 @@
'use client';
import React, { useState } from 'react';
import { useSocket } from '@/lib/socket/useSocket';
import { LogsTerminal } from 'src/app/components/LogsTerminal/LogsTerminal';
export const LogsContainer = () => {
let nextId = 0;
const [logs, setLogs] = useState<{ id: number; text: string }[]>([]);
const [maxLines, setMaxLines] = useState<number>(300);
useSocket({
selector: { type: 'runtipi-logs', event: 'newLogs' },
onCleanup: () => setLogs([]),
emitOnConnect: { type: 'runtipi-logs-init', event: 'initLogs', data: { maxLines } },
emitOnDisconnect: { type: 'runtipi-logs', event: 'stopLogs', data: {} },
onEvent: (_, data) => {
setLogs((prevLogs) => {
if (!data.lines) {
return prevLogs;
}
const newLogs = [...prevLogs, ...data.lines.map((line) => ({ id: nextId++, text: line.trim() }))];
if (newLogs.length > maxLines) {
return newLogs.slice(newLogs.length - maxLines);
}
return newLogs;
});
},
});
const updateMaxLines = (lines: number) => {
const linesToKeep = Math.max(1, lines);
setMaxLines(linesToKeep);
setLogs(logs.slice(logs.length - linesToKeep));
};
return <LogsTerminal logs={logs} maxLines={maxLines} onMaxLinesChange={updateMaxLines} />;
};

View File

@ -0,0 +1 @@
export { LogsContainer } from './LogsContainer';

View File

@ -24,6 +24,9 @@ export const SettingsTabTriggers = () => {
<TabsTrigger onClick={() => handleTabChange('security')} value="security">
{t('SETTINGS_SECURITY_TAB_TITLE')}
</TabsTrigger>
<TabsTrigger onClick={() => handleTabChange('logs')} value="logs">
{t('SETTINGS_LOGS_TAB_TITLE')}
</TabsTrigger>
</TabsList>
);
};

View File

@ -10,6 +10,7 @@ import { SettingsTabTriggers } from './components/SettingsTabTriggers';
import { GeneralActions } from './components/GeneralActions';
import { SettingsContainer } from './components/SettingsContainer';
import { SecurityContainer } from './components/SecurityContainer';
import { LogsContainer } from './components/LogsContainer';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
@ -40,6 +41,9 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
<TabsContent value="security">
<SecurityContainer totpEnabled={Boolean(user?.totpEnabled)} username={user?.username} />
</TabsContent>
<TabsContent value="logs">
<LogsContainer />
</TabsContent>
</Tabs>
</div>
);

View File

@ -0,0 +1,33 @@
'use client';
import { Button } from '@/components/ui/Button';
import React from 'react';
export default function TestErrorPage() {
const fetchFaultyApi = async () => {
const response = await fetch('/api/test-error');
const json = await response.json();
return json;
};
return (
<div className="card d-flex">
<Button
type="button"
onClick={() => {
throw new Error('Sentry Frontend Error');
}}
>
Throw error
</Button>
<Button
type="button"
onClick={async () => {
await fetchFaultyApi();
}}
>
Fetch faulty API
</Button>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { handleApiError } from '@/actions/utils/handle-api-error';
export async function GET() {
try {
throw new Error('API throw error test');
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,9 @@
.logTerminal {
height: 300px;
overflow-y: scroll !important;
scrollbar-color: rgb(94, 107, 133) rgb(29, 39, 59);
}
.wrapLines {
text-wrap: wrap;
}

View File

@ -0,0 +1,73 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import styles from './LogsTerminal.module.scss';
import { useTranslations } from 'next-intl';
type Props = {
logs: { id: number; text: string }[];
maxLines: number;
onMaxLinesChange: (lines: number) => void;
};
export const LogsTerminal = (props: Props) => {
const t = useTranslations();
const { logs, onMaxLinesChange, maxLines } = props;
const [follow, setFollow] = useState<boolean>(true);
const [wrapLines, setWrapLines] = useState<boolean>(false);
const ref = useRef<HTMLPreElement>(null);
const lastLogId = logs.length > 0 ? logs.at(-1)?.id : null;
useEffect(() => {
if (ref.current && follow) {
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [lastLogId, follow]);
const updateMaxLines = (lines: number) => {
const linesToKeep = Math.max(1, lines);
onMaxLinesChange(linesToKeep);
};
return (
<div>
<div className="row d-flex align-items-center ps-1">
<div className="col">
<label className="form-check form-switch mt-1" htmlFor="follow-logs">
<input id="follow-logs" className="form-check-input" type="checkbox" checked={follow} onChange={() => setFollow(!follow)} />
<span className="form-check-label">{t('APP_LOGS_TAB_FOLLOW')}</span>
</label>
</div>
<div className="col">
<label className="form-check form-switch mt-1" htmlFor="follow-logs">
<input id="follow-logs" className="form-check-input" type="checkbox" checked={wrapLines} onChange={() => setWrapLines(!wrapLines)} />
<span className="form-check-label">{t('APP_LOGS_TAB_WRAP_LINES')}</span>
</label>
</div>
<div className="col">
<div className="input-group mb-2">
<span className="input-group-text">{t('APP_LOGS_TAB_MAX_LINES')}</span>
<input
id="max-lines"
type="number"
className="form-control"
value={maxLines}
onChange={(e) => updateMaxLines(Number.parseInt(e.target.value, 10))}
/>
</div>
</div>
</div>
<pre id="log-terminal" className={clsx('mt-2', styles.logTerminal, { [styles.wrapLines || '']: wrapLines })} ref={ref}>
{logs.map((log) => (
<React.Fragment key={log.id}>
{log.text}
<br />
</React.Fragment>
))}
</pre>
</div>
);
};

View File

@ -1,19 +0,0 @@
'use client';
import React, { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
import Error from 'next/error';
export default function GlobalError({ error }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body>
<Error />
</body>
</html>
);
}

20
src/app/global-error.tsx Normal file
View File

@ -0,0 +1,20 @@
'use client';
import React from 'react';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body>
<NextError statusCode={undefined as never} />
</body>
</html>
);
}

View File

@ -79,6 +79,10 @@
"APP_INSTALL_FORM_GENERAL": "General",
"APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy",
"APP_INSTALL_SUCCESS": "App {id} installed successfully",
"APP_LOGS_TAB_FOLLOW": "Follow logs",
"APP_LOGS_TAB_MAX_LINES": "Max lines:",
"APP_LOGS_TAB_TITLE": "Logs",
"APP_LOGS_TAB_WRAP_LINES": "Wrap lines",
"APP_NEW": "NEW",
"APP_RESET_FORM_SUBMIT": "Reset",
"APP_RESET_FORM_SUBTITLE": "All data for this app will be lost.",
@ -294,6 +298,7 @@
"SETTINGS_SECURITY_PASSWORD_NEEDED_HINT": "Your password is required to change two-factor authentication settings.",
"SETTINGS_SECURITY_SCAN_QR_CODE": "Scan this QR code with your authenticator app.",
"SETTINGS_SECURITY_TAB_TITLE": "Security",
"SETTINGS_LOGS_TAB_TITLE": "Logs",
"SETTINGS_TITLE": "Settings",
"SYSTEM_ERROR_COULD_NOT_GET_LATEST_VERSION": "Could not get latest version",
"SYSTEM_ERROR_CURRENT_VERSION_IS_LATEST": "Current version is already up to date",

View File

@ -77,6 +77,10 @@
"APP_INSTALL_FORM_SUBMIT_UPDATE": "Actualizar",
"APP_INSTALL_FORM_TITLE": "Instalar {name}",
"APP_INSTALL_SUCCESS": "La aplicación {id} se instaló correctamente",
"APP_LOGS_TAB_FOLLOW": "Seguir trazas",
"APP_LOGS_TAB_MAX_LINES": "Maximo numero de líneas:",
"APP_LOGS_TAB_TITLE": "Trazas",
"APP_LOGS_TAB_WRAP_LINES": "Ajustar líneas",
"APP_RESET_FORM_SUBMIT": "Restablecer",
"APP_RESET_FORM_SUBTITLE": "Todos los datos de esta aplicación se perderán.",
"APP_RESET_FORM_TITLE": "Restablecer {name}?",

26
src/instrumentation.ts Normal file
View File

@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs';
import { cleanseErrorData } from '@runtipi/shared';
export async function register() {
if (process.env.ALLOW_ERROR_MONITORING === 'true' && process.env.NODE_ENV === 'production' && process.env.LOCAL !== 'true') {
if (process.env.NEXT_RUNTIME === 'nodejs') {
Sentry.init({
environment: process.env.NODE_ENV,
dsn: 'https://7a73d72f886948478b55621e7b92c3c7@o4504242900238336.ingest.sentry.io/4504826587971584',
beforeSend: cleanseErrorData,
integrations: [Sentry.extraErrorDataIntegration()],
tracesSampleRate: 1.0,
});
}
if (process.env.NEXT_RUNTIME === 'edge') {
Sentry.init({
environment: process.env.NODE_ENV,
dsn: 'https://7a73d72f886948478b55621e7b92c3c7@o4504242900238336.ingest.sentry.io/4504826587971584',
beforeSend: cleanseErrorData,
integrations: [Sentry.extraErrorDataIntegration()],
tracesSampleRate: 1.0,
});
}
}
}

View File

@ -0,0 +1,113 @@
import { MockedFunction, afterEach, describe, expect, it, vi } from 'vitest';
import { useSocket } from './useSocket'; // Adjust the path as needed
import io, { Socket } from 'socket.io-client';
import { renderHook, waitFor } from '@/tests/test-utils';
// Mocking socket.io-client
vi.mock('socket.io-client');
const mockedIo = io as unknown as MockedFunction<typeof io>;
// Utility functions to mock the socket.io behavior
const mockSocket = {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
connected: true,
disconnected: false,
};
mockedIo.mockReturnValue(mockSocket as unknown as Socket);
describe('useSocket', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('should connect to the socket on mount', () => {
const props = {
selector: { type: 'runtipi-logs' },
} as const;
renderHook(() => useSocket(props));
expect(mockedIo).toHaveBeenCalled();
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function));
});
it('should emit on connect if emitOnConnect is provided', () => {
const emitOnConnect = { type: 'runtipi-logs', event: 'stopLogs', data: {} } as const;
renderHook(() => useSocket({ selector: { type: 'runtipi-logs' }, emitOnConnect }));
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function));
mockSocket.on.mock.calls.find((call) => call[0] === 'connect')[1](); // Call the connect handler
expect(mockSocket.emit).toHaveBeenCalledWith(emitOnConnect.type, emitOnConnect);
});
it('should handle events according to the selector', () => {
const eventData = { type: 'app-logs', event: 'newLogs', data: { appId: 'test' } };
const onEvent = vi.fn();
renderHook(() => useSocket({ selector: { type: 'app-logs', event: 'newLogs' }, onEvent }));
expect(mockSocket.on).toHaveBeenCalledWith('app-logs', expect.any(Function));
mockSocket.on.mock.calls.find((call) => call[0] === 'app-logs')[1](eventData); // Call the event handler
expect(onEvent).toHaveBeenCalledWith('newLogs', eventData.data);
});
it('should handle errors', () => {
const onError = vi.fn();
const errorMessage = 'test error';
renderHook(() =>
useSocket({
selector: { type: 'runtipi-logs' },
onError,
}),
);
expect(mockSocket.on).toHaveBeenCalledWith('error', expect.any(Function));
mockSocket.on.mock.calls.find((call) => call[0] === 'error')[1](errorMessage); // Call the error handler
expect(onError).toHaveBeenCalledWith(errorMessage);
});
it('should emit on disconnect if emitOnDisconnect is provided', () => {
const { unmount } = renderHook(() =>
useSocket({ selector: { type: 'app' }, emitOnDisconnect: { type: 'app', event: 'stop_error', data: { appId: 'test' } } }),
);
unmount();
expect(mockSocket.emit).toHaveBeenCalledWith('app', { type: 'app', event: 'stop_error', data: { appId: 'test' } });
expect(mockSocket.off).toHaveBeenCalledWith('app');
expect(mockSocket.off).toHaveBeenCalledWith('error');
});
it('should call onCleanup on unmount', () => {
const onCleanup = vi.fn();
const { unmount } = renderHook(() => useSocket({ selector: { type: 'app-logs' }, onCleanup }));
unmount();
expect(onCleanup).toHaveBeenCalled();
});
it('should update lastData state on receiving data matching selector', async () => {
const eventData = { type: 'app', event: 'stop_success', data: { appId: 'value' } };
const { result } = renderHook(() => useSocket({ selector: { type: 'app' } }));
expect(mockSocket.on).toHaveBeenCalledWith('app', expect.any(Function));
mockSocket.on.mock.calls.find((call) => call[0] === 'app')[1](eventData); // Call the event handler
await waitFor(() => {
expect(result.current.lastData).toEqual(eventData.data);
});
});
});

View File

@ -17,12 +17,15 @@ type Selector<T, U> = {
type Props<T, U> = {
onEvent?: (event: Extract<Extract<SocketEvent, { type: T }>['event'], U>, data: Extract<SocketEvent, { type: T }>['data']) => void;
onError?: (error: string) => void;
onCleanup?: () => void;
emitOnConnect?: SocketEvent;
emitOnDisconnect?: SocketEvent;
initialData?: Extract<SocketEvent, { type: T }>['data'] | undefined;
selector: Selector<T, U>;
};
export const useSocket = <T extends SocketEvent['type'], U extends SocketEvent['event']>(props: Props<T, U>) => {
const { onEvent, onError, selector, initialData } = props;
const { onEvent, onError, onCleanup, selector, initialData, emitOnConnect, emitOnDisconnect } = props;
const [lastData, setLastData] = useState(initialData as unknown);
const socketRef = useRef<Socket>();
@ -37,6 +40,12 @@ export const useSocket = <T extends SocketEvent['type'], U extends SocketEvent['
socketRef.current.connect();
}
socketRef.current.on('connect', () => {
if (emitOnConnect) {
socketRef.current?.emit(emitOnConnect.type, emitOnConnect);
}
});
const handleEvent = (type: SocketEvent['type'], rawData: unknown) => {
const parsedEvent = socketEventSchema.safeParse(rawData);
@ -56,7 +65,7 @@ export const useSocket = <T extends SocketEvent['type'], U extends SocketEvent['
}
const property = selector.data?.property as keyof SocketEvent['data'];
if (selector.data && selector.data.value !== data[property]) {
if (selector.data && data && selector.data.value !== data[property]) {
return;
}
}
@ -76,11 +85,17 @@ export const useSocket = <T extends SocketEvent['type'], U extends SocketEvent['
});
return () => {
if (emitOnDisconnect) {
socketRef.current?.emit(emitOnDisconnect.type, emitOnDisconnect);
}
socketRef.current?.off(selector.type as string);
socketRef.current?.off('error');
socketRef.current = undefined;
onCleanup?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- This effect should never re-run
}, []);
return { lastData } as { lastData: Extract<SocketEvent, { type: T }>['data'] | undefined };
return { lastData, socket: socketRef.current } as { lastData: Extract<SocketEvent, { type: T }>['data'] | undefined; socket: Socket | undefined };
};

View File

@ -1,11 +1,13 @@
import { defineWorkspace } from 'vitest/config';
import { UserWorkspaceConfig, defineWorkspace } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
type Plugins = UserWorkspaceConfig['plugins'];
export default defineWorkspace([
{
plugins: [tsconfigPaths()],
plugins: [tsconfigPaths()] as Plugins,
test: {
globals: true,
name: 'server',
@ -16,13 +18,13 @@ export default defineWorkspace([
},
},
{
plugins: [tsconfigPaths(), react()],
plugins: [tsconfigPaths(), react()] as Plugins,
test: {
globals: true,
name: 'client',
root: './',
environment: 'jsdom',
include: ['./src/client/**/*.{spec,test}.{ts,tsx}', './src/app/**/*.{spec,test}.{ts,tsx}'],
include: ['./src/client/**/*.{spec,test}.{ts,tsx}', './src/app/**/*.{spec,test}.{ts,tsx}', './src/lib/**/*.{spec,test}.{ts,tsx}'],
setupFiles: ['./tests/client/test.setup.tsx'],
},
},