Merge pull request #1211 from runtipi/release/3.0.0

Release/3.0.0
This commit is contained in:
Nicolas Meienberger 2024-02-11 12:23:00 +01:00 committed by GitHub
commit f2eca3ad34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
190 changed files with 11709 additions and 11216 deletions

View File

@ -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": [

View File

@ -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/**

View File

@ -2,4 +2,9 @@
.eslintrc.js
next.config.js
jest.config.js
packages/
/packages
/repos
.next/
/app-data
/apps
package.json

View File

@ -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,

View File

@ -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

View File

@ -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-*

View File

@ -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]

View File

@ -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 }}

View File

@ -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

View File

@ -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
View 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
View 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-*

View File

@ -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]

View File

@ -2,5 +2,6 @@ module.exports = {
singleQuote: true,
semi: true,
trailingComma: 'all',
tabWidth: 2,
printWidth: 150,
};

View File

@ -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

View File

@ -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!

View File

@ -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:

View File

@ -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:

View File

@ -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>) => {

View File

@ -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": {

View File

@ -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

View File

@ -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",

View File

@ -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';

View File

@ -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 =

View File

@ -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'));

View File

@ -0,0 +1,6 @@
{
"browser": null,
"main": "../src/node/index.ts",
"module": "../src/node/index.ts",
"types": "../src/node/index.ts"
}

View File

@ -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"
}
}

View File

@ -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);

View File

@ -1 +0,0 @@
export * from './fs-helpers';

View File

@ -1,2 +0,0 @@
export * from './env-helpers';
export * from './fs-helpers';

View File

@ -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';

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export { execAsync } from './helpers/exec-async';
export { pathExists } from './helpers/fs-helpers';
export { FileLogger } from './logger/FileLogger';

View File

@ -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) => {

View File

@ -93,7 +93,6 @@ export const envSchema = z.object({
export const settingsSchema = envSchema
.partial()
.pick({
version: true,
dnsIp: true,
internalIp: true,
postgresPort: true,

View File

@ -1,3 +0,0 @@
export * from './app-schemas';
export * from './env-schemas';
export * from './queue-schemas';

View 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>;

View File

@ -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([

View 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>;

View File

@ -1 +0,0 @@
export { newLogger as createLogger } from './Logger';

View File

@ -12,3 +12,4 @@ POSTGRES_DBNAME=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433
JWT_SECRET=secret

View File

@ -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"]

View File

@ -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
);

View File

@ -0,0 +1,2 @@
ALTER TABLE "link"
ADD COLUMN IF NOT EXISTS "description" character varying(50)

View File

@ -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

View File

@ -8,7 +8,7 @@ providers:
watch: true
exposedByDefault: false
file:
directory: /root/.config/dynamic
directory: /etc/traefik/dynamic
watch: true
entryPoints:

View File

@ -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',
}),

View File

@ -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"
}

View 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));
};

View File

@ -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);

View 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();
};

View File

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

View File

@ -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 {

View File

@ -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';

View File

@ -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,
};
});

View File

@ -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);

View File

@ -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';

View File

@ -1 +1 @@
export { copySystemFiles, generateSystemEnvFile, ensureFilePermissions, generateTlsCertificates } from './system.helpers';
export { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from './system.helpers';

View File

@ -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(() => {});
}
}
};

View File

@ -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();
});

View File

@ -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';

View File

@ -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 {
const client = await getDbClient();
let rows: { id: string; config: Record<string, unknown> }[] = [];
if (!forceStartAll) {
// Get all apps with status running
const { rows } = await client.query(`SELECT * FROM app WHERE 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();
}
};
}

View File

@ -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';

View File

@ -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) {

View File

@ -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,14 +15,15 @@ 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 () => {
public getSystemLoad = async () => {
try {
const { currentLoad } = await si.currentLoad();
const memResult = { total: 0, used: 0, available: 0 };
@ -58,23 +50,7 @@ export class SystemExecutors {
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();
}
return { success: true, message: '' };
return { success: true as const, data: { diskUsed, diskSize, percentUsed, cpuLoad: currentLoad, memoryTotal, percentUsedMemory } };
} catch (e) {
return this.handleSystemError(e);
}

View File

@ -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 };

View File

@ -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 {

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ 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
@ -45,7 +45,6 @@ while [ -n "${1-}" ]; do
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,10 +67,10 @@ 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
@ -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

View 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

View File

@ -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 },
},
});
}

View File

@ -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();

View File

@ -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();

View File

@ -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';

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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';

View File

@ -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>
);

View File

@ -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>
</>
);

View File

@ -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>
);

View File

@ -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>

View File

@ -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')}>

View File

@ -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>

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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 (

View File

@ -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>
);

View File

@ -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>

View File

@ -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