mirror of
https://github.com/meienberger/runtipi.git
synced 2024-09-19 07:58:01 +03:00
commit
f2eca3ad34
@ -311,7 +311,7 @@
|
||||
},
|
||||
{
|
||||
"login": "steveiliop56",
|
||||
"name": "Stavros Iliopoulos",
|
||||
"name": "Stavros",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/106091011?v=4",
|
||||
"profile": "https://github.com/steveiliop56",
|
||||
"contributions": [
|
||||
|
@ -1,26 +1,17 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
/node_modules
|
||||
/.next
|
||||
node_modules
|
||||
.next
|
||||
dist/
|
||||
*
|
||||
|
||||
# all docker-compose files
|
||||
docker-compose*.yml
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Tipi folder
|
||||
logs/
|
||||
state/
|
||||
templates/
|
||||
scripts/
|
||||
screenshots/
|
||||
repos/
|
||||
media/
|
||||
data/
|
||||
apps/
|
||||
app-data/
|
||||
.github/
|
||||
__mocks__/
|
||||
### Includes ###
|
||||
!pnpm-*.yaml
|
||||
!package.json
|
||||
!patches/**
|
||||
!packages/**/src/**
|
||||
!packages/**/assets/**
|
||||
!**/package.json
|
||||
!**/nodemon.json
|
||||
!**/tsconfig.json
|
||||
!**/build.js
|
||||
!next.config.mjs
|
||||
!sentry.*.config.ts
|
||||
!public/**
|
||||
!src/**
|
||||
!tests/**
|
||||
|
@ -2,4 +2,9 @@
|
||||
.eslintrc.js
|
||||
next.config.js
|
||||
jest.config.js
|
||||
packages/
|
||||
/packages
|
||||
/repos
|
||||
.next/
|
||||
/app-data
|
||||
/apps
|
||||
package.json
|
||||
|
21
.eslintrc.js
21
.eslintrc.js
@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom', 'drizzle'],
|
||||
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom', 'jsonc', 'drizzle'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'next/core-web-vitals',
|
||||
@ -8,10 +8,12 @@ module.exports = {
|
||||
'airbnb-typescript',
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'prettier',
|
||||
'plugin:react/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:jsonc/recommended-with-json',
|
||||
'plugin:jsonc/prettier',
|
||||
'plugin:drizzle/recommended',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
@ -70,6 +72,21 @@ module.exports = {
|
||||
files: ['*.test.ts', '*.test.tsx'],
|
||||
extends: ['plugin:jest-dom/recommended', 'plugin:testing-library/react'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.json', '*.json5', '*.jsonc'],
|
||||
parser: 'jsonc-eslint-parser',
|
||||
rules: {
|
||||
// Disable all @typescript-eslint rules as they don't apply here
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/dot-notation': 'off',
|
||||
'@typescript-eslint/no-implied-eval': 'off',
|
||||
'@typescript-eslint/no-throw-literal': 'off',
|
||||
'@typescript-eslint/return-await': 'off',
|
||||
// jsonc rules
|
||||
'jsonc/sort-keys': 2,
|
||||
'jsonc/key-name-casing': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
globals: {
|
||||
JSX: true,
|
||||
|
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -9,6 +9,8 @@ assignees: meienberger
|
||||
|
||||
### Checklist
|
||||
Before opening your issue be sure to have completed all those tasks.
|
||||
- [ ] My issue is not related to an app I installed through tipi (If so please open your issue here: https://github.com/runtipi/runtipi-appstore/issues)
|
||||
- [ ] My issue is not a support request (eg: "My instance is not running after update". If so please ask for support in the help section of our [Discord server](https://discord.gg/gyeHhmvwaK))
|
||||
- [ ] I have searched for an already existing issue with similar context and errors. My issue has not yet been reported.
|
||||
- [ ] I have included a clear description and steps to reproduce.
|
||||
- [ ] I have included logs from the file `runtipi/logs/error.log` if relevant
|
||||
|
84
.github/workflows/alpha-release.yml
vendored
84
.github/workflows/alpha-release.yml
vendored
@ -54,7 +54,7 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
TIPI_VERSION=${{ needs.create-tag.outputs.tagname }}
|
||||
file: ./packages/worker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/worker:${{ needs.create-tag.outputs.tagname }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache
|
||||
@ -87,63 +87,67 @@ jobs:
|
||||
build-args: |
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
TIPI_VERSION=${{ needs.create-tag.outputs.tagname }}
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-tag
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Dispatch an action and get the run ID
|
||||
uses: codex-/return-dispatch@v1
|
||||
id: return_dispatch
|
||||
with:
|
||||
node-version: 20
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
ref: main
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
workflow: build.yml
|
||||
workflow_inputs: '{ "version": "${{ needs.create-tag.outputs.tagname }}" }'
|
||||
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
- name: Await Run ID ${{ steps.return_dispatch.outputs.run_id }}
|
||||
uses: Codex-/await-remote-run@v1.11.0
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
run_id: ${{ steps.return_dispatch.outputs.run_id }}
|
||||
run_timeout_seconds: 300
|
||||
poll_interval_ms: 5000
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
- name: Create bin folder
|
||||
run: mkdir -p bin
|
||||
|
||||
- name: Download CLI form release on runtipi/cli repo
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
REPO="runtipi/cli"
|
||||
VERSION="${{ needs.create-tag.outputs.tagname }}"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
ASSETS_URL=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$REPO/releases/tags/$VERSION" \
|
||||
| jq -r '.assets[] | select(.name | test("runtipi-cli.+")) | .browser_download_url')
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
echo "Assets URL: $ASSETS_URL"
|
||||
|
||||
- name: Set version
|
||||
run: pnpm -r --filter cli set-version ${{ needs.create-tag.outputs.tagname }}
|
||||
|
||||
- name: Build CLI
|
||||
run: pnpm -r --filter cli package
|
||||
for url in $ASSETS_URL; do
|
||||
echo "Downloading from $url"
|
||||
curl -L -o "bin/${url##*/}" -H "Accept: application/octet-stream" -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$url"
|
||||
done
|
||||
|
||||
- name: Upload CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli
|
||||
path: packages/cli/dist
|
||||
path: bin
|
||||
|
||||
publish-release:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [create-tag, build-images, build-cli, build-worker]
|
||||
|
||||
needs: [build-worker, build-images, build-cli, create-tag]
|
||||
steps:
|
||||
- name: Download CLI
|
||||
uses: actions/download-artifact@v4
|
||||
@ -151,9 +155,8 @@ jobs:
|
||||
name: cli
|
||||
path: cli
|
||||
|
||||
- name: Rename CLI
|
||||
run: |
|
||||
mv cli/bin/cli-x64 ./runtipi-cli-linux-x64
|
||||
- name: List files
|
||||
run: tree cli
|
||||
|
||||
- name: Create alpha release
|
||||
id: create_release
|
||||
@ -167,5 +170,4 @@ jobs:
|
||||
name: ${{ needs.create-tag.outputs.tagname }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
files: |
|
||||
runtipi-cli-linux-x64
|
||||
files: cli/runtipi-cli-*
|
||||
|
76
.github/workflows/beta-release.yml
vendored
76
.github/workflows/beta-release.yml
vendored
@ -90,55 +90,60 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
needs: create-tag
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Dispatch an action and get the run ID
|
||||
uses: codex-/return-dispatch@v1
|
||||
id: return_dispatch
|
||||
with:
|
||||
node-version: 20
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
ref: main
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
workflow: build.yml
|
||||
workflow_inputs: '{ "version": "${{ needs.create-tag.outputs.tagname }}" }'
|
||||
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
- name: Await Run ID ${{ steps.return_dispatch.outputs.run_id }}
|
||||
uses: Codex-/await-remote-run@v1.11.0
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
run_id: ${{ steps.return_dispatch.outputs.run_id }}
|
||||
run_timeout_seconds: 300
|
||||
poll_interval_ms: 5000
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
- name: Create bin folder
|
||||
run: mkdir -p bin
|
||||
|
||||
- name: Download CLI form release on runtipi/cli repo
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
REPO="runtipi/cli"
|
||||
VERSION="${{ needs.create-tag.outputs.tagname }}"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
ASSETS_URL=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$REPO/releases/tags/$VERSION" \
|
||||
| jq -r '.assets[] | select(.name | test("runtipi-cli.+")) | .browser_download_url')
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
echo "Assets URL: $ASSETS_URL"
|
||||
|
||||
- name: Set version
|
||||
run: pnpm -r --filter cli set-version ${{ needs.create-tag.outputs.tagname }}
|
||||
|
||||
- name: Build CLI
|
||||
run: pnpm -r --filter cli package
|
||||
for url in $ASSETS_URL; do
|
||||
echo "Downloading from $url"
|
||||
curl -L -o "bin/${url##*/}" -H "Accept: application/octet-stream" -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$url"
|
||||
done
|
||||
|
||||
- name: Upload CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli
|
||||
path: packages/cli/dist
|
||||
path: bin
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
@ -152,11 +157,6 @@ jobs:
|
||||
name: cli
|
||||
path: cli
|
||||
|
||||
- name: Rename CLI
|
||||
run: |
|
||||
mv cli/bin/cli-x64 ./runtipi-cli-linux-x64
|
||||
mv cli/bin/cli-arm64 ./runtipi-cli-linux-arm64
|
||||
|
||||
- name: Create beta release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@ -169,9 +169,7 @@ jobs:
|
||||
name: ${{ needs.create-tag.outputs.tagname }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
files: |
|
||||
runtipi-cli-linux-x64
|
||||
runtipi-cli-linux-arm64
|
||||
files: cli/runtipi-cli-*
|
||||
|
||||
e2e-tests:
|
||||
needs: [create-tag, publish-release]
|
||||
|
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@ -57,7 +57,7 @@ jobs:
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
@ -81,22 +81,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: pnpm run test --max-workers ${{ steps.cpu-cores.outputs.count }}
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/lcov.info
|
||||
flags: app
|
||||
|
||||
- name: Run packages tests
|
||||
run: pnpm -r test
|
||||
|
||||
- name: Upload CLI coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/cli/coverage/lcov.info
|
||||
flags: cli
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -120,7 +107,7 @@ jobs:
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
|
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@ -17,4 +17,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v4
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v3
|
||||
uses: actions/dependency-review-action@v4
|
||||
|
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
@ -93,7 +93,7 @@ jobs:
|
||||
curl -s https://raw.githubusercontent.com/runtipi/runtipi/${{ inputs.version }}/scripts/install.sh > install.sh
|
||||
chmod +x install.sh
|
||||
echo 'Running install script'
|
||||
./install.sh --version ${{ inputs.version }}
|
||||
./install.sh --version ${{ inputs.version }} --asset runtipi-cli-linux-x86_64.tar.gz
|
||||
echo 'App deployed'
|
||||
host: ${{ steps.get-droplet-ip.outputs.droplet_ip }}
|
||||
user: root # TODO: use non-root user
|
||||
@ -124,7 +124,7 @@ jobs:
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
|
23
.github/workflows/issue-auto-close.yml
vendored
Normal file
23
.github/workflows/issue-auto-close.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
close-issue-reason: "completed"
|
||||
any-of-issue-labels: "bug"
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
137
.github/workflows/nightly-release.yml
vendored
Normal file
137
.github/workflows/nightly-release.yml
vendored
Normal file
@ -0,0 +1,137 @@
|
||||
name: Nightly Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
TIPI_VERSION=nightly
|
||||
file: ./packages/worker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/worker:nightly
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/worker:buildcache,mode=max
|
||||
|
||||
build-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
TIPI_VERSION=nightly
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ghcr.io/runtipi/runtipi:nightly
|
||||
cache-from: type=registry,ref=ghcr.io/runtipi/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/runtipi/runtipi:buildcache,mode=max
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch an action and get the run ID
|
||||
uses: codex-/return-dispatch@v1
|
||||
id: return_dispatch
|
||||
with:
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
ref: main
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
workflow: nightly.yml
|
||||
|
||||
- name: Await Run ID ${{ steps.return_dispatch.outputs.run_id }}
|
||||
uses: Codex-/await-remote-run@v1.11.0
|
||||
with:
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
run_id: ${{ steps.return_dispatch.outputs.run_id }}
|
||||
run_timeout_seconds: 300
|
||||
poll_interval_ms: 5000
|
||||
|
||||
- name: Create bin folder
|
||||
run: mkdir -p bin
|
||||
|
||||
- name: Download CLI form release on runtipi/cli repo
|
||||
run: |
|
||||
REPO="runtipi/cli"
|
||||
VERSION="nightly"
|
||||
|
||||
ASSETS_URL=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$REPO/releases/tags/$VERSION" \
|
||||
| jq -r '.assets[] | select(.name | test("runtipi-cli.+")) | .browser_download_url')
|
||||
|
||||
echo "Assets URL: $ASSETS_URL"
|
||||
|
||||
for url in $ASSETS_URL; do
|
||||
echo "Downloading from $url"
|
||||
curl -L -o "bin/${url##*/}" -H "Accept: application/octet-stream" -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$url"
|
||||
done
|
||||
|
||||
- name: Upload CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli
|
||||
path: bin
|
||||
|
||||
update-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-worker, build-images, build-cli]
|
||||
steps:
|
||||
- name: Download CLI
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cli
|
||||
path: cli
|
||||
|
||||
- uses: pyTooling/Actions/releaser@r0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: nightly
|
||||
rm: true
|
||||
files: cli/runtipi-cli-*
|
78
.github/workflows/release.yml
vendored
78
.github/workflows/release.yml
vendored
@ -52,11 +52,12 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/runtipi:${{ needs.create-tag.outputs.tagname }},ghcr.io/${{ github.repository_owner }}/runtipi:latest
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runtipi:buildcache,mode=max
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'runtipi/runtipi'
|
||||
needs: create-tag
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@ -91,51 +92,55 @@ jobs:
|
||||
|
||||
build-cli:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: create-tag
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Dispatch an action and get the run ID
|
||||
uses: codex-/return-dispatch@v1
|
||||
id: return_dispatch
|
||||
with:
|
||||
node-version: 20
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
ref: main
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
workflow: build.yml
|
||||
workflow_inputs: '{ "version": "${{ needs.create-tag.outputs.tagname }}" }'
|
||||
|
||||
- uses: pnpm/action-setup@v2.4.0
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
- name: Await Run ID ${{ steps.return_dispatch.outputs.run_id }}
|
||||
uses: Codex-/await-remote-run@v1.11.0
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
token: ${{ secrets.PAT_CLI }}
|
||||
repo: cli
|
||||
owner: runtipi
|
||||
run_id: ${{ steps.return_dispatch.outputs.run_id }}
|
||||
run_timeout_seconds: 300
|
||||
poll_interval_ms: 5000
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
- name: Create bin folder
|
||||
run: mkdir -p bin
|
||||
|
||||
- name: Download CLI form release on runtipi/cli repo
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
REPO="runtipi/cli"
|
||||
VERSION="${{ needs.create-tag.outputs.tagname }}"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
ASSETS_URL=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/$REPO/releases/tags/$VERSION" \
|
||||
| jq -r '.assets[] | select(.name | test("runtipi-cli.+")) | .browser_download_url')
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
echo "Assets URL: $ASSETS_URL"
|
||||
|
||||
- name: Set version
|
||||
run: pnpm -r --filter cli set-version ${{ needs.create-tag.outputs.tagname }}
|
||||
|
||||
- name: Build CLI
|
||||
run: pnpm -r --filter cli package
|
||||
for url in $ASSETS_URL; do
|
||||
echo "Downloading from $url"
|
||||
curl -L -o "bin/${url##*/}" -H "Accept: application/octet-stream" -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$url"
|
||||
done
|
||||
|
||||
- name: Upload CLI
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli
|
||||
path: packages/cli/dist
|
||||
path: bin
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
@ -149,11 +154,6 @@ jobs:
|
||||
name: cli
|
||||
path: cli
|
||||
|
||||
- name: Rename CLI
|
||||
run: |
|
||||
mv cli/bin/cli-x64 ./runtipi-cli-linux-x64
|
||||
mv cli/bin/cli-arm64 ./runtipi-cli-linux-arm64
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@ -166,9 +166,7 @@ jobs:
|
||||
name: ${{ needs.create-tag.outputs.tagname }}
|
||||
draft: false
|
||||
prerelease: true
|
||||
files: |
|
||||
runtipi-cli-linux-x64
|
||||
runtipi-cli-linux-arm64
|
||||
files: cli/runtipi-cli-*
|
||||
|
||||
e2e-tests:
|
||||
needs: [create-tag, publish-release]
|
||||
|
@ -2,5 +2,6 @@ module.exports = {
|
||||
singleQuote: true,
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
tabWidth: 2,
|
||||
printWidth: 150,
|
||||
};
|
||||
|
@ -47,15 +47,15 @@ FROM node_base AS app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
USER node
|
||||
USER 1000:1000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/next.config.mjs ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=1000:1000 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1000:1000 /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
@ -64,9 +64,11 @@ Tipi is licensed under the GNU General Public License v3.0. TL;DR — You may co
|
||||
|
||||
## 🙏 Acknowledgements
|
||||
|
||||
- [GitHub](https://github.com) - Thanks for generously giving us access to your full product suite
|
||||
- [Freepik](https://www.flaticon.com/free-icons/tipi) - Thanks for providing a free logo for the project
|
||||
- [Sentry](https://sentry.io) - Thanks for providing error tracking for the project
|
||||
- [Crowdin](https://crowdin.com) - Thanks for providing localization management for the project
|
||||
- [CodeRabbit](https://coderabbit.ai/) - Thanks for providing free AI code reviews in our Pull Requests
|
||||
|
||||
## ✨ Contributors
|
||||
|
||||
@ -118,7 +120,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://micro.nghialele.com"><img src="https://avatars.githubusercontent.com/u/129353223?v=4?s=100" width="100px;" alt="Nghia Lele"/><br /><sub><b>Nghia Lele</b></sub></a><br /><a href="#translation-nghialele" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/amusingimpala75"><img src="https://avatars.githubusercontent.com/u/69653100?v=4?s=100" width="100px;" alt="amusingimpala75"/><br /><sub><b>amusingimpala75</b></sub></a><br /><a href="https://github.com/runtipi/runtipi/commits?author=amusingimpala75" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://m1n.omg.lol"><img src="https://avatars.githubusercontent.com/u/54779580?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="#translation-M1n-4d316e" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveiliop56"><img src="https://avatars.githubusercontent.com/u/106091011?v=4?s=100" width="100px;" alt="Stavros Iliopoulos"/><br /><sub><b>Stavros Iliopoulos</b></sub></a><br /><a href="#translation-steveiliop56" title="Translation">🌍</a> <a href="https://github.com/runtipi/runtipi/commits?author=steveiliop56" title="Code">💻</a> <a href="https://github.com/runtipi/runtipi/commits?author=steveiliop56" title="Tests">⚠️</a> <a href="https://github.com/runtipi/runtipi/commits?author=steveiliop56" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveiliop56"><img src="https://avatars.githubusercontent.com/u/106091011?v=4?s=100" width="100px;" alt="Stavros"/><br /><sub><b>Stavros</b></sub></a><br /><a href="#translation-steveiliop56" title="Translation">🌍</a> <a href="https://github.com/runtipi/runtipi/commits?author=steveiliop56" title="Code">💻</a> <a href="https://github.com/runtipi/runtipi/commits?author=steveiliop56" title="Tests">⚠️</a> <a href="https://github.com/runtipi/runtipi/commits?author=steveiliop56" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/loxiry"><img src="https://avatars.githubusercontent.com/u/86959495?v=4?s=100" width="100px;" alt="loxiry"/><br /><sub><b>loxiry</b></sub></a><br /><a href="#translation-loxiry" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JigSawFr"><img src="https://avatars.githubusercontent.com/u/5781907?v=4?s=100" width="100px;" alt="JigSaw"/><br /><sub><b>JigSaw</b></sub></a><br /><a href="https://github.com/runtipi/runtipi/commits?author=JigSawFr" title="Code">💻</a></td>
|
||||
</tr>
|
||||
@ -148,4 +150,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
Did you contribute and want to see your name listed in the README? Write a comment [here](https://github.com/runtipi/runtipi/issues/380)
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
@ -3,17 +3,19 @@ version: '3.7'
|
||||
services:
|
||||
tipi-reverse-proxy:
|
||||
container_name: tipi-reverse-proxy
|
||||
image: traefik:v2.8
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
- tipi-dashboard
|
||||
image: traefik:v2.11
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
- 8080:8080
|
||||
command: --providers.docker
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik:/root/.config
|
||||
- ./traefik:/etc/traefik
|
||||
- ./traefik/shared:/shared
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
@ -42,7 +44,7 @@ services:
|
||||
container_name: tipi-redis
|
||||
image: redis:7.2.0
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --stop-writes-on-bgsave-error no
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
@ -61,7 +63,7 @@ services:
|
||||
dockerfile: ./packages/worker/Dockerfile.dev
|
||||
container_name: tipi-worker
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/worker-api/healthcheck']
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
@ -75,13 +77,13 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
TIPI_VERSION: 0.0.0
|
||||
TIPI_VERSION: development
|
||||
volumes:
|
||||
# Dev mode
|
||||
- ./packages/worker/src:/app/packages/worker/src
|
||||
# Production mode
|
||||
- /proc:/host/proc:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./.env:/app/.env
|
||||
- ./state:/app/state
|
||||
- ./repos:/app/repos
|
||||
@ -95,21 +97,32 @@ services:
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.services.worker.loadbalancer.server.port: 3001
|
||||
traefik.http.services.worker-api.loadbalancer.server.port: 3000
|
||||
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
|
||||
# Local ip
|
||||
traefik.http.routers.worker.rule: PathPrefix("/worker")
|
||||
traefik.http.routers.worker.service: worker
|
||||
traefik.http.routers.worker.entrypoints: web
|
||||
traefik.http.routers.worker-api.rule: PathPrefix("/worker-api")
|
||||
traefik.http.routers.worker-api.service: worker-api
|
||||
traefik.http.routers.worker-api.entrypoints: web
|
||||
# Local domain
|
||||
traefik.http.routers.worker-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker")
|
||||
traefik.http.routers.worker-local-insecure.entrypoints: web
|
||||
traefik.http.routers.worker-local-insecure.service: worker
|
||||
traefik.http.routers.worker-local-insecure.middlewares: redirect-to-https
|
||||
traefik.http.routers.worker-api-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api")
|
||||
traefik.http.routers.worker-api-local-insecure.entrypoints: web
|
||||
traefik.http.routers.worker-api-local-insecure.service: worker-api
|
||||
traefik.http.routers.worker-api-local-insecure.middlewares: redirect-to-https
|
||||
# secure
|
||||
traefik.http.routers.worker-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker")
|
||||
traefik.http.routers.worker-local.entrypoints: websecure
|
||||
traefik.http.routers.worker-local.tls: true
|
||||
traefik.http.routers.worker-local.service: worker
|
||||
traefik.http.routers.worker-api-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api")
|
||||
traefik.http.routers.worker-api-local.entrypoints: websecure
|
||||
traefik.http.routers.worker-api-local.tls: true
|
||||
|
||||
tipi-dashboard:
|
||||
build:
|
||||
@ -128,7 +141,8 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
TIPI_VERSION: 0.0.0
|
||||
TIPI_VERSION: development
|
||||
NEXT_PUBLIC_TIPI_VERSION: 0.0.0
|
||||
networks:
|
||||
- tipi_main_network
|
||||
ports:
|
||||
|
@ -3,17 +3,19 @@ version: '3.7'
|
||||
services:
|
||||
tipi-reverse-proxy:
|
||||
container_name: tipi-reverse-proxy
|
||||
image: traefik:v2.8
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
- tipi-dashboard
|
||||
image: traefik:v2.11
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
- 8080:8080
|
||||
command: --providers.docker
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik:/root/.config
|
||||
- ./traefik:/etc/traefik
|
||||
- ./traefik/shared:/shared
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
@ -42,7 +44,7 @@ services:
|
||||
container_name: tipi-redis
|
||||
image: redis:7.2.0
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --stop-writes-on-bgsave-error no
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
@ -61,10 +63,10 @@ services:
|
||||
dockerfile: ./packages/worker/Dockerfile
|
||||
args:
|
||||
- SENTRY_DISABLE_AUTO_UPLOAD=true
|
||||
- TIPI_VERSION=0.0.0
|
||||
- TIPI_VERSION=development
|
||||
container_name: tipi-worker
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/worker-api/healthcheck']
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
@ -78,10 +80,10 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TIPI_VERSION: 0.0.0
|
||||
TIPI_VERSION: development
|
||||
volumes:
|
||||
- /proc:/host/proc
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./.env:/app/.env
|
||||
- ./state:/app/state
|
||||
- ./repos:/app/repos
|
||||
@ -95,21 +97,32 @@ services:
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.services.worker.loadbalancer.server.port: 3001
|
||||
traefik.http.services.worker-api.loadbalancer.server.port: 3000
|
||||
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
|
||||
# Local ip
|
||||
traefik.http.routers.worker.rule: PathPrefix("/worker")
|
||||
traefik.http.routers.worker.service: worker
|
||||
traefik.http.routers.worker.entrypoints: web
|
||||
traefik.http.routers.worker-api.rule: PathPrefix("/worker-api")
|
||||
traefik.http.routers.worker-api.service: worker-api
|
||||
traefik.http.routers.worker-api.entrypoints: web
|
||||
# Local domain
|
||||
traefik.http.routers.worker-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker")
|
||||
traefik.http.routers.worker-local-insecure.entrypoints: web
|
||||
traefik.http.routers.worker-local-insecure.service: worker
|
||||
traefik.http.routers.worker-local-insecure.middlewares: redirect-to-https
|
||||
traefik.http.routers.worker-api-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api")
|
||||
traefik.http.routers.worker-api-local-insecure.entrypoints: web
|
||||
traefik.http.routers.worker-api-local-insecure.service: worker-api
|
||||
traefik.http.routers.worker-api-local-insecure.middlewares: redirect-to-https
|
||||
# secure
|
||||
traefik.http.routers.worker-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker")
|
||||
traefik.http.routers.worker-local.entrypoints: websecure
|
||||
traefik.http.routers.worker-local.tls: true
|
||||
traefik.http.routers.worker-local.service: worker
|
||||
traefik.http.routers.worker-api-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix("/worker-api")
|
||||
traefik.http.routers.worker-api-local.entrypoints: websecure
|
||||
traefik.http.routers.worker-api-local.tls: true
|
||||
|
||||
tipi-dashboard:
|
||||
build:
|
||||
@ -131,6 +144,7 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TIPI_VERSION: 0.0.0
|
||||
NEXT_PUBLIC_TIPI_VERSION: 0.0.0
|
||||
networks:
|
||||
- tipi_main_network
|
||||
ports:
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { promises } from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { settingsSchema, pathExists } from '@runtipi/shared';
|
||||
import { settingsSchema } from '@runtipi/shared';
|
||||
import { pathExists } from '@runtipi/shared/node';
|
||||
import { execRemoteCommand } from './write-remote-file';
|
||||
|
||||
export const setSettings = async (settings: z.infer<typeof settingsSchema>) => {
|
||||
|
56
package.json
56
package.json
@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "runtipi",
|
||||
"version": "2.5.0",
|
||||
"version": "3.0.0",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"clean-containers": "docker rm -f $(docker ps -a -q)",
|
||||
"knip": "knip",
|
||||
"test": "dotenv -e .env.test -- jest --colors",
|
||||
"test:e2e": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test",
|
||||
@ -24,17 +25,18 @@
|
||||
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14",
|
||||
"version": "echo $npm_package_version",
|
||||
"release:rc": "./scripts/deploy/release-rc.sh",
|
||||
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test .",
|
||||
"test:build": "docker buildx build --platform linux/amd64,linux/arm64 -t meienberger/runtipi:test .",
|
||||
"test:build:arm64": "docker buildx build --platform linux/arm64 -t meienberger/runtipi:test .",
|
||||
"test:build:arm7": "docker buildx build --platform linux/arm/v7 -t meienberger/runtipi:test .",
|
||||
"test:build:amd64": "docker buildx build --platform linux/amd64 -t meienberger/runtipi:test .",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
@ -43,23 +45,24 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@runtipi/postgres-migrations": "^5.3.0",
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"@sentry/integrations": "^7.92.0",
|
||||
"@sentry/nextjs": "^7.92.0",
|
||||
"@sentry/integrations": "^7.94.1",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
"@tabler/core": "1.0.0-beta20",
|
||||
"@tabler/icons-react": "^2.45.0",
|
||||
"argon2": "^0.31.2",
|
||||
"bullmq": "^4.13.0",
|
||||
"bullmq": "^5.1.6",
|
||||
"clsx": "^2.1.0",
|
||||
"connect-redis": "^7.1.0",
|
||||
"connect-redis": "^7.1.1",
|
||||
"drizzle-orm": "^0.29.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"geist": "^1.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"let-it-go": "^1.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"next": "14.0.4",
|
||||
"next-client-cookies": "^1.1.0",
|
||||
"next-intl": "^2.22.1",
|
||||
"next-safe-action": "^5.0.2",
|
||||
"next-intl": "^3.4.4",
|
||||
"next-safe-action": "^6.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
@ -67,14 +70,15 @@
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-query": "^3.39.3",
|
||||
"react-select": "^5.8.0",
|
||||
"react-tooltip": "^5.25.0",
|
||||
"react-tooltip": "^5.26.0",
|
||||
"redaxios": "^0.5.1",
|
||||
"redis": "^4.6.12",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.69.5",
|
||||
"sass": "^1.70.0",
|
||||
"semver": "^7.5.4",
|
||||
"sharp": "0.32.6",
|
||||
"socket.io-client": "^4.7.3",
|
||||
@ -84,44 +88,46 @@
|
||||
"validator": "^13.11.0",
|
||||
"winston": "^3.11.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.7"
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.6",
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@testing-library/dom": "^9.3.3",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@total-typescript/shoehorn": "^0.1.1",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"@types/node": "20.8.10",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/react": "18.2.45",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"@types/semver": "^7.5.4",
|
||||
"@types/semver": "^7.5.6",
|
||||
"@types/ssh2": "^1.11.18",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/validator": "^13.11.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^1.1.3",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "8.55.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-jest": "^27.6.3",
|
||||
"eslint-plugin-jest-dom": "^5.1.0",
|
||||
"eslint-plugin-jsonc": "^2.11.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
@ -132,14 +138,14 @@
|
||||
"memfs": "^4.6.0",
|
||||
"msw": "^2.0.11",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier": "^3.2.4",
|
||||
"ssh2": "^1.15.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "5.2.2",
|
||||
"vite-tsconfig-paths": "^4.2.3",
|
||||
"vitest": "^1.1.3",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.1",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
"msw": {
|
||||
|
@ -1,180 +0,0 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
tipi-reverse-proxy:
|
||||
container_name: tipi-reverse-proxy
|
||||
image: traefik:v2.8
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- tipi-dashboard
|
||||
ports:
|
||||
- ${NGINX_PORT:-80}:80
|
||||
- ${NGINX_PORT_SSL:-443}:443
|
||||
command: --providers.docker
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik:/root/.config
|
||||
- ./traefik/shared:/shared
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-db:
|
||||
container_name: tipi-db
|
||||
image: postgres:14
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 1m
|
||||
ports:
|
||||
- ${POSTGRES_PORT:-5432}:5432
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USER: tipi
|
||||
POSTGRES_DB: tipi
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -d tipi -U tipi']
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-redis:
|
||||
container_name: tipi-redis
|
||||
image: redis:7.2.0
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-worker:
|
||||
container_name: tipi-worker
|
||||
image: ghcr.io/runtipi/worker:${TIPI_VERSION}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
start_period: 5s
|
||||
depends_on:
|
||||
tipi-db:
|
||||
condition: service_healthy
|
||||
tipi-redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TIPI_VERSION: ${TIPI_VERSION}
|
||||
volumes:
|
||||
# Core
|
||||
- /proc:/host/proc
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# App
|
||||
- ./.env:/app/.env
|
||||
- ./state:/app/state
|
||||
- ./repos:/app/repos
|
||||
- ./apps:/app/apps
|
||||
- ./logs:/app/logs
|
||||
- ./traefik:/app/traefik
|
||||
- ./user-config:/app/user-config
|
||||
- ./media:/app/media
|
||||
- ${STORAGE_PATH:-.}:/storage
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
# Main
|
||||
traefik.enable: true
|
||||
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
|
||||
traefik.http.services.worker.loadbalancer.server.port: 3001
|
||||
# Local ip
|
||||
traefik.http.routers.worker.rule: PathPrefix("/worker")
|
||||
traefik.http.routers.worker.service: worker
|
||||
traefik.http.routers.worker.entrypoints: web
|
||||
# Websecure
|
||||
traefik.http.routers.worker-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker`)
|
||||
traefik.http.routers.worker-insecure.service: worker
|
||||
traefik.http.routers.worker-insecure.entrypoints: web
|
||||
traefik.http.routers.worker-insecure.middlewares: redirect-to-https
|
||||
traefik.http.routers.worker-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/worker`)
|
||||
traefik.http.routers.worker-secure.service: worker
|
||||
traefik.http.routers.worker-secure.entrypoints: websecure
|
||||
traefik.http.routers.worker-secure.tls.certresolver: myresolver
|
||||
# Local domain
|
||||
traefik.http.routers.worker-local-insecure.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix(`/worker`)
|
||||
traefik.http.routers.worker-local-insecure.entrypoints: web
|
||||
traefik.http.routers.worker-local-insecure.service: worker
|
||||
traefik.http.routers.worker-local-insecure.middlewares: redirect-to-https
|
||||
traefik.http.routers.worker-local.rule: Host(`${LOCAL_DOMAIN}`) && PathPrefix(`/worker`)
|
||||
traefik.http.routers.worker-local.entrypoints: websecure
|
||||
traefik.http.routers.worker-local.tls: true
|
||||
traefik.http.routers.worker-local.service: worker
|
||||
|
||||
tipi-dashboard:
|
||||
image: ghcr.io/runtipi/runtipi:${TIPI_VERSION}
|
||||
restart: unless-stopped
|
||||
container_name: tipi-dashboard
|
||||
networks:
|
||||
- tipi_main_network
|
||||
depends_on:
|
||||
tipi-db:
|
||||
condition: service_healthy
|
||||
tipi-redis:
|
||||
condition: service_healthy
|
||||
tipi-worker:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./.env:/runtipi/.env:ro
|
||||
- ./state:/runtipi/state
|
||||
- ./repos:/runtipi/repos:ro
|
||||
- ./apps:/runtipi/apps
|
||||
- ./traefik:/runtipi/traefik
|
||||
- ./logs:/app/logs
|
||||
- ${STORAGE_PATH:-.}:/app/storage
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TIPI_VERSION: ${TIPI_VERSION}
|
||||
labels:
|
||||
# Main
|
||||
traefik.enable: true
|
||||
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
|
||||
traefik.http.services.dashboard.loadbalancer.server.port: 3000
|
||||
# Local ip
|
||||
traefik.http.routers.dashboard.rule: PathPrefix("/")
|
||||
traefik.http.routers.dashboard.service: dashboard
|
||||
traefik.http.routers.dashboard.entrypoints: web
|
||||
# Websecure
|
||||
traefik.http.routers.dashboard-insecure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
traefik.http.routers.dashboard-insecure.service: dashboard
|
||||
traefik.http.routers.dashboard-insecure.entrypoints: web
|
||||
traefik.http.routers.dashboard-insecure.middlewares: redirect-to-https
|
||||
traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
traefik.http.routers.dashboard-secure.service: dashboard
|
||||
traefik.http.routers.dashboard-secure.entrypoints: websecure
|
||||
traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
|
||||
# Local domain
|
||||
traefik.http.routers.dashboard-local-insecure.rule: Host(`${LOCAL_DOMAIN}`)
|
||||
traefik.http.routers.dashboard-local-insecure.entrypoints: web
|
||||
traefik.http.routers.dashboard-local-insecure.service: dashboard
|
||||
traefik.http.routers.dashboard-local-insecure.middlewares: redirect-to-https
|
||||
traefik.http.routers.dashboard-local.rule: Host(`${LOCAL_DOMAIN}`)
|
||||
traefik.http.routers.dashboard-local.entrypoints: websecure
|
||||
traefik.http.routers.dashboard-local.tls: true
|
||||
traefik.http.routers.dashboard-local.service: dashboard
|
||||
|
||||
networks:
|
||||
tipi_main_network:
|
||||
driver: bridge
|
||||
name: runtipi_tipi_main_network
|
@ -29,7 +29,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@types/cli-progress": "^3.11.5",
|
||||
"@types/node": "20.8.10",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
@ -37,17 +37,17 @@
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"knip": "^3.8.2",
|
||||
"memfs": "^4.6.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"pkg": "^5.8.1",
|
||||
"vite": "^5.0.11",
|
||||
"vite-tsconfig-paths": "^4.2.3",
|
||||
"vitest": "^1.1.3"
|
||||
"vite": "^5.0.12",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"axios": "^1.6.2",
|
||||
"axios": "^1.6.7",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "^4.13.0",
|
||||
"bullmq": "^5.1.6",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"cli-spinners": "^2.9.2",
|
||||
|
@ -9,7 +9,7 @@ import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { Stream } from 'stream';
|
||||
import dotenv from 'dotenv';
|
||||
import { pathExists } from '@runtipi/shared';
|
||||
import { pathExists } from '@runtipi/shared/node';
|
||||
import { AppExecutors } from '../app/app.executors';
|
||||
import { copySystemFiles, generateSystemEnvFile } from './system.helpers';
|
||||
import { TerminalSpinner } from '@/utils/logger/terminal-spinner';
|
||||
|
@ -2,7 +2,8 @@ import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { envMapToString, envStringToMap, pathExists, settingsSchema } from '@runtipi/shared';
|
||||
import { envMapToString, envStringToMap, settingsSchema } from '@runtipi/shared';
|
||||
import { pathExists } from '@runtipi/shared/node';
|
||||
import { logger } from '@/utils/logger/logger';
|
||||
|
||||
type EnvKeys =
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FileLogger } from '@runtipi/shared';
|
||||
import { FileLogger } from '@runtipi/shared/node';
|
||||
import path from 'node:path';
|
||||
|
||||
export const logger = new FileLogger('cli', path.join(process.cwd(), 'logs'));
|
||||
|
6
packages/shared/node/package.json
Normal file
6
packages/shared/node/package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"browser": null,
|
||||
"main": "../src/node/index.ts",
|
||||
"module": "../src/node/index.ts",
|
||||
"types": "../src/node/index.ts"
|
||||
}
|
@ -2,7 +2,27 @@
|
||||
"name": "@runtipi/shared",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./node": {
|
||||
"browser": null,
|
||||
"import": "./src/node/index.ts",
|
||||
"require": "./src/node/index.ts",
|
||||
"default": "./src/node/index.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"node",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .ts src",
|
||||
"tsc": "tsc --noEmit"
|
||||
@ -17,7 +37,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/types": "^7.92.0",
|
||||
"@sentry/types": "^7.94.1",
|
||||
"@types/lodash.clonedeep": "^4.5.9"
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,10 @@ const IgnoreErrors = [
|
||||
// Innocuous browser errors
|
||||
/ResizeObserver loop limit exceeded/,
|
||||
/ResizeObserver loop completed with undelivered notifications/,
|
||||
// Error on user's side
|
||||
/no space left on device/,
|
||||
// Dark reader extension
|
||||
/WeakMap key undefined must be an object or an unregistered symbol/,
|
||||
];
|
||||
|
||||
const cleanseUrl = (url: string) => {
|
||||
@ -55,12 +59,12 @@ export const cleanseErrorData = (event: ErrorEvent, hint: EventHint) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (error && error.message && shouldIgnoreException(error.message)) {
|
||||
if (error && error?.message && shouldIgnoreException(error.message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// IF error message starts with 'Command failed: docker-compose' then grab only the 200 last characters
|
||||
if (error.message.startsWith('Command failed: docker-compose')) {
|
||||
if (error?.message?.startsWith('Command failed: docker-compose')) {
|
||||
// Command failed: docker-compose --env-file /storage/app-data/<app-name>/app.env
|
||||
const appName = error.message.split('/')[3];
|
||||
const message = error.message.slice(-200);
|
||||
|
@ -1 +0,0 @@
|
||||
export * from './fs-helpers';
|
@ -1,2 +0,0 @@
|
||||
export * from './env-helpers';
|
||||
export * from './fs-helpers';
|
@ -1,5 +1,11 @@
|
||||
export * from './schemas';
|
||||
export * from './helpers';
|
||||
export { createLogger } from './utils/logger';
|
||||
export { FileLogger } from './lib/FileLogger';
|
||||
export { execAsync } from './lib/exec-async';
|
||||
// Schemas
|
||||
export { appInfoSchema, formFieldSchema, FIELD_TYPES, APP_CATEGORIES, type AppInfo, type FormField, type AppCategory } from './schemas/app-schemas';
|
||||
export { envSchema, settingsSchema, ARCHITECTURES, type Architecture } from './schemas/env-schemas';
|
||||
export { eventSchema, eventResultSchema, EVENT_TYPES, type EventType, type SystemEvent } from './schemas/queue-schemas';
|
||||
export { linkSchema, type LinkInfo, type LinkInfoInput } from './schemas/link-schemas';
|
||||
export { socketEventSchema, type SocketEvent } from './schemas/socket-schemas';
|
||||
export { systemLoadSchema, type SystemLoad } from './schemas/system-schemas';
|
||||
|
||||
// Helpers
|
||||
export { envMapToString, envStringToMap } from './helpers/env-helpers';
|
||||
export { cleanseErrorData } from './helpers/error-helpers';
|
||||
|
@ -1 +0,0 @@
|
||||
export { FileLogger } from './FileLogger';
|
@ -1 +0,0 @@
|
||||
export { execAsync } from './execAsync';
|
4
packages/shared/src/node/index.ts
Normal file
4
packages/shared/src/node/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { execAsync } from './helpers/exec-async';
|
||||
export { pathExists } from './helpers/fs-helpers';
|
||||
|
||||
export { FileLogger } from './logger/FileLogger';
|
@ -1,6 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createLogger } from '../../utils/logger';
|
||||
import { newLogger as createLogger } from './Logger';
|
||||
|
||||
function streamLogToHistory(logsFolder: string, logFile: string) {
|
||||
return new Promise((resolve, reject) => {
|
@ -93,7 +93,6 @@ export const envSchema = z.object({
|
||||
export const settingsSchema = envSchema
|
||||
.partial()
|
||||
.pick({
|
||||
version: true,
|
||||
dnsIp: true,
|
||||
internalIp: true,
|
||||
postgresPort: true,
|
||||
|
@ -1,3 +0,0 @@
|
||||
export * from './app-schemas';
|
||||
export * from './env-schemas';
|
||||
export * from './queue-schemas';
|
13
packages/shared/src/schemas/link-schemas.ts
Normal file
13
packages/shared/src/schemas/link-schemas.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const linkSchema = z.object({
|
||||
id: z.number().nullable().optional(),
|
||||
title: z.string().min(1).max(20),
|
||||
description: z.string().min(0).max(50).nullable(),
|
||||
url: z.string().url(),
|
||||
iconUrl: z.string().url().or(z.string().max(0)).nullable(),
|
||||
userId: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type LinkInfo = z.output<typeof linkSchema>;
|
||||
export type LinkInfoInput = z.input<typeof linkSchema>;
|
@ -1,20 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const systemInfoSchema = z.object({
|
||||
diskUsed: z.number(),
|
||||
diskSize: z.number(),
|
||||
percentUsed: z.number(),
|
||||
cpuLoad: z.number(),
|
||||
memoryTotal: z.number(),
|
||||
percentUsedMemory: z.number(),
|
||||
});
|
||||
|
||||
export const socketEventSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal('system_info'),
|
||||
event: z.literal('status_change'),
|
||||
data: systemInfoSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('app'),
|
||||
event: z.union([
|
12
packages/shared/src/schemas/system-schemas.ts
Normal file
12
packages/shared/src/schemas/system-schemas.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const systemLoadSchema = z.object({
|
||||
diskUsed: z.number().optional().default(0),
|
||||
diskSize: z.number().optional().default(0),
|
||||
percentUsed: z.number().optional().default(0),
|
||||
cpuLoad: z.number().optional().default(0),
|
||||
memoryTotal: z.number().optional().default(0),
|
||||
percentUsedMemory: z.number().optional().default(0),
|
||||
});
|
||||
|
||||
export type SystemLoad = z.infer<typeof systemLoadSchema>;
|
@ -1 +0,0 @@
|
||||
export { newLogger as createLogger } from './Logger';
|
@ -12,3 +12,4 @@ POSTGRES_DBNAME=postgres
|
||||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_PORT=5433
|
||||
JWT_SECRET=secret
|
||||
|
@ -4,6 +4,7 @@ ARG DOCKER_COMPOSE_VERSION="v2.23.3"
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS node_base
|
||||
|
||||
|
||||
# Install docker
|
||||
RUN apk upgrade --update-cache --available && \
|
||||
apk add openssl git docker docker-cli-compose curl && \
|
||||
@ -39,5 +40,6 @@ COPY ./packages ./packages
|
||||
|
||||
RUN pnpm install -r --prefer-offline
|
||||
|
||||
|
||||
CMD ["pnpm", "--filter", "@runtipi/worker", "-r", "dev"]
|
||||
|
||||
|
@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS "link" (
|
||||
"id" serial NOT NULL,
|
||||
"title" character varying(20) NOT NULL,
|
||||
"url" character varying NOT NULL,
|
||||
"icon_url" character varying,
|
||||
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp NOT NULL DEFAULT now(),
|
||||
"user_id" integer NOT NULL,
|
||||
CONSTRAINT "PK_link" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_link_user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE
|
||||
);
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "link"
|
||||
ADD COLUMN IF NOT EXISTS "description" character varying(50)
|
@ -7,8 +7,8 @@ tls:
|
||||
stores:
|
||||
default:
|
||||
defaultCertificate:
|
||||
certFile: /root/.config/tls/cert.pem
|
||||
keyFile: /root/.config/tls/key.pem
|
||||
certFile: /etc/traefik/tls/cert.pem
|
||||
keyFile: /etc/traefik/tls/key.pem
|
||||
certificates:
|
||||
- certFile: /root/.config/tls/cert.pem
|
||||
keyFile: /root/.config/tls/key.pem
|
||||
- certFile: /etc/traefik/tls/cert.pem
|
||||
keyFile: /etc/traefik/tls/key.pem
|
||||
|
@ -8,7 +8,7 @@ providers:
|
||||
watch: true
|
||||
exposedByDefault: false
|
||||
file:
|
||||
directory: /root/.config/dynamic
|
||||
directory: /etc/traefik/dynamic
|
||||
watch: true
|
||||
|
||||
entryPoints:
|
||||
|
@ -18,6 +18,7 @@ async function bundle() {
|
||||
plugins: [
|
||||
sentryEsbuildPlugin({
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
release: process.env.TIPI_VERSION,
|
||||
org: 'runtipi',
|
||||
project: 'runtipi-worker',
|
||||
}),
|
||||
|
@ -16,30 +16,32 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@sentry/esbuild-plugin": "^2.10.2",
|
||||
"@types/web-push": "^3.6.3",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"esbuild": "^0.19.4",
|
||||
"knip": "^3.8.2",
|
||||
"memfs": "^4.6.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vite-tsconfig-paths": "^4.2.3",
|
||||
"vitest": "^1.1.3"
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.5.0",
|
||||
"@runtipi/postgres-migrations": "^5.3.0",
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"@sentry/esbuild-plugin": "^2.10.2",
|
||||
"@sentry/integrations": "^7.92.0",
|
||||
"@sentry/node": "^7.92.0",
|
||||
"bullmq": "^4.13.0",
|
||||
"@sentry/integrations": "^7.94.1",
|
||||
"@sentry/node": "^7.94.1",
|
||||
"bullmq": "^5.1.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"hono": "^3.12.2",
|
||||
"ioredis": "^5.3.2",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io": "^4.7.2",
|
||||
"systeminformation": "^5.21.15",
|
||||
"systeminformation": "^5.21.22",
|
||||
"web-push": "^3.6.6",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
|
78
packages/worker/src/api.ts
Normal file
78
packages/worker/src/api.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { jwt } from 'hono/jwt';
|
||||
import { prettyJSON } from 'hono/pretty-json';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
import { Hono } from 'hono';
|
||||
import { getEnv } from './lib/environment';
|
||||
import { AppExecutors, SystemExecutors } from './services';
|
||||
|
||||
const apps = new AppExecutors();
|
||||
const system = new SystemExecutors();
|
||||
|
||||
export const setupRoutes = (app: Hono) => {
|
||||
app.get('/healthcheck', (c) => c.text('OK', 200));
|
||||
|
||||
app.use('*', prettyJSON());
|
||||
app.use('*', secureHeaders());
|
||||
|
||||
app.use('*', jwt({ secret: getEnv().jwtSecret, alg: 'HS256' }));
|
||||
|
||||
app.get('/system-status', async (c) => {
|
||||
const result = await system.getSystemLoad();
|
||||
if (result.success) {
|
||||
return c.json({ data: result.data, ok: true }, 200);
|
||||
}
|
||||
return c.json({ message: result.message, ok: false }, 500);
|
||||
});
|
||||
|
||||
app.post('/apps/:id/start', async (c) => {
|
||||
const appId = c.req.param('id');
|
||||
const { success, message } = await apps.startApp(appId, {}, true);
|
||||
if (success) {
|
||||
return c.json({ message, ok: true }, 200);
|
||||
}
|
||||
return c.json({ message, ok: false }, 500);
|
||||
});
|
||||
|
||||
app.post('/apps/:id/stop', async (c) => {
|
||||
const appId = c.req.param('id');
|
||||
const { success, message } = await apps.stopApp(appId, {}, true);
|
||||
if (success) {
|
||||
return c.json({ message, ok: true }, 200);
|
||||
}
|
||||
return c.json({ message, ok: false }, 500);
|
||||
});
|
||||
|
||||
app.post('/apps/:id/reset', async (c) => {
|
||||
const appId = c.req.param('id');
|
||||
const { success, message } = await apps.resetApp(appId, {});
|
||||
if (success) {
|
||||
return c.json({ message, ok: true }, 200);
|
||||
}
|
||||
return c.json({ message, ok: false }, 500);
|
||||
});
|
||||
|
||||
app.post('/apps/:id/update', async (c) => {
|
||||
const appId = c.req.param('id');
|
||||
const { success, message } = await apps.updateApp(appId, {});
|
||||
if (success) {
|
||||
return c.json({ message, ok: true }, 200);
|
||||
}
|
||||
return c.json({ message, ok: false }, 500);
|
||||
});
|
||||
|
||||
app.post('/apps/:id/uninstall', async (c) => {
|
||||
const appId = c.req.param('id');
|
||||
const { success, message } = await apps.uninstallApp(appId, {});
|
||||
if (success) {
|
||||
return c.json({ message, ok: true }, 200);
|
||||
}
|
||||
return c.json({ message, ok: false }, 500);
|
||||
});
|
||||
|
||||
app.post('/apps/start-all', async (c) => {
|
||||
await apps.startAllApps(true);
|
||||
return c.json({ ok: true }, 200);
|
||||
});
|
||||
|
||||
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404));
|
||||
};
|
@ -1,19 +1,20 @@
|
||||
import { SystemEvent } from '@runtipi/shared';
|
||||
import { SystemEvent, cleanseErrorData } from '@runtipi/shared';
|
||||
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import Redis from 'ioredis';
|
||||
import dotenv from 'dotenv';
|
||||
import { Queue } from 'bullmq';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { cleanseErrorData } from '@runtipi/shared/src/helpers/error-helpers';
|
||||
import { ExtraErrorData } from '@sentry/integrations';
|
||||
import { copySystemFiles, ensureFilePermissions, generateSystemEnvFile, generateTlsCertificates } from '@/lib/system';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from '@/lib/system';
|
||||
import { runPostgresMigrations } from '@/lib/migrations';
|
||||
import { startWorker } from './watcher/watcher';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { AppExecutors, RepoExecutors, SystemExecutors } from './services';
|
||||
import { AppExecutors, RepoExecutors } from './services';
|
||||
import { SocketManager } from './lib/socket/SocketManager';
|
||||
import { setupRoutes } from './api';
|
||||
|
||||
const rootFolder = '/app';
|
||||
const envFile = path.join(rootFolder, '.env');
|
||||
@ -41,6 +42,7 @@ const main = async () => {
|
||||
try {
|
||||
await logger.flush();
|
||||
|
||||
logger.info(`Running tipi-worker version: ${process.env.TIPI_VERSION}`);
|
||||
logger.info('Generating system env file...');
|
||||
const envMap = await generateSystemEnvFile();
|
||||
|
||||
@ -59,13 +61,10 @@ const main = async () => {
|
||||
logger.info('Generating TLS certificates...');
|
||||
await generateTlsCertificates({ domain: envMap.get('LOCAL_DOMAIN') });
|
||||
|
||||
logger.info('Ensuring file permissions...');
|
||||
await ensureFilePermissions();
|
||||
|
||||
SocketManager.init();
|
||||
|
||||
const repoExecutors = new RepoExecutors();
|
||||
const systemExecutors = new SystemExecutors();
|
||||
|
||||
const clone = await repoExecutors.cloneRepo(envMap.get('APPS_REPO_URL') as string);
|
||||
if (!clone.success) {
|
||||
logger.error(`Failed to clone repo ${envMap.get('APPS_REPO_URL') as string}`);
|
||||
@ -81,12 +80,9 @@ const main = async () => {
|
||||
logger.info('Obliterating queue...');
|
||||
await queue.obliterate({ force: true });
|
||||
|
||||
await systemExecutors.systemInfo();
|
||||
|
||||
// Scheduled jobs
|
||||
logger.info('Adding scheduled jobs to queue...');
|
||||
await repeatQueue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent, { repeat: { pattern: '*/30 * * * *' } });
|
||||
await repeatQueue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent, { repeat: { every: 3000 } });
|
||||
|
||||
logger.info('Closing queue...');
|
||||
await queue.close();
|
||||
@ -112,18 +108,12 @@ const main = async () => {
|
||||
logger.info('Starting all apps...');
|
||||
appExecutor.startAllApps();
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/healthcheck') {
|
||||
res.writeHead(200);
|
||||
res.end('OK');
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(3000, () => {
|
||||
const app = new Hono().basePath('/worker-api');
|
||||
serve({ fetch: app.fetch, port: 3000 }, (info) => {
|
||||
startWorker();
|
||||
|
||||
setupRoutes(app);
|
||||
logger.info(`Listening on http://localhost:${info.port}`);
|
||||
});
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
|
57
packages/worker/src/lib/db/db.ts
Normal file
57
packages/worker/src/lib/db/db.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import pg from 'pg';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { getEnv } from '../environment';
|
||||
import { logger } from '../logger';
|
||||
|
||||
class DbClientSingleton {
|
||||
private client: pg.Client | null;
|
||||
|
||||
constructor() {
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (!this.client) {
|
||||
try {
|
||||
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
|
||||
|
||||
this.client = new pg.Client({
|
||||
host: postgresHost,
|
||||
database: postgresDatabase,
|
||||
user: postgresUsername,
|
||||
password: postgresPassword,
|
||||
port: Number(postgresPort),
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
logger.info('Database connection successfully established.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to the database:', error);
|
||||
this.client = null; // Ensure client is null to retry connection on next call
|
||||
throw error; // Rethrow or handle error as needed
|
||||
}
|
||||
}
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
Sentry.captureException(error);
|
||||
logger.error('Database connection error:', error);
|
||||
this.client = null;
|
||||
});
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async getClient() {
|
||||
if (!this.client) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
const dbClientSingleton = new DbClientSingleton();
|
||||
|
||||
export const getDbClient = async () => {
|
||||
return dbClientSingleton.getClient();
|
||||
};
|
1
packages/worker/src/lib/db/index.ts
Normal file
1
packages/worker/src/lib/db/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { getDbClient } from './db';
|
@ -7,7 +7,7 @@ import { compose } from './docker-helpers';
|
||||
|
||||
const execAsync = vi.fn().mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' }));
|
||||
|
||||
vi.mock('@runtipi/shared', async (importOriginal) => {
|
||||
vi.mock('@runtipi/shared/node', async (importOriginal) => {
|
||||
const mod = (await importOriginal()) as object;
|
||||
|
||||
return {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { execAsync, pathExists } from '@runtipi/shared';
|
||||
import { execAsync, pathExists } from '@runtipi/shared/node';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
|
||||
|
@ -22,6 +22,7 @@ const environmentSchema = z
|
||||
POSTGRES_PASSWORD: z.string(),
|
||||
POSTGRES_DBNAME: z.string(),
|
||||
POSTGRES_HOST: z.string(),
|
||||
JWT_SECRET: z.string(),
|
||||
})
|
||||
.transform((env) => {
|
||||
const {
|
||||
@ -38,6 +39,7 @@ const environmentSchema = z
|
||||
POSTGRES_USERNAME,
|
||||
POSTGRES_PORT,
|
||||
POSTGRES_HOST,
|
||||
JWT_SECRET,
|
||||
...rest
|
||||
} = env;
|
||||
|
||||
@ -55,6 +57,7 @@ const environmentSchema = z
|
||||
postgresPassword: POSTGRES_PASSWORD,
|
||||
postgresDatabase: POSTGRES_DBNAME,
|
||||
postgresHost: POSTGRES_HOST,
|
||||
jwtSecret: JWT_SECRET,
|
||||
...rest,
|
||||
};
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FileLogger } from '@runtipi/shared';
|
||||
import { FileLogger } from '@runtipi/shared/node';
|
||||
import path from 'node:path';
|
||||
|
||||
export const logger = new FileLogger('worker', path.join('/app', 'logs'), true);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SocketEvent } from '@runtipi/shared/src/schemas/socket';
|
||||
import { SocketEvent } from '@runtipi/shared';
|
||||
import { Server } from 'socket.io';
|
||||
import { logger } from '../logger';
|
||||
|
||||
|
@ -1 +1 @@
|
||||
export { copySystemFiles, generateSystemEnvFile, ensureFilePermissions, generateTlsCertificates } from './system.helpers';
|
||||
export { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from './system.helpers';
|
||||
|
@ -4,7 +4,8 @@ import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { envMapToString, envStringToMap, execAsync, pathExists, settingsSchema } from '@runtipi/shared';
|
||||
import { envMapToString, envStringToMap, settingsSchema } from '@runtipi/shared';
|
||||
import { execAsync, pathExists } from '@runtipi/shared/node';
|
||||
import { logger } from '../logger/logger';
|
||||
import { getRepoHash } from '../../services/repo/repo.helpers';
|
||||
import { ROOT_FOLDER } from '@/config/constants';
|
||||
@ -269,22 +270,3 @@ export const generateTlsCertificates = async (data: { domain?: string }) => {
|
||||
logger.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const ensureFilePermissions = async () => {
|
||||
const filesAndFolders = [path.join(ROOT_FOLDER, 'state'), path.join(ROOT_FOLDER, 'traefik'), path.join(ROOT_FOLDER, 'media'), path.join(ROOT_FOLDER, 'apps')];
|
||||
|
||||
const files600 = [path.join(ROOT_FOLDER, 'traefik', 'shared', 'acme.json')];
|
||||
|
||||
// Give permission to read and write to all files and folders for the current user
|
||||
for (const directory of filesAndFolders) {
|
||||
if (await pathExists(directory)) {
|
||||
await execAsync(`find ${directory} -type d -exec chmod a+rwx {} +`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
for (const fileOrFolder of files600) {
|
||||
if (await pathExists(fileOrFolder)) {
|
||||
await execAsync(`chmod 600 ${fileOrFolder}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { pathExists } from '@runtipi/shared';
|
||||
import { pathExists } from '@runtipi/shared/node';
|
||||
import { AppExecutors } from '../app.executors';
|
||||
import { createAppConfig } from '@/tests/apps.factory';
|
||||
import * as dockerHelpers from '@/lib/docker';
|
||||
@ -28,7 +28,7 @@ describe('test: app executors', () => {
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(message).toBe(`App ${config.id} installed successfully`);
|
||||
expect(spy).toHaveBeenCalledWith(config.id, 'up -d');
|
||||
expect(spy).toHaveBeenCalledWith(config.id, 'up --detach --force-recreate --remove-orphans --pull always');
|
||||
expect(envExists).toBe(true);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { pathExists } from '@runtipi/shared';
|
||||
import { pathExists } from '@runtipi/shared/node';
|
||||
import { copyDataDir, generateEnvFile } from '../app.helpers';
|
||||
import { createAppConfig } from '@/tests/apps.factory';
|
||||
import { getAppEnvMap } from '../env.helpers';
|
||||
|
@ -2,32 +2,16 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { execAsync, pathExists } from '@runtipi/shared';
|
||||
import { SocketEvent } from '@runtipi/shared/src/schemas/socket';
|
||||
import { execAsync, pathExists } from '@runtipi/shared/node';
|
||||
import { SocketEvent } from '@runtipi/shared';
|
||||
import { copyDataDir, generateEnvFile } from './app.helpers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { compose } from '@/lib/docker';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
|
||||
import { SocketManager } from '@/lib/socket/SocketManager';
|
||||
|
||||
const getDbClient = async () => {
|
||||
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
|
||||
|
||||
const client = new pg.Client({
|
||||
host: postgresHost,
|
||||
database: postgresDatabase,
|
||||
user: postgresUsername,
|
||||
password: postgresPassword,
|
||||
port: Number(postgresPort),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
return client;
|
||||
};
|
||||
import { getDbClient } from '@/lib/db';
|
||||
|
||||
export class AppExecutors {
|
||||
private readonly logger;
|
||||
@ -68,7 +52,7 @@ export class AppExecutors {
|
||||
* @param {string} appId - App id
|
||||
*/
|
||||
private ensureAppDir = async (appId: string) => {
|
||||
const { appDirPath, repoPath } = this.getAppPaths(appId);
|
||||
const { appDirPath, appDataDirPath, repoPath } = this.getAppPaths(appId);
|
||||
const dockerFilePath = path.join(ROOT_FOLDER, 'apps', appId, 'docker-compose.yml');
|
||||
|
||||
if (!(await pathExists(dockerFilePath))) {
|
||||
@ -80,6 +64,10 @@ export class AppExecutors {
|
||||
this.logger.info(`Copying app ${appId} from repo ${getEnv().appsRepoId}`);
|
||||
await fs.promises.cp(repoPath, appDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
await execAsync(`chmod -R 770 ${path.join(appDataDirPath)}`).catch(() => {
|
||||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
};
|
||||
|
||||
public regenerateAppEnv = async (appId: string, config: Record<string, unknown>) => {
|
||||
@ -148,13 +136,11 @@ export class AppExecutors {
|
||||
await copyDataDir(appId);
|
||||
}
|
||||
|
||||
await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => {
|
||||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
await this.ensureAppDir(appId);
|
||||
|
||||
// run docker-compose up
|
||||
this.logger.info(`Running docker-compose up for app ${appId}`);
|
||||
await compose(appId, 'up -d');
|
||||
await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always');
|
||||
|
||||
this.logger.info(`Docker-compose up for app ${appId} finished`);
|
||||
|
||||
@ -196,6 +182,8 @@ export class AppExecutors {
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'stop_success', data: { appId } });
|
||||
|
||||
const client = await getDbClient();
|
||||
await client?.query('UPDATE app SET status = $1 WHERE id = $2', ['stopped', appId]);
|
||||
return { success: true, message: `App ${appId} stopped successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'stop_error');
|
||||
@ -206,8 +194,6 @@ export class AppExecutors {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
const { appDataDirPath } = this.getAppPaths(appId);
|
||||
|
||||
this.logger.info(`Starting app ${appId}`);
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
@ -221,13 +207,10 @@ export class AppExecutors {
|
||||
|
||||
this.logger.info(`App ${appId} started`);
|
||||
|
||||
this.logger.info(`Setting permissions for app ${appId}`);
|
||||
await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => {
|
||||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'start_success', data: { appId } });
|
||||
|
||||
const client = await getDbClient();
|
||||
await client?.query('UPDATE app SET status = $1 WHERE id = $2', ['running', appId]);
|
||||
return { success: true, message: `App ${appId} started successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'start_error');
|
||||
@ -268,6 +251,8 @@ export class AppExecutors {
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'uninstall_success', data: { appId } });
|
||||
|
||||
const client = await getDbClient();
|
||||
await client?.query(`DELETE FROM app WHERE id = $1`, [appId]);
|
||||
return { success: true, message: `App ${appId} uninstalled successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'uninstall_error');
|
||||
@ -310,10 +295,7 @@ export class AppExecutors {
|
||||
await copyDataDir(appId);
|
||||
}
|
||||
|
||||
// Set permissions
|
||||
await execAsync(`chmod -R a+rwx ${path.join(appDataDirPath)}`).catch(() => {
|
||||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
await this.ensureAppDir(appId);
|
||||
|
||||
// run docker-compose up
|
||||
this.logger.info(`Running docker-compose up for app ${appId}`);
|
||||
@ -321,6 +303,8 @@ export class AppExecutors {
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'reset_success', data: { appId } });
|
||||
|
||||
const client = await getDbClient();
|
||||
await client?.query(`UPDATE app SET status = $1 WHERE id = $2`, ['running', appId]);
|
||||
return { success: true, message: `App ${appId} reset successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'reset_error');
|
||||
@ -349,6 +333,8 @@ export class AppExecutors {
|
||||
this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`);
|
||||
await fs.promises.cp(repoPath, appDirPath, { recursive: true });
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
|
||||
await compose(appId, 'pull');
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'update_success', data: { appId } });
|
||||
@ -362,15 +348,23 @@ export class AppExecutors {
|
||||
/**
|
||||
* Start all apps with status running
|
||||
*/
|
||||
public startAllApps = async () => {
|
||||
const client = await getDbClient();
|
||||
|
||||
public startAllApps = async (forceStartAll = false) => {
|
||||
try {
|
||||
// Get all apps with status running
|
||||
const { rows } = await client.query(`SELECT * FROM app WHERE status = 'running'`);
|
||||
const client = await getDbClient();
|
||||
let rows: { id: string; config: Record<string, unknown> }[] = [];
|
||||
|
||||
if (!forceStartAll) {
|
||||
// Get all apps with status running
|
||||
const result = await client?.query(`SELECT * FROM app WHERE status = 'running'`);
|
||||
rows = result?.rows || [];
|
||||
} else {
|
||||
// Get all apps
|
||||
const result = await client?.query(`SELECT * FROM app`);
|
||||
rows = result?.rows || [];
|
||||
}
|
||||
|
||||
// Update all apps with status different than running or stopped to stopped
|
||||
await client.query(`UPDATE app SET status = 'stopped' WHERE status != 'stopped' AND status != 'running' AND status != 'missing'`);
|
||||
await client?.query(`UPDATE app SET status = 'stopped' WHERE status != 'stopped' AND status != 'running' AND status != 'missing'`);
|
||||
|
||||
// Start all apps
|
||||
for (const row of rows) {
|
||||
@ -380,15 +374,13 @@ export class AppExecutors {
|
||||
|
||||
if (!success) {
|
||||
this.logger.error(`Error starting app ${id}`);
|
||||
await client.query(`UPDATE app SET status = 'stopped' WHERE id = '${id}'`);
|
||||
await client?.query(`UPDATE app SET status = $1 WHERE id = $2`, ['stopped', id]);
|
||||
} else {
|
||||
await client.query(`UPDATE app SET status = 'running' WHERE id = '${id}'`);
|
||||
await client?.query(`UPDATE app SET status = $1 WHERE id = $2`, ['running', id]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Error starting apps: ${err}`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { appInfoSchema, envMapToString, envStringToMap, execAsync, pathExists } from '@runtipi/shared';
|
||||
import { appInfoSchema, envMapToString, envStringToMap } from '@runtipi/shared';
|
||||
import { pathExists, execAsync } from '@runtipi/shared/node';
|
||||
import { generateVapidKeys, getAppEnvMap } from './env.helpers';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { execAsync, pathExists } from '@runtipi/shared';
|
||||
import { execAsync, pathExists } from '@runtipi/shared/node';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { getRepoHash, getRepoBaseUrlAndBranch } from './repo.helpers';
|
||||
import { logger } from '@/lib/logger';
|
||||
@ -56,6 +56,10 @@ export class RepoExecutors {
|
||||
|
||||
await execAsync(cloneCommand);
|
||||
|
||||
// Chmod the repo folder to 777
|
||||
this.logger.info(`Executing: chmod -R 777 ${repoPath}`);
|
||||
await execAsync(`chmod -R 777 ${repoPath}`);
|
||||
|
||||
this.logger.info(`Cloned repo ${repoUrl} to ${repoPath}`);
|
||||
return { success: true, message: '' };
|
||||
} catch (err) {
|
||||
|
@ -1,22 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import si from 'systeminformation';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { ROOT_FOLDER } from '@/config/constants';
|
||||
import { SocketManager } from '../../lib/socket/SocketManager';
|
||||
|
||||
export class SystemExecutors {
|
||||
private readonly logger;
|
||||
|
||||
private cacheTime: number;
|
||||
|
||||
private cacheTimeout: number;
|
||||
|
||||
constructor(cacheTimeout = 15000) {
|
||||
constructor() {
|
||||
this.logger = logger;
|
||||
this.cacheTime = 0;
|
||||
this.cacheTimeout = cacheTimeout;
|
||||
}
|
||||
|
||||
private handleSystemError = (err: unknown) => {
|
||||
@ -24,57 +15,42 @@ export class SystemExecutors {
|
||||
|
||||
if (err instanceof Error) {
|
||||
this.logger.error(`An error occurred: ${err.message}`);
|
||||
return { success: false, message: err.message };
|
||||
return { success: false as const, message: err.message };
|
||||
}
|
||||
this.logger.error(`An error occurred: ${err}`);
|
||||
|
||||
return { success: false, message: `An error occurred: ${String(err)}` };
|
||||
return { success: false as const, message: `An error occurred: ${String(err)}` };
|
||||
};
|
||||
|
||||
private getSystemLoad = async () => {
|
||||
const { currentLoad } = await si.currentLoad();
|
||||
|
||||
const memResult = { total: 0, used: 0, available: 0 };
|
||||
|
||||
public getSystemLoad = async () => {
|
||||
try {
|
||||
const memInfo = await fs.promises.readFile('/host/proc/meminfo');
|
||||
const { currentLoad } = await si.currentLoad();
|
||||
|
||||
memResult.total = Number(memInfo.toString().match(/MemTotal:\s+(\d+)/)?.[1] ?? 0) * 1024;
|
||||
memResult.available = Number(memInfo.toString().match(/MemAvailable:\s+(\d+)/)?.[1] ?? 0) * 1024;
|
||||
memResult.used = memResult.total - memResult.available;
|
||||
} catch (e) {
|
||||
this.logger.error(`Unable to read /host/proc/meminfo: ${e}`);
|
||||
}
|
||||
const memResult = { total: 0, used: 0, available: 0 };
|
||||
|
||||
const [disk0] = await si.fsSize();
|
||||
try {
|
||||
const memInfo = await fs.promises.readFile('/host/proc/meminfo');
|
||||
|
||||
const disk = disk0 ?? { available: 0, size: 0 };
|
||||
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
|
||||
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
|
||||
const diskUsed = diskSize - diskFree;
|
||||
const percentUsed = Math.round((diskUsed / diskSize) * 100);
|
||||
|
||||
const memoryTotal = Math.round(Number(memResult.total) / 1024 / 1024 / 1024);
|
||||
const memoryFree = Math.round(Number(memResult.available) / 1024 / 1024 / 1024);
|
||||
const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100);
|
||||
|
||||
return { diskUsed, diskSize, percentUsed, cpuLoad: currentLoad, memoryTotal, percentUsedMemory };
|
||||
};
|
||||
|
||||
public systemInfo = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const systemLoad = await this.getSystemLoad();
|
||||
|
||||
SocketManager.emit({ type: 'system_info', event: 'status_change', data: systemLoad });
|
||||
|
||||
if (now - this.cacheTime > this.cacheTimeout) {
|
||||
await fs.promises.writeFile(path.join(ROOT_FOLDER, 'state', 'system-info.json'), JSON.stringify(systemLoad, null, 2));
|
||||
await fs.promises.chmod(path.join(ROOT_FOLDER, 'state', 'system-info.json'), 0o777);
|
||||
this.cacheTime = Date.now();
|
||||
memResult.total = Number(memInfo.toString().match(/MemTotal:\s+(\d+)/)?.[1] ?? 0) * 1024;
|
||||
memResult.available = Number(memInfo.toString().match(/MemAvailable:\s+(\d+)/)?.[1] ?? 0) * 1024;
|
||||
memResult.used = memResult.total - memResult.available;
|
||||
} catch (e) {
|
||||
this.logger.error(`Unable to read /host/proc/meminfo: ${e}`);
|
||||
}
|
||||
|
||||
return { success: true, message: '' };
|
||||
const [disk0] = await si.fsSize();
|
||||
|
||||
const disk = disk0 ?? { available: 0, size: 0 };
|
||||
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
|
||||
const diskSize = Math.round(disk.size / 1024 / 1024 / 1024);
|
||||
const diskUsed = diskSize - diskFree;
|
||||
const percentUsed = Math.round((diskUsed / diskSize) * 100);
|
||||
|
||||
const memoryTotal = Math.round(Number(memResult.total) / 1024 / 1024 / 1024);
|
||||
const memoryFree = Math.round(Number(memResult.available) / 1024 / 1024 / 1024);
|
||||
const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100);
|
||||
|
||||
return { success: true as const, data: { diskUsed, diskSize, percentUsed, cpuLoad: currentLoad, memoryTotal, percentUsedMemory } };
|
||||
} catch (e) {
|
||||
return this.handleSystemError(e);
|
||||
}
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { eventSchema } from '@runtipi/shared';
|
||||
import { Worker } from 'bullmq';
|
||||
import { AppExecutors, RepoExecutors, SystemExecutors } from '@/services';
|
||||
import { AppExecutors, RepoExecutors } from '@/services';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
|
||||
const { systemInfo } = new SystemExecutors();
|
||||
const { installApp, resetApp, startApp, stopApp, uninstallApp, updateApp, regenerateAppEnv } = new AppExecutors();
|
||||
const { cloneRepo, pullRepo } = new RepoExecutors();
|
||||
|
||||
@ -56,10 +55,6 @@ const runCommand = async (jobData: unknown) => {
|
||||
if (data.command === 'update') {
|
||||
({ success, message } = await pullRepo(data.url));
|
||||
}
|
||||
} else if (data.type === 'system') {
|
||||
if (data.command === 'system_info') {
|
||||
({ success, message } = await systemInfo());
|
||||
}
|
||||
}
|
||||
|
||||
return { success, message };
|
||||
|
@ -3,7 +3,7 @@ import path from 'path';
|
||||
import { vi, beforeEach } from 'vitest';
|
||||
import { ROOT_FOLDER } from '@/config/constants';
|
||||
|
||||
vi.mock('@runtipi/shared', async (importOriginal) => {
|
||||
vi.mock('@runtipi/shared/node', async (importOriginal) => {
|
||||
const mod = (await importOriginal()) as object;
|
||||
|
||||
return {
|
||||
|
1552
pnpm-lock.yaml
1552
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -17,35 +17,34 @@ fi
|
||||
### --------------------------------
|
||||
UPDATE="false"
|
||||
VERSION="latest"
|
||||
ASSET="runtipi-cli-linux-x64"
|
||||
ASSET="runtipi-cli-linux-x86_64.tar.gz"
|
||||
|
||||
while [ -n "${1-}" ]; do
|
||||
case "$1" in
|
||||
--update) UPDATE="true" ;;
|
||||
--version)
|
||||
shift # Move to the next parameter
|
||||
VERSION="$1" # Assign the value to VERSION
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Option --version requires a value" && exit 1
|
||||
fi
|
||||
;;
|
||||
--asset)
|
||||
shift # Move to the next parameter
|
||||
ASSET="$1" # Assign the value to ASSET
|
||||
if [ -z "$ASSET" ]; then
|
||||
echo "Option --asset requires a value" && exit 1
|
||||
fi
|
||||
;;
|
||||
--)
|
||||
shift # The double dash makes them parameters
|
||||
break
|
||||
;;
|
||||
*) echo "Option $1 not recognized" && exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
case "$1" in
|
||||
--update) UPDATE="true" ;;
|
||||
--version)
|
||||
shift # Move to the next parameter
|
||||
VERSION="$1" # Assign the value to VERSION
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Option --version requires a value" && exit 1
|
||||
fi
|
||||
;;
|
||||
--asset)
|
||||
shift # Move to the next parameter
|
||||
ASSET="$1" # Assign the value to ASSET
|
||||
if [ -z "$ASSET" ]; then
|
||||
echo "Option --asset requires a value" && exit 1
|
||||
fi
|
||||
;;
|
||||
--)
|
||||
shift # The double dash makes them parameters
|
||||
break
|
||||
;;
|
||||
*) echo "Option $1 not recognized" && exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
|
||||
OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
|
||||
SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"' || echo 'unknown')"
|
||||
|
||||
@ -68,17 +67,17 @@ function install_generic() {
|
||||
elif [[ "${os}" == "fedora" ]]; then
|
||||
sudo dnf -y install "${dependency}"
|
||||
return 0
|
||||
elif [[ "${os}" == "arch" ]]; then
|
||||
if ! sudo pacman -Sy --noconfirm "${dependency}" ; then
|
||||
if command -v yay > /dev/null 2>&1 ; then
|
||||
sudo -u $SUDO_USER yay -Sy --noconfirm "${dependency}"
|
||||
elif [[ "${os}" == "arch" || "${os}" == "manjaro" ]]; then
|
||||
if ! sudo pacman -Sy --noconfirm "${dependency}"; then
|
||||
if command -v yay >/dev/null 2>&1; then
|
||||
sudo -u "$SUDO_USER" yay -Sy --noconfirm "${dependency}"
|
||||
else
|
||||
echo "Could not install \"${dependency}\", either using pacman or the yay AUR helper. Please try installing it manually."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
|
||||
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
@ -125,7 +124,7 @@ function install_docker() {
|
||||
sudo systemctl start docker
|
||||
sudo systemctl enable docker
|
||||
return 0
|
||||
elif [[ "${os}" == "arch" ]]; then
|
||||
elif [[ "${os}" == "arch" || "${os}" == "manjaro" ]]; then
|
||||
sudo pacman -Sy --noconfirm docker docker-compose
|
||||
sudo systemctl start docker.service
|
||||
sudo systemctl enable docker.service
|
||||
@ -186,11 +185,8 @@ if [[ "${VERSION}" == "latest" ]]; then
|
||||
VERSION="${LATEST_VERSION}"
|
||||
fi
|
||||
|
||||
# Temporary workaround to support current assets before the release of the new version
|
||||
if [[ "$ARCHITECTURE" == "arm64" || "$ARCHITECTURE" == "aarch64" ]]; then
|
||||
if [[ "$ASSET" == "runtipi-cli-linux-x64" ]]; then
|
||||
ASSET="runtipi-cli-linux-arm64"
|
||||
fi
|
||||
ASSET="runtipi-cli-linux-aarch64.tar.gz"
|
||||
fi
|
||||
|
||||
URL="https://github.com/runtipi/runtipi/releases/download/$VERSION/$ASSET"
|
||||
@ -204,6 +200,9 @@ fi
|
||||
if [[ "$ASSET" == *".tar.gz" ]]; then
|
||||
curl --location "$URL" -o ./runtipi-cli.tar.gz
|
||||
tar -xzf ./runtipi-cli.tar.gz
|
||||
|
||||
asset_name=$(tar -tzf ./runtipi-cli.tar.gz | head -n 1 | cut -f1 -d"/")
|
||||
mv "./${asset_name}" ./runtipi-cli
|
||||
rm ./runtipi-cli.tar.gz
|
||||
else
|
||||
curl --location "$URL" -o ./runtipi-cli
|
||||
|
29
scripts/update-2.0.0-to-3.0.0.sh
Executable file
29
scripts/update-2.0.0-to-3.0.0.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
ARCHITECTURE="$(uname -m)"
|
||||
|
||||
ASSET="runtipi-cli-linux-x86_64.tar.gz"
|
||||
if [[ "$ARCHITECTURE" == "arm64" || "$ARCHITECTURE" == "aarch64" ]]; then
|
||||
ASSET="runtipi-cli-linux-aarch64.tar.gz"
|
||||
fi
|
||||
|
||||
URL="https://github.com/runtipi/runtipi/releases/download/v3.0.0/$ASSET"
|
||||
|
||||
rm -f ./runtipi-cli
|
||||
|
||||
if [[ "$ASSET" == *".tar.gz" ]]; then
|
||||
curl --location "$URL" -o ./runtipi-cli.tar.gz
|
||||
tar -xzf ./runtipi-cli.tar.gz
|
||||
|
||||
asset_name=$(tar -tzf ./runtipi-cli.tar.gz | head -n 1 | cut -f1 -d"/")
|
||||
mv "./${asset_name}" ./runtipi-cli
|
||||
rm ./runtipi-cli.tar.gz
|
||||
else
|
||||
curl --location "$URL" -o ./runtipi-cli
|
||||
fi
|
||||
|
||||
chmod +x ./runtipi-cli
|
||||
sudo ./runtipi-cli start
|
@ -1,7 +1,6 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { ExtraErrorData } from '@sentry/integrations';
|
||||
import { settingsSchema } from '@runtipi/shared/src/schemas/env-schemas';
|
||||
import { cleanseErrorData } from '@runtipi/shared/src/helpers/error-helpers';
|
||||
import { settingsSchema, cleanseErrorData } from '@runtipi/shared';
|
||||
|
||||
const getClientConfig = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
@ -19,16 +18,17 @@ const getClientConfig = () => {
|
||||
return parsedSettings;
|
||||
};
|
||||
|
||||
const { allowErrorMonitoring, version } = getClientConfig();
|
||||
const { allowErrorMonitoring } = getClientConfig();
|
||||
|
||||
if (allowErrorMonitoring && process.env.NODE_ENV === 'production') {
|
||||
Sentry.init({
|
||||
release: version,
|
||||
release: process.env.NEXT_PUBLIC_TIPI_VERSION,
|
||||
environment: process.env.NODE_ENV,
|
||||
dsn: 'https://7a73d72f886948478b55621e7b92c3c7@o4504242900238336.ingest.sentry.io/4504826587971584',
|
||||
beforeSend: cleanseErrorData,
|
||||
integrations: [new ExtraErrorData()],
|
||||
initialScope: {
|
||||
tags: { version },
|
||||
tags: { version: process.env.TIPI_VERSION },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import { ExtraErrorData } from '@sentry/integrations';
|
||||
import { cleanseErrorData } from '@runtipi/shared/src/helpers/error-helpers';
|
||||
import { cleanseErrorData } from '@runtipi/shared';
|
||||
|
||||
const { version, allowErrorMonitoring, NODE_ENV } = TipiConfig.getConfig();
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { ExtraErrorData } from '@sentry/integrations';
|
||||
import { TipiConfig } from '@/server/core/TipiConfig';
|
||||
import { cleanseErrorData } from '@runtipi/shared/src/helpers/error-helpers';
|
||||
import { cleanseErrorData } from '@runtipi/shared';
|
||||
|
||||
const { version, allowErrorMonitoring, NODE_ENV } = TipiConfig.getConfig();
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { loginAction } from '@/actions/login/login-action';
|
||||
|
@ -20,7 +20,7 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
|
||||
const t = useTranslations('auth');
|
||||
const t = useTranslations();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -37,24 +37,33 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<h2 className="h2 text-center mb-3">{t('login.title')}</h2>
|
||||
<Input {...register('email')} name="email" label={t('form.email')} error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder={t('form.email-placeholder')} />
|
||||
<span className="form-label-description">
|
||||
<Link href="/reset-password">{t('form.forgot')}</Link>
|
||||
</span>
|
||||
<h2 className="h2 text-center mb-3">{t('AUTH_LOGIN_TITLE')}</h2>
|
||||
<Input
|
||||
{...register('email')}
|
||||
name="email"
|
||||
label={t('AUTH_FORM_EMAIL')}
|
||||
error={errors.email?.message}
|
||||
disabled={loading}
|
||||
type="email"
|
||||
className="mb-3"
|
||||
placeholder={t('AUTH_FORM_EMAIL_PLACEHOLDER')}
|
||||
/>
|
||||
<Input
|
||||
{...register('password')}
|
||||
name="password"
|
||||
label={t('form.password')}
|
||||
label={t('AUTH_FORM_PASSWORD')}
|
||||
error={errors.password?.message}
|
||||
disabled={loading}
|
||||
type="password"
|
||||
className="mb-3"
|
||||
placeholder={t('form.password-placeholder')}
|
||||
placeholder={t('AUTH_FORM_PASSWORD_PLACEHOLDER')}
|
||||
/>
|
||||
<Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
|
||||
{t('login.submit')}
|
||||
{t('AUTH_LOGIN_SUBMIT')}
|
||||
</Button>
|
||||
<div className="form-text text-center">
|
||||
<Link href="/reset-password">{t('AUTH_FORM_FORGOT')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
@ -10,7 +10,7 @@ type Props = {
|
||||
|
||||
export const TotpForm = (props: Props) => {
|
||||
const { onSubmit, loading } = props;
|
||||
const t = useTranslations('auth');
|
||||
const t = useTranslations();
|
||||
const [totpCode, setTotpCode] = React.useState('');
|
||||
|
||||
return (
|
||||
@ -22,11 +22,11 @@ export const TotpForm = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<h3 className="">{t('totp.title')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('totp.instructions')}</p>
|
||||
<h3 className="">{t('AUTH_TOTP_TITLE')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('AUTH_TOTP_INSTRUCTIONS')}</p>
|
||||
<OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
|
||||
<Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
|
||||
{t('totp.submit')}
|
||||
{t('AUTH_TOTP_SUBMIT')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { registerAction } from '@/actions/register/register-action';
|
||||
|
@ -14,18 +14,18 @@ interface IProps {
|
||||
type FormValues = { email: string; password: string; passwordConfirm: string };
|
||||
|
||||
export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
|
||||
const t = useTranslations('auth');
|
||||
const t = useTranslations();
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8, t('form.errors.password.minlength')),
|
||||
passwordConfirm: z.string().min(8, t('form.errors.password.minlength')),
|
||||
password: z.string().min(8, t('AUTH_ERROR_INVALID_PASSWORD_LENGTH')),
|
||||
passwordConfirm: z.string().min(8, t('AUTH_ERROR_INVALID_PASSWORD_LENGTH')),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.passwordConfirm) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('form.errors.password-confirmation.match'),
|
||||
message: t('AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_MATCH'),
|
||||
path: ['passwordConfirm'],
|
||||
});
|
||||
}
|
||||
@ -40,20 +40,36 @@ export const RegisterForm: React.FC<IProps> = ({ onSubmit, loading }) => {
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<h2 className="h2 text-center mb-3">{t('register.title')}</h2>
|
||||
<Input {...register('email')} label={t('form.email')} error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder={t('form.email-placeholder')} />
|
||||
<Input {...register('password')} label={t('form.password')} error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder={t('form.password-placeholder')} />
|
||||
<h2 className="h2 text-center mb-3">{t('AUTH_REGISTER_TITLE')}</h2>
|
||||
<Input
|
||||
{...register('email')}
|
||||
label={t('AUTH_FORM_EMAIL')}
|
||||
error={errors.email?.message}
|
||||
disabled={loading}
|
||||
type="email"
|
||||
className="mb-3"
|
||||
placeholder={t('AUTH_FORM_EMAIL_PLACEHOLDER')}
|
||||
/>
|
||||
<Input
|
||||
{...register('password')}
|
||||
label={t('AUTH_FORM_PASSWORD')}
|
||||
error={errors.password?.message}
|
||||
disabled={loading}
|
||||
type="password"
|
||||
className="mb-3"
|
||||
placeholder={t('AUTH_FORM_PASSWORD_PLACEHOLDER')}
|
||||
/>
|
||||
<Input
|
||||
{...register('passwordConfirm')}
|
||||
label={t('form.password-confirmation')}
|
||||
label={t('AUTH_FORM_PASSWORD_CONFIRMATION')}
|
||||
error={errors.passwordConfirm?.message}
|
||||
disabled={loading}
|
||||
type="password"
|
||||
className="mb-3"
|
||||
placeholder={t('form.password-confirmation-placeholder')}
|
||||
placeholder={t('AUTH_FORM_PASSWORD_CONFIRMATION_PLACEHOLDER')}
|
||||
/>
|
||||
<Button loading={loading} type="submit" className="btn btn-primary w-100">
|
||||
{t('register.submit')}
|
||||
{t('AUTH_REGISTER_SUBMIT')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -29,10 +29,10 @@ export const ResetPasswordContainer: React.FC = () => {
|
||||
if (resetPasswordMutation.result.data?.success && resetPasswordMutation.result.data?.email) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
|
||||
<p>{t('auth.reset-password.success', { email: resetPasswordMutation.result.data.email })}</p>
|
||||
<h2 className="h2 text-center mb-3">{t('AUTH_RESET_PASSWORD_SUCCESS_TITLE')}</h2>
|
||||
<p>{t('AUTH_RESET_PASSWORD_SUCCESS', { email: resetPasswordMutation.result.data.email })}</p>
|
||||
<Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
|
||||
{t('auth.reset-password.back-to-login')}
|
||||
{t('AUTH_RESET_PASSWORD_BACK_TO_LOGIN')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
@ -15,17 +15,17 @@ interface IProps {
|
||||
type FormValues = { password: string; passwordConfirm: string };
|
||||
|
||||
export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCancel }) => {
|
||||
const t = useTranslations('auth');
|
||||
const t = useTranslations();
|
||||
const schema = z
|
||||
.object({
|
||||
password: z.string().min(8, t('form.errors.password.minlength')),
|
||||
passwordConfirm: z.string().min(8, t('form.errors.password.minlength')),
|
||||
password: z.string().min(8, t('AUTH_FORM_ERROR_PASSWORD_LENGTH')),
|
||||
passwordConfirm: z.string().min(8, t('AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_LENGTH')),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.passwordConfirm) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('form.errors.password-confirmation.match'),
|
||||
message: t('AUTH_FORM_ERROR_PASSWORD_CONFIRMATION_MATCH'),
|
||||
path: ['passwordConfirm'],
|
||||
});
|
||||
}
|
||||
@ -41,30 +41,30 @@ export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCance
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<h2 className="h2 text-center mb-3">{t('reset-password.title')}</h2>
|
||||
<h2 className="h2 text-center mb-3">{t('AUTH_RESET_PASSWORD_TITLE')}</h2>
|
||||
<Input
|
||||
{...register('password')}
|
||||
label={t('form.password')}
|
||||
label={t('AUTH_FORM_PASSWORD')}
|
||||
error={errors.password?.message}
|
||||
disabled={loading}
|
||||
type="password"
|
||||
className="mb-3"
|
||||
placeholder={t('form.new-password-placeholder')}
|
||||
placeholder={t('AUTH_FORM_NEW_PASSWORD_PLACEHOLDER')}
|
||||
/>
|
||||
<Input
|
||||
{...register('passwordConfirm')}
|
||||
label={t('form.password-confirmation')}
|
||||
label={t('AUTH_FORM_PASSWORD_CONFIRMATION')}
|
||||
error={errors.passwordConfirm?.message}
|
||||
disabled={loading}
|
||||
type="password"
|
||||
className="mb-3"
|
||||
placeholder={t('form.new-password-confirmation-placeholder')}
|
||||
placeholder={t('AUTH_FORM_NEW_PASSWORD_CONFIRMATION_PLACEHOLDER')}
|
||||
/>
|
||||
<Button loading={loading} type="submit" className="btn btn-primary w-100">
|
||||
{t('reset-password.submit')}
|
||||
{t('AUTH_RESET_PASSWORD_SUBMIT')}
|
||||
</Button>
|
||||
<Button onClick={onCancel} type="button" className="btn btn-secondary w-100 mt-3">
|
||||
{t('reset-password.cancel')}
|
||||
{t('AUTH_RESET_PASSWORD_CANCEL')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
@ -13,8 +13,8 @@ export default async function ResetPasswordPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-3">{translator('auth.reset-password.title')}</h2>
|
||||
<p>{translator('auth.reset-password.instructions')}</p>
|
||||
<h2 className="h2 text-center mb-3">{translator('AUTH_RESET_PASSWORD_TITLE')}</h2>
|
||||
<p>{translator('AUTH_RESET_PASSWORD_INSTRUCTIONS')}</p>
|
||||
<pre>
|
||||
<code>./runtipi-cli reset-password</code>
|
||||
</pre>
|
||||
|
@ -80,34 +80,34 @@ export const AppActions: React.FC<IProps> = ({
|
||||
onUpdateSettings,
|
||||
}) => {
|
||||
const { info } = app;
|
||||
const t = useTranslations('apps.app-details');
|
||||
const t = useTranslations();
|
||||
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
|
||||
|
||||
const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
|
||||
const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title={t('actions.remove')} color="danger" />;
|
||||
const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title={t('actions.settings')} />;
|
||||
const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title={t('actions.stop')} color="danger" />;
|
||||
const LoadingButtion = <ActionButton key="loading" loading color="success" title={t('actions.loading')} />;
|
||||
const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title={t('actions.cancel')} />;
|
||||
const InstallButton = <ActionButton key="install" onClick={onInstall} title={t('actions.install')} color="success" />;
|
||||
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('APP_ACTION_START')} color="success" />;
|
||||
const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title={t('APP_ACTION_REMOVE')} color="danger" />;
|
||||
const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title={t('APP_ACTION_SETTINGS')} />;
|
||||
const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title={t('APP_ACTION_STOP')} color="danger" />;
|
||||
const LoadingButtion = <ActionButton key="loading" loading color="success" title={t('APP_ACTION_LOADING')} />;
|
||||
const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title={t('APP_ACTION_CANCEL')} />;
|
||||
const InstallButton = <ActionButton key="install" onClick={onInstall} title={t('APP_ACTION_INSTALL')} color="success" />;
|
||||
const UpdateButton = (
|
||||
<ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('actions.update')} color="success" />
|
||||
<ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('APP_ACTION_UPDATE')} color="success" />
|
||||
);
|
||||
|
||||
const OpenButton = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button width={140} className={clsx('me-2 px-4 mt-2')}>
|
||||
{t('actions.open')}
|
||||
{t('APP_ACTION_OPEN')}
|
||||
<IconExternalLink className="ms-1" size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t('choose-open-method')}</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t('APP_DETAILS_CHOOSE_OPEN_METHOD')}</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{app.exposed && app.domain && (
|
||||
<DropdownMenuItem onClick={() => onOpen('domain')}>
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useDisclosure } from '@/client/hooks/useDisclosure';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { installAppAction } from '@/actions/app-actions/install-app-action';
|
||||
import { uninstallAppAction } from '@/actions/app-actions/uninstall-app-action';
|
||||
import { stopAppAction } from '@/actions/app-actions/stop-app-action';
|
||||
@ -100,7 +100,7 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
|
||||
updateSettingsDisclosure.close();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('apps.app-details.update-config-success'));
|
||||
toast.success(t('APP_UPDATE_CONFIG_SUCCESS'));
|
||||
},
|
||||
});
|
||||
|
||||
@ -192,7 +192,7 @@ export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, l
|
||||
<AppLogo id={app.id} size={130} alt={app.info.name} />
|
||||
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
|
||||
<div>
|
||||
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
|
||||
<span className="mt-1 me-1">{t('APP_DETAILS_VERSION')}: </span>
|
||||
<span className="badge bg-muted mt-2 text-white">{app.info.version}</span>
|
||||
</div>
|
||||
<span className="mt-1 text-muted text-center text-md-start mb-2">{app.info.short_desc}</span>
|
||||
|
@ -11,13 +11,13 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
|
||||
const t = useTranslations('apps.app-details');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="description" orientation="vertical" style={{ marginTop: -1 }}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="description">{t('description')}</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('base-info')}</TabsTrigger>
|
||||
<TabsTrigger value="description">{t('APP_DETAILS_DESCRIPTION')}</TabsTrigger>
|
||||
<TabsTrigger value="info">{t('APP_DETAILS_BASE_INFO')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="description">
|
||||
{info.deprecated && (
|
||||
@ -27,8 +27,8 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
|
||||
<IconAlertCircle />
|
||||
</div>
|
||||
<div className="ms-2">
|
||||
<h4 className="alert-title">{t('deprecated-alert-title')}</h4>
|
||||
<div className="text-secondary">{t('deprecated-alert-subtitle')} </div>
|
||||
<h4 className="alert-title">{t('APP_DETAILS_DEPRECATED_ALERT_TITLE')}</h4>
|
||||
<div className="text-secondary">{t('APP_DETAILS_DEPRECATED_ALERT_SUBTITLE')} </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -37,26 +37,26 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
|
||||
</TabsContent>
|
||||
<TabsContent value="info">
|
||||
<DataGrid>
|
||||
<DataGridItem title={t('source-code')}>
|
||||
<DataGridItem title={t('APP_DETAILS_SOURCE_CODE')}>
|
||||
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
|
||||
{t('link')}
|
||||
{t('APP_DETAILS_LINK')}
|
||||
<IconExternalLink size={15} className="ms-1 mb-1" />
|
||||
</a>
|
||||
</DataGridItem>
|
||||
<DataGridItem title={t('author')}>{info.author}</DataGridItem>
|
||||
<DataGridItem title={t('port')}>
|
||||
<DataGridItem title={t('APP_DETAILS_AUTHOR')}>{info.author}</DataGridItem>
|
||||
<DataGridItem title={t('APP_DETAILS_PORT')}>
|
||||
<b>{info.port}</b>
|
||||
</DataGridItem>
|
||||
<DataGridItem title={t('categories-title')}>
|
||||
<DataGridItem title={t('APP_DETAILS_CATEGORIES_TITLE')}>
|
||||
{info.categories.map((c) => (
|
||||
<div key={c} className="badge text-white bg-green me-1">
|
||||
{t(`categories.${c}`)}
|
||||
{t(`APP_CATEGORY_${c.toUpperCase() as Uppercase<typeof c>}`)}
|
||||
</div>
|
||||
))}
|
||||
</DataGridItem>
|
||||
<DataGridItem title={t('version')}>{info.version}</DataGridItem>
|
||||
<DataGridItem title={t('APP_DETAILS_VERSION')}>{info.version}</DataGridItem>
|
||||
{info.supported_architectures && (
|
||||
<DataGridItem title={t('supported-arch')}>
|
||||
<DataGridItem title={t('APP_DETAILS_SUPPORTED_ARCH')}>
|
||||
{info.supported_architectures.map((a) => (
|
||||
<div key={a} className="badge text-white bg-red me-1">
|
||||
{a.toLowerCase()}
|
||||
@ -65,7 +65,7 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
|
||||
</DataGridItem>
|
||||
)}
|
||||
{info.website && (
|
||||
<DataGridItem title={t('website')}>
|
||||
<DataGridItem title={t('APP_DETAILS_WEBSITE')}>
|
||||
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.website}>
|
||||
{info.website}
|
||||
<IconExternalLink size={15} className="ms-1 mb-1" />
|
||||
|
@ -33,7 +33,7 @@ const hiddenTypes = ['random'];
|
||||
const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
|
||||
|
||||
export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, initalValues, loading, onReset, status }) => {
|
||||
const t = useTranslations('apps.app-details.install-form');
|
||||
const t = useTranslations();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -102,7 +102,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
|
||||
render={({ field: { onChange, value, ref, ...props } }) => (
|
||||
<Select value={value as string} defaultValue={field.default as string} onValueChange={onChange} {...props}>
|
||||
<SelectTrigger className="mb-3" error={errors[field.env_variable]?.message} label={label}>
|
||||
<SelectValue placeholder={t('choose-option')} />
|
||||
<SelectValue placeholder={t('APP_INSTALL_FORM_CHOOSE_OPTION')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
@ -144,14 +144,20 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={info.force_expose}
|
||||
label={t('expose-app')}
|
||||
label={t('APP_INSTALL_FORM_EXPOSE_APP')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{watchExposed && (
|
||||
<div className="mb-3">
|
||||
<Input {...register('domain')} label={t('domain-name')} error={errors.domain?.message} disabled={loading} placeholder={t('domain-name')} />
|
||||
<span className="text-muted">{t('domain-name-hint')}</span>
|
||||
<Input
|
||||
{...register('domain')}
|
||||
label={t('APP_INSTALL_FORM_DOMAIN_NAME')}
|
||||
error={errors.domain?.message}
|
||||
disabled={loading}
|
||||
placeholder={t('APP_INSTALL_FORM_DOMAIN_NAME')}
|
||||
/>
|
||||
<span className="text-muted">{t('APP_INSTALL_FORM_DOMAIN_NAME_HINT')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -186,17 +192,17 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
{...props}
|
||||
label={t('display-on-guest-dashboard')}
|
||||
label={t('APP_INSTALL_FORM_DISPLAY_ON_GUEST_DASHBOARD')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{info.exposable && renderExposeForm()}
|
||||
<Button loading={loading} type="submit" className="btn-success">
|
||||
{initalValues ? t('submit-update') : t('sumbit-install')}
|
||||
{initalValues ? t('APP_INSTALL_FORM_SUBMIT_UPDATE') : t('APP_INSTALL_FORM_SUBMIT_INSTALL')}
|
||||
</Button>
|
||||
{initalValues && onReset && (
|
||||
<Button loading={status === 'stopping'} onClick={onClickReset} className="btn-danger ms-2">
|
||||
{t('reset')}
|
||||
{t('APP_INSTALL_FORM_RESET')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
@ -13,13 +13,13 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => {
|
||||
const t = useTranslations('apps.app-details.install-form');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_INSTALL_FORM_TITLE', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<ScrollArea maxHeight={500}>
|
||||
<DialogDescription>
|
||||
|
@ -14,22 +14,22 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const ResetAppModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm, isLoading }) => {
|
||||
const t = useTranslations('apps.app-details.reset-app-form');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent type="danger" size="sm">
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_RESET_FORM_TITLE', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-center py-4">
|
||||
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
|
||||
<h3>{t('warning')}</h3>
|
||||
<div className="text-muted">{t('subtitle')}</div>
|
||||
<h3>{t('APP_RESET_FORM_WARNING')}</h3>
|
||||
<div className="text-muted">{t('APP_RESET_FORM_SUBTITLE')}</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button loading={isLoading} onClick={onConfirm} className="btn-danger">
|
||||
{t('submit')}
|
||||
{t('APP_RESET_FORM_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@ -12,20 +12,20 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
|
||||
const t = useTranslations('apps.app-details.stop-form');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_STOP_FORM_TITLE', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<div className="text-muted">{t('subtitle')}</div>
|
||||
<div className="text-muted">{t('APP_STOP_FORM_SUBTITLE')}</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm} className="btn-danger">
|
||||
{t('submit')}
|
||||
{t('APP_STOP_FORM_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@ -13,22 +13,22 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
|
||||
const t = useTranslations('apps.app-details.uninstall-form');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent type="danger" size="sm">
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_UNINSTALL_FORM_TITLE', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-center py-4">
|
||||
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
|
||||
<h3>{t('warning')}</h3>
|
||||
<div className="text-muted">{t('subtitle')}</div>
|
||||
<h3>{t('APP_UNINSTALL_FORM_WARNING')}</h3>
|
||||
<div className="text-muted">{t('APP_UNINSTALL_FORM_SUBTITLE')}</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm} className="btn-danger">
|
||||
{t('submit')}
|
||||
{t('APP_UNINSTALL_FORM_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@ -13,23 +13,23 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => {
|
||||
const t = useTranslations('apps.app-details.update-form');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_UPDATE_FORM_TITLE', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
<div className="text-muted">
|
||||
{t('subtitle1')} <b>{newVersion}</b> ?<br />
|
||||
{t('subtitle2')}
|
||||
{t('APP_UPDATE_FORM_SUBTITLE_1')} <b>{newVersion}</b> ?<br />
|
||||
{t('APP_UPDATE_FORM_SUBTITLE_2')}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm} className="btn-success">
|
||||
{t('submit')}
|
||||
{t('APP_UPDATE_FORM_SUBMIT')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@ -17,17 +17,24 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, onReset, status }) => {
|
||||
const t = useTranslations('apps.app-details.update-settings-form');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
|
||||
<h5 className="modal-title">{t('APP_UPDATE_SETTINGS_FORM_TITLE', { name: info.name })}</h5>
|
||||
</DialogHeader>
|
||||
<ScrollArea maxHeight={500}>
|
||||
<DialogDescription>
|
||||
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config }} onReset={onReset} status={status} />
|
||||
<InstallForm
|
||||
onSubmit={onSubmit}
|
||||
formFields={info.form_fields}
|
||||
info={info}
|
||||
initalValues={{ ...config }}
|
||||
onReset={onReset}
|
||||
status={status}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
|
@ -6,7 +6,7 @@ export const validateField = (field: FormField, value: string | undefined | bool
|
||||
const { translator } = useUIStore.getState();
|
||||
|
||||
if (field.required && !value && typeof value !== 'boolean') {
|
||||
return translator('apps.app-details.install-form.errors.required', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_REQUIRED', { label: field.label });
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'string') {
|
||||
@ -14,51 +14,51 @@ export const validateField = (field: FormField, value: string | undefined | bool
|
||||
}
|
||||
|
||||
if (field.regex && !validator.matches(value, field.regex)) {
|
||||
return field.pattern_error || translator('apps.app-details.install-form.errors.regex', { label: field.label, pattern: field.regex });
|
||||
return field.pattern_error || translator('APP_INSTALL_FORM_ERROR_REGEX', { label: field.label, pattern: field.regex });
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
if (field.max && value.length > field.max) {
|
||||
return translator('apps.app-details.install-form.errors.max-length', { label: field.label, max: field.max });
|
||||
return translator('APP_INSTALL_FORM_ERROR_MAX_LENGTH', { label: field.label, max: field.max });
|
||||
}
|
||||
if (field.min && value.length < field.min) {
|
||||
return translator('apps.app-details.install-form.errors.min-length', { label: field.label, min: field.min });
|
||||
return translator('APP_INSTALL_FORM_ERROR_MIN_LENGTH', { label: field.label, min: field.min });
|
||||
}
|
||||
break;
|
||||
case 'password':
|
||||
if (!validator.isLength(value, { min: field.min || 0, max: field.max || 100 })) {
|
||||
return translator('apps.app-details.install-form.errors.between-length', { label: field.label, min: field.min, max: field.max });
|
||||
return translator('APP_INSTALL_FORM_ERROR_BETWEEN_LENGTH', { label: field.label, min: field.min, max: field.max });
|
||||
}
|
||||
break;
|
||||
case 'email':
|
||||
if (!validator.isEmail(value)) {
|
||||
return translator('apps.app-details.install-form.errors.invalid-email', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_INVALID_EMAIL', { label: field.label });
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
if (!validator.isNumeric(value)) {
|
||||
return translator('apps.app-details.install-form.errors.number', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_NUMBER', { label: field.label });
|
||||
}
|
||||
break;
|
||||
case 'fqdn':
|
||||
if (!validator.isFQDN(value)) {
|
||||
return translator('apps.app-details.install-form.errors.fqdn', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_FQDN', { label: field.label });
|
||||
}
|
||||
break;
|
||||
case 'ip':
|
||||
if (!validator.isIP(value)) {
|
||||
return translator('apps.app-details.install-form.errors.ip', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_IP', { label: field.label });
|
||||
}
|
||||
break;
|
||||
case 'fqdnip':
|
||||
if (!validator.isFQDN(value || '') && !validator.isIP(value)) {
|
||||
return translator('apps.app-details.install-form.errors.fqdnip', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_FQDNIP', { label: field.label });
|
||||
}
|
||||
break;
|
||||
case 'url':
|
||||
if (!validator.isURL(value)) {
|
||||
return translator('apps.app-details.install-form.errors.url', { label: field.label });
|
||||
return translator('APP_INSTALL_FORM_ERROR_URL', { label: field.label });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@ -71,7 +71,7 @@ export const validateField = (field: FormField, value: string | undefined | bool
|
||||
const validateDomain = (domain?: string | boolean): string | undefined => {
|
||||
if (typeof domain !== 'string' || !validator.isFQDN(domain || '')) {
|
||||
const { translator } = useUIStore.getState();
|
||||
return translator('apps.app-details.install-form.errors.fqdn', { label: String(domain) });
|
||||
return translator('APP_INSTALL_FORM_ERROR_FQDN', { label: String(domain) });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
@ -14,10 +14,13 @@ interface IProps {
|
||||
export const AppStoreTable: React.FC<IProps> = ({ data }) => {
|
||||
const { category, search, sort, sortDirection } = useAppStoreState();
|
||||
|
||||
const tableData = React.useMemo(() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }), [data, sort, sortDirection, category, search]);
|
||||
const tableData = React.useMemo(
|
||||
() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }),
|
||||
[data, sort, sortDirection, category, search],
|
||||
);
|
||||
|
||||
if (!tableData.length) {
|
||||
return <EmptyPage title="apps.app-store.no-results" subtitle="apps.app-store.no-results-subtitle" />;
|
||||
return <EmptyPage title="APP_STORE_NO_RESULTS" subtitle="APP_STORE_NO_RESULTS_SUBTITLE" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -10,11 +10,16 @@ import { CategorySelector } from '../CategorySelector';
|
||||
|
||||
export const AppStoreTableActions = () => {
|
||||
const { setCategory, category, search, setSearch } = useAppStoreState();
|
||||
const t = useTranslations('apps.app-store');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('search-placeholder')} className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('APP_STORE_SEARCH_PLACEHOLDER')}
|
||||
className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)}
|
||||
/>
|
||||
<CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
|
||||
</div>
|
||||
);
|
||||
|
@ -18,7 +18,7 @@ type App = {
|
||||
};
|
||||
|
||||
export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
|
||||
const t = useTranslations('apps.app-details');
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Link aria-label={app.name} className={clsx('cursor-pointer col-sm-6 col-lg-4 p-2 mt-4', styles.appTile)} href={`/app-store/${app.id}`} passHref>
|
||||
@ -29,7 +29,7 @@ export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
|
||||
<p className="text-muted text-nowrap mb-2">{limitText(app.short_desc, 30)}</p>
|
||||
{app.categories?.map((category) => (
|
||||
<div className={`text-white badge me-1 bg-${colorSchemeForCategory[category]}`} key={`${app.id}-${category}`}>
|
||||
{t(`categories.${category}`)}
|
||||
{t(`APP_CATEGORY_${category.toUpperCase() as Uppercase<typeof category>}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -48,11 +48,11 @@ const ControlComponent = (props: ControlProps<OptionsType>) => {
|
||||
};
|
||||
|
||||
export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
|
||||
const t = useTranslations('apps');
|
||||
const t = useTranslations();
|
||||
const { darkMode } = useUIStore();
|
||||
const options: OptionsType[] = iconForCategory.map((category) => ({
|
||||
value: category.id,
|
||||
label: t(`app-details.categories.${category.id}`),
|
||||
label: t(`APP_CATEGORY_${category.id.toUpperCase() as Uppercase<typeof category.id>}`),
|
||||
icon: category.icon,
|
||||
}));
|
||||
|
||||
@ -108,7 +108,7 @@ export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initia
|
||||
defaultValue={[]}
|
||||
name="categories"
|
||||
options={options}
|
||||
placeholder={t('app-store.category-placeholder')}
|
||||
placeholder={t('APP_STORE_CATEGORY_PLACEHOLDER')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user