Merge pull request #468 from meienberger/release/1.5.0

Release/1.5.0
This commit is contained in:
Nicolas Meienberger 2023-06-10 14:47:36 +02:00 committed by GitHub
commit 496747f5cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 3375 additions and 1418 deletions

View file

@ -236,6 +236,51 @@
"code",
"doc"
]
},
{
"login": "NoisyFridge",
"name": "NoisyFridge",
"avatar_url": "https://avatars.githubusercontent.com/u/73795785?v=4",
"profile": "https://github.com/NoisyFridge",
"contributions": [
"translation"
]
},
{
"login": "Bvoxl",
"name": "Bvoxl",
"avatar_url": "https://avatars.githubusercontent.com/u/67489519?v=4",
"profile": "https://github.com/Bvoxl",
"contributions": [
"translation"
]
},
{
"login": "m-lab-0",
"name": "m-lab-0",
"avatar_url": "https://avatars.githubusercontent.com/u/116570617?v=4",
"profile": "https://github.com/m-lab-0",
"contributions": [
"translation"
]
},
{
"login": "dannkunt",
"name": "dannkunt",
"avatar_url": "https://avatars.githubusercontent.com/u/32395839?v=4",
"profile": "https://github.com/dannkunt",
"contributions": [
"translation"
]
},
{
"login": "Schmanko",
"name": "Schmanko",
"avatar_url": "https://avatars.githubusercontent.com/u/94195393?v=4",
"profile": "https://github.com/Schmanko",
"contributions": [
"translation"
]
}
],
"contributorsPerLine": 7,
@ -244,5 +289,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true,
"commitConvention": "angular"
"commitConvention": "angular",
"commitType": "docs"
}

View file

@ -13,7 +13,7 @@ ROOT_FOLDER_HOST=/Users/nicolas/Projects/runtipi
NGINX_PORT=3000
NGINX_PORT_SSL=443
POSTGRES_PASSWORD=postgres
DOMAIN=tipi.localhost
DOMAIN=example.com
STORAGE_PATH=/Users/nicolas/Projects/runtipi
REDIS_HOST=tipi-redis

View file

@ -31,7 +31,10 @@ module.exports = {
'react/jsx-props-no-spreading': 0,
'react/no-unused-prop-types': 0,
'react/button-has-type': 0,
'import/no-extraneous-dependencies': ['error', { devDependencies: ['esbuild.js', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/*.factory.{ts,tsx}', '**/mocks/**', 'tests/**', '**/*.d.ts'] }],
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: ['esbuild.js', 'e2e/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/*.factory.{ts,tsx}', '**/mocks/**', 'tests/**', '**/*.d.ts'] },
],
'no-underscore-dangle': 0,
'arrow-body-style': 0,
'class-methods-use-this': 0,

17
.github/stale.yml vendored Normal file
View file

@ -0,0 +1,17 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 30
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions. If you feel this issue should remain open, please
add a comment with the requested information and we will keep it open.
closeComment: false

View file

@ -11,6 +11,7 @@ env:
REDIS_HOST: redis
APPS_REPO_URL: https://repo.github.com/
DOMAIN: localhost
LOCAL_DOMAIN: tipi.lan
TIPI_VERSION: 0.0.1
jobs:
@ -36,19 +37,19 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
- uses: pnpm/action-setup@v2.2.2
- uses: pnpm/action-setup@v2.2.4
name: Install pnpm
id: pnpm-install
with:
version: 7
version: 8
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
name: Setup pnpm cache

View file

@ -17,4 +17,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@v3
- name: 'Dependency Review'
uses: actions/dependency-review-action@v1
uses: actions/dependency-review-action@v3

238
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,238 @@
name: E2E Tests
on:
workflow_dispatch:
push:
branches:
- release/*
jobs:
build:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push images
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64
push: true
tags: meienberger/runtipi:e2e
cache-from: type=registry,ref=meienberger/runtipi:buildcache-e2e
cache-to: type=registry,ref=meienberger/runtipi:buildcache-e2e,mode=max
deploy:
timeout-minutes: 15
runs-on: ubuntu-latest
needs: [build]
outputs:
droplet_id: ${{ steps.create-droplet.outputs.droplet_id }}
droplet_ip: ${{ steps.get-droplet-ip.outputs.droplet_ip }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install SSH key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.SSH_KEY }}
known_hosts: unnecessary
name: id_rsa
- name: Get sha of last commit
id: get-sha
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Create new Droplet
id: create-droplet
run: |
droplet_id=$(doctl compute droplet create runtipi-${{ steps.get-sha.outputs.sha }} \
--image ubuntu-20-04-x64 \
--size s-1vcpu-1gb \
--format ID \
--no-header \
--ssh-keys ${{ secrets.SSH_KEY_FINGERPRINT }})
echo "droplet_id=$droplet_id" >> $GITHUB_OUTPUT
- name: Wait for Droplet to become active
run: |
while ! doctl compute droplet get ${{ steps.create-droplet.outputs.droplet_id }} --format Status --no-header | grep -q "active"; do sleep 5; done
- name: Get Droplet IP address
id: get-droplet-ip
run: |
droplet_ip=$(doctl compute droplet get ${{ steps.create-droplet.outputs.droplet_id }} --format PublicIPv4 --no-header)
echo "droplet_ip=$droplet_ip" >> $GITHUB_OUTPUT
- name: Wait for SSH to be ready on Droplet
run: |
while ! ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa root@${{ steps.get-droplet-ip.outputs.droplet_ip }} "echo 'SSH is ready'"; do sleep 5; done
- name: Wait 1 minute for Droplet to be ready
run: sleep 60
- name: Extract branch name from ref
id: extract-branch-name
run: |
branch_name=$(echo ${{ github.ref }} | sed 's/refs\/heads\///')
echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
- name: Deploy app to Droplet
uses: fifsky/ssh-action@master
with:
command: |
echo 'Cloning repo on branch ${{ github.head_ref }}'
git clone --single-branch --branch ${{ steps.extract-branch-name.outputs.branch_name }} https://github.com/${{ github.repository }}
echo 'Waiting for dpkg lock to be released'
cd runtipi
echo 'Checking out branch ${{ steps.extract-branch-name.outputs.branch_name }}'
git checkout ${{ steps.extract-branch-name.outputs.branch_name }}
sudo ./scripts/start-e2e.sh e2e
echo 'App deployed'
host: ${{ steps.get-droplet-ip.outputs.droplet_ip }}
user: root
key: ${{ secrets.SSH_KEY }}
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: [deploy]
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
name: Install pnpm
id: pnpm-install
with:
version: 8
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- 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-
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Create .env.e2e file with Droplet IP
run: echo "SERVER_IP=${{ needs.deploy.outputs.droplet_ip }}" > .env.e2e
- name: Install dependencies
run: pnpm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run test:e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
report-deployment:
runs-on: ubuntu-latest
needs: [e2e]
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
pull-requests: write # to comment on the PR
issues: write # to comment on the PR
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
if: always()
steps:
- name: Download report artifact
uses: actions/download-artifact@v3
with:
name: playwright-report
path: playwright-report/
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
path: playwright-report/
- name: Deploy to GitHub Pages
id: report-deployment
uses: actions/deploy-pages@v2
- name: Comment on PR
uses: actions/github-script@v6
if: github.event_name == 'pull_request'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const comments = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo
});
const comment = comments.data.find(comment => comment.body.includes('Playwright report:'));
if (comment) {
await github.rest.issues.deleteComment({
comment_id: comment.id,
owner: context.repo.owner,
repo: context.repo.repo
});
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Playwright report: ${{ steps.report-deployment.outputs.page_url }}`
})
teardown:
runs-on: ubuntu-latest
if: always()
needs: [e2e, deploy]
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Delete Droplet
run: doctl compute droplet delete ${{ needs.deploy.outputs.droplet_id }} --force

View file

@ -31,10 +31,10 @@ jobs:
run: |
VERSION=$(npm run version --silent)
TAG=${VERSION}
echo "::set-output name=tag::${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Build and push images
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64

View file

@ -27,10 +27,10 @@ jobs:
run: |
VERSION=$(npm run version --silent)
TAG=${VERSION}
echo "::set-output name=tag::${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Build and push images
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
@ -58,5 +58,3 @@ jobs:
release_name: ${{ steps.create_tag.outputs.tagname }}
draft: false
prerelease: false

4
.gitignore vendored
View file

@ -54,8 +54,12 @@ node_modules/
/repos/
/apps/
traefik/shared
traefik/tls
# media folder
media
/state/
/test-results/
/playwright-report/
/playwright/.cache/

View file

@ -1,5 +1,5 @@
ARG NODE_VERSION="18.15"
ARG ALPINE_VERSION="3.16"
ARG NODE_VERSION="18.16"
ARG ALPINE_VERSION="3.18"
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS node_base

View file

@ -1,5 +1,5 @@
ARG NODE_VERSION="18.15"
ARG ALPINE_VERSION="3.16"
ARG NODE_VERSION="18.16"
ARG ALPINE_VERSION="3.18"
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

View file

@ -1,7 +1,7 @@
# Tipi — A personal homeserver for everyone
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-24-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-29-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![License](https://img.shields.io/github/license/meienberger/runtipi)](https://github.com/meienberger/runtipi/blob/master/LICENSE)
@ -104,6 +104,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SergeyKodolov"><img src="https://avatars.githubusercontent.com/u/35339452?v=4?s=100" width="100px;" alt="Sergey Kodolov"/><br /><sub><b>Sergey Kodolov</b></sub></a><br /><a href="#translation-SergeyKodolov" title="Translation">🌍</a> <a href="https://github.com/meienberger/runtipi/commits?author=SergeyKodolov" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sclaren"><img src="https://avatars.githubusercontent.com/u/915292?v=4?s=100" width="100px;" alt="sclaren"/><br /><sub><b>sclaren</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=sclaren" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mcmeel"><img src="https://avatars.githubusercontent.com/u/13773536?v=4?s=100" width="100px;" alt="mcmeel"/><br /><sub><b>mcmeel</b></sub></a><br /><a href="#question-mcmeel" title="Answering Questions">💬</a> <a href="#ideas-mcmeel" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/meienberger/runtipi/commits?author=mcmeel" title="Code">💻</a> <a href="https://github.com/meienberger/runtipi/commits?author=mcmeel" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/NoisyFridge"><img src="https://avatars.githubusercontent.com/u/73795785?v=4?s=100" width="100px;" alt="NoisyFridge"/><br /><sub><b>NoisyFridge</b></sub></a><br /><a href="#translation-NoisyFridge" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bvoxl"><img src="https://avatars.githubusercontent.com/u/67489519?v=4?s=100" width="100px;" alt="Bvoxl"/><br /><sub><b>Bvoxl</b></sub></a><br /><a href="#translation-Bvoxl" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/m-lab-0"><img src="https://avatars.githubusercontent.com/u/116570617?v=4?s=100" width="100px;" alt="m-lab-0"/><br /><sub><b>m-lab-0</b></sub></a><br /><a href="#translation-m-lab-0" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dannkunt"><img src="https://avatars.githubusercontent.com/u/32395839?v=4?s=100" width="100px;" alt="dannkunt"/><br /><sub><b>dannkunt</b></sub></a><br /><a href="#translation-dannkunt" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Schmanko"><img src="https://avatars.githubusercontent.com/u/94195393?v=4?s=100" width="100px;" alt="Schmanko"/><br /><sub><b>Schmanko</b></sub></a><br /><a href="#translation-Schmanko" title="Translation">🌍</a></td>
</tr>
</tbody>
</table>

View file

@ -57,7 +57,9 @@ class FsMock {
};
mkdirSync = (p: string) => {
this.mockFiles[p] = Object.create(null);
if (!this.mockFiles[p]) {
this.mockFiles[p] = [];
}
};
rmSync = (p: string) => {
@ -128,6 +130,48 @@ class FsMock {
},
writeFile: async (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
const dir = path.dirname(p);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(p));
},
mkdir: async (p: string) => {
if (!this.mockFiles[p]) {
this.mockFiles[p] = [];
}
},
readdir: async (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(this.mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
},
lstat: async (p: string) => {
return {
isDirectory: () => {
return this.mockFiles[p] instanceof Array;
},
};
},
readFile: async (p: string) => {
return this.mockFiles[p];
},
copyFile: async (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
},
};
}

View file

@ -1,6 +1,22 @@
version: '3.7'
services:
reverse-proxy:
container_name: reverse-proxy
image: traefik:v2.8
restart: on-failure
ports:
- 80:80
- 443:443
- 8080:8080
command: --providers.docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}/traefik:/root/.config
- ${PWD}/traefik/shared:/shared
networks:
- tipi_main_network
tipi-db:
container_name: tipi-db
image: postgres:14
@ -64,6 +80,7 @@ services:
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
LOCAL_DOMAIN: ${LOCAL_DOMAIN}
networks:
- tipi_main_network
ports:
@ -77,7 +94,22 @@ services:
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/runtipi/traefik
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
traefik.http.services.dashboard.loadbalancer.server.port: 3000
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
# 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
# secure
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:

121
docker-compose.e2e.yml Normal file
View file

@ -0,0 +1,121 @@
version: '3.7'
services:
reverse-proxy:
container_name: reverse-proxy
image: traefik:v2.8
restart: unless-stopped
ports:
- ${NGINX_PORT-80}:80
- ${NGINX_PORT_SSL-443}:443
command: --providers.docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}/traefik:/root/.config
- ${PWD}/traefik/shared:/shared
networks:
- tipi_main_network
tipi-db:
container_name: tipi-db
image: postgres:14
restart: unless-stopped
stop_grace_period: 1m
ports:
- 5432:5432
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:alpine
restart: unless-stopped
volumes:
- ./data/redis:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 10s
retries: 120
networks:
- tipi_main_network
dashboard:
image: meienberger/runtipi:${DOCKER_TAG}
restart: unless-stopped
container_name: dashboard
networks:
- tipi_main_network
depends_on:
tipi-db:
condition: service_healthy
tipi-redis:
condition: service_healthy
environment:
NODE_ENV: production
INTERNAL_IP: ${INTERNAL_IP}
TIPI_VERSION: ${TIPI_VERSION}
JWT_SECRET: ${JWT_SECRET}
NGINX_PORT: ${NGINX_PORT}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_USERNAME: ${POSTGRES_USERNAME}
POSTGRES_DBNAME: ${POSTGRES_DBNAME}
POSTGRES_HOST: ${POSTGRES_HOST}
APPS_REPO_ID: ${APPS_REPO_ID}
APPS_REPO_URL: ${APPS_REPO_URL}
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
LOCAL_DOMAIN: ${LOCAL_DOMAIN}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/runtipi/traefik
- ${PWD}:/app/storage
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
ipam:
driver: default
config:
- subnet: 10.21.21.0/24

View file

@ -21,6 +21,8 @@ services:
image: postgres:14
restart: unless-stopped
stop_grace_period: 1m
ports:
- 5432:5432
volumes:
- pgdata:/var/lib/postgresql/data
environment:

View file

@ -4,7 +4,7 @@ services:
reverse-proxy:
container_name: reverse-proxy
image: traefik:v2.8
restart: unless-stopped
restart: on-failure
ports:
- ${NGINX_PORT-80}:80
- ${NGINX_PORT_SSL-443}:443
@ -19,7 +19,7 @@ services:
tipi-db:
container_name: tipi-db
image: postgres:14
restart: unless-stopped
restart: on-failure
stop_grace_period: 1m
volumes:
- ${PWD}/data/postgres:/var/lib/postgresql/data
@ -38,7 +38,7 @@ services:
tipi-redis:
container_name: tipi-redis
image: redis:alpine
restart: unless-stopped
restart: on-failure
volumes:
- ./data/redis:/data
healthcheck:
@ -51,7 +51,7 @@ services:
dashboard:
image: meienberger/runtipi:${TIPI_VERSION}
restart: unless-stopped
restart: on-failure
container_name: dashboard
networks:
- tipi_main_network
@ -76,26 +76,42 @@ services:
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
LOCAL_DOMAIN: ${LOCAL_DOMAIN}
volumes:
- ${PWD}/.env:/runtipi/.env
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/runtipi/traefik
- ${STORAGE_PATH}:/app/storage
labels:
# Main
traefik.enable: true
# Web
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
traefik.http.services.dashboard.loadbalancer.server.port: 3000
# 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-secure
traefik.http.routers.dashboard-secure.service: dashboard
traefik.http.routers.dashboard-secure.entrypoints: websecure
traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
# 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:

29
e2e/0001-register.spec.ts Normal file
View file

@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { testUser } from './helpers/constants';
import { clearDatabase } from './helpers/db';
test.beforeEach(async () => {
await clearDatabase();
});
test('user should be redirected to /register', async ({ page }) => {
await page.goto('/');
await page.waitForURL(/register/);
await expect(page.getByRole('heading', { name: 'Register your account' })).toBeVisible();
});
test('user can register a new account', async ({ page }) => {
await page.goto('/register');
await page.getByPlaceholder('you@example.com').click();
await page.getByPlaceholder('you@example.com').fill(testUser.email);
await page.getByPlaceholder('Enter your password', { exact: true }).fill(testUser.password);
await page.getByPlaceholder('Confirm your password').fill(testUser.password);
await page.getByRole('button', { name: 'Register' }).click();
await expect(page).toHaveTitle(/Dashboard/);
});

26
e2e/0002-login.spec.ts Normal file
View file

@ -0,0 +1,26 @@
import { test, expect } from '@playwright/test';
import { loginUser, createTestUser } from './fixtures/fixtures';
import { testUser } from './helpers/constants';
import { clearDatabase } from './helpers/db';
test.beforeEach(async () => {
await clearDatabase();
});
test('user can login and is redirected to the dashboard', async ({ page }) => {
await createTestUser();
await page.goto('/login');
await page.getByPlaceholder('you@example.com').fill(testUser.email);
await page.getByPlaceholder('Your password').fill(testUser.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('user can logout', async ({ page }) => {
await loginUser(page);
await page.getByTestId('logout-button').click();
await expect(page.getByText('Login to your account')).toBeVisible();
});

57
e2e/0003-apps.spec.ts Normal file
View file

@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';
import { loginUser } from './fixtures/fixtures';
import { clearDatabase } from './helpers/db';
test.beforeEach(async ({ page, isMobile }) => {
await clearDatabase();
await loginUser(page);
if (isMobile) {
// TODO: Fix mobile accessibility for the dropdown menu
// await page.getByRole('button', { name: 'Menu' }).click();
await page.goto('/app-store');
} else {
await page.getByRole('link', { name: 'App store' }).click();
}
await page.getByPlaceholder('Search').fill('hello');
await page.getByRole('link', { name: 'Hello World' }).click();
});
test('user can install and uninstall app', async ({ page, context }) => {
// Install app
await page.getByRole('button', { name: 'Install' }).click();
await expect(page.getByText('Install Hello World')).toBeVisible();
await page.getByRole('button', { name: 'Install' }).click();
await expect(page.getByText('Installing')).toBeVisible();
await expect(page.getByText('Running')).toBeVisible({ timeout: 60000 });
await expect(page.getByText('App installed successfully')).toBeVisible();
await page.getByTestId('app-details').getByRole('button', { name: 'Open' }).press('ArrowDown');
const [newPage] = await Promise.all([context.waitForEvent('page'), await page.getByRole('menuitem', { name: `${process.env.SERVER_IP}:8000` }).click()]);
await newPage.waitForLoadState();
await expect(newPage.getByText('Hello World')).toBeVisible();
await newPage.close();
// Stop app
await page.getByRole('button', { name: 'Stop' }).click();
await expect(page.getByText('Stop Hello World')).toBeVisible();
await page.getByRole('button', { name: 'Stop' }).click();
await expect(page.getByText('Stopping')).toBeVisible();
await expect(page.getByText('App stopped successfully')).toBeVisible({ timeout: 60000 });
// Uninstall app
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('Uninstall Hello World?')).toBeVisible();
await page.getByRole('button', { name: 'Uninstall' }).click();
await expect(page.getByText('Uninstalling')).toBeVisible();
await expect(page.getByText('App uninstalled successfully')).toBeVisible({ timeout: 60000 });
});

View file

@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test';
import { eq } from 'drizzle-orm';
import { userTable } from '@/server/db/schema';
import { loginUser } from './fixtures/fixtures';
import { clearDatabase, db } from './helpers/db';
import { testUser } from './helpers/constants';
test.beforeEach(async ({ page }) => {
await clearDatabase();
await loginUser(page);
await page.goto('/settings');
});
test('user can change their password', async ({ page }) => {
// Change password
await page.getByRole('tab', { name: 'Security' }).click();
await page.getByPlaceholder('Current password').click();
await page.getByPlaceholder('Current password').fill(testUser.password);
await page.getByPlaceholder('New password', { exact: true }).click();
await page.getByPlaceholder('New password', { exact: true }).fill('password2');
await page.getByPlaceholder('Confirm new password').click();
await page.getByPlaceholder('Confirm new password').fill('password2');
await page.getByRole('button', { name: 'Change password' }).click();
await expect(page.getByText('Password changed successfully')).toBeVisible();
// Login with new password
await page.getByPlaceholder('you@example.com').fill(testUser.email);
await page.getByPlaceholder('Your password').fill('password2');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('user can change their language and it is persisted in database', async ({ page }) => {
await page.getByRole('tab', { name: 'Settings' }).click();
await page.getByRole('combobox', { name: 'Language Help translate Tipi' }).click();
await page.getByRole('option', { name: 'Français' }).click();
await expect(page.getByText('Paramètres utilisateur')).toBeVisible();
const dbUser = await db.query.userTable.findFirst({ where: eq(userTable.username, testUser.email) });
expect(dbUser?.locale).toEqual('fr-FR');
});

25
e2e/fixtures/fixtures.ts Normal file
View file

@ -0,0 +1,25 @@
import * as argon2 from 'argon2';
import { expect, Page } from '@playwright/test';
import { userTable } from '@/server/db/schema';
import { db } from '../helpers/db';
import { testUser } from '../helpers/constants';
export const createTestUser = async () => {
// Create user in database
const password = await argon2.hash(testUser.password);
await db.insert(userTable).values({ password, username: testUser.email, operator: true });
};
export const loginUser = async (page: Page) => {
// Create user in database
await createTestUser();
// Login flow
await page.goto('/login');
await page.getByPlaceholder('you@example.com').fill(testUser.email);
await page.getByPlaceholder('Your password').fill(testUser.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
};

4
e2e/helpers/constants.ts Normal file
View file

@ -0,0 +1,4 @@
export const testUser = {
email: 'tester@test.com',
password: 'password',
};

17
e2e/helpers/db.ts Normal file
View file

@ -0,0 +1,17 @@
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from '../../src/server/db/schema';
const connectionString = `postgresql://tipi:postgres@${process.env.SERVER_IP}:5432/tipi?connect_timeout=300`;
const pool = new Pool({
connectionString,
});
export const db = drizzle(pool, { schema });
export const clearDatabase = async () => {
// delete all data in table user
await db.delete(schema.userTable);
await db.delete(schema.appTable);
};

View file

@ -0,0 +1,10 @@
import { clearDatabase } from './db';
/**
*
*/
async function globalSetup() {
await clearDatabase();
}
export default globalSetup;

View file

@ -1,10 +1,12 @@
{
"name": "runtipi",
"version": "1.4.2",
"version": "1.5.0",
"description": "A homeserver for everyone",
"scripts": {
"copy:migrations": "mkdir -p dist/migrations && cp -r ./src/server/migrations dist",
"test": "dotenv -e .env.test -- jest --colors",
"test:e2e": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test",
"test:e2e:ui": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test --ui",
"test:client": "jest --colors --selectProjects client --",
"test:server": "jest --colors --selectProjects server --",
"dev": "npm run copy:migrations && nodemon",
@ -14,10 +16,10 @@
"build": "npm run copy:migrations && node ./esbuild.js build",
"build:server": "node ./esbuild.js build",
"build:next": "next build",
"start:dev": "./scripts/start-dev.sh",
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
"start:prod": "docker-compose -f docker-compose.test.yml --env-file .env up --build",
"start:dev": "./scripts/start-dev.sh",
"start:e2e": "./scripts/start-e2e.sh latest",
"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",
@ -32,6 +34,7 @@
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
@ -47,17 +50,16 @@
"argon2": "^0.30.3",
"clsx": "^1.1.1",
"connect-redis": "^7.1.0",
"drizzle-orm": "^0.26.0",
"cookies-next": "^2.1.1",
"drizzle-orm": "^0.26.5",
"express": "^4.17.3",
"express-session": "^1.17.3",
"fs-extra": "^11.1.1",
"isomorphic-fetch": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash.merge": "^4.6.2",
"next": "13.4.3",
"next": "13.4.4",
"next-intl": "^2.14.2",
"node-cron": "^3.0.1",
"node-fetch-commonjs": "^3.2.4",
"pg": "^8.11.0",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
@ -67,12 +69,13 @@
"react-markdown": "^8.0.7",
"react-select": "^5.7.3",
"react-tooltip": "^5.13.1",
"redaxios": "^0.5.1",
"redis": "^4.6.6",
"remark-breaks": "^3.0.3",
"remark-gfm": "^3.0.1",
"sass": "^1.62.1",
"semver": "^7.5.1",
"sharp": "0.31.3",
"sharp": "0.32.1",
"superjson": "^1.12.3",
"tslib": "^2.5.2",
"uuid": "^9.0.0",
@ -85,6 +88,7 @@
"devDependencies": {
"@babel/core": "^7.21.8",
"@faker-js/faker": "^8.0.1",
"@playwright/test": "^1.35.0",
"@testing-library/dom": "^9.3.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
@ -95,18 +99,17 @@
"@types/express-session": "^1.17.7",
"@types/fs-extra": "^11.0.1",
"@types/isomorphic-fetch": "^0.0.36",
"@types/jest": "^29.5.1",
"@types/js-cookie": "^3.0.3",
"@types/jest": "^29.5.2",
"@types/lodash.merge": "^4.6.7",
"@types/node": "20.2.1",
"@types/node": "20.2.5",
"@types/node-cron": "^3.0.2",
"@types/pg": "^8.6.6",
"@types/react": "18.2.6",
"@types/react": "18.2.10",
"@types/react-dom": "18.2.4",
"@types/semver": "^7.5.0",
"@types/supertest": "^2.0.12",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^9.0.1",
"@types/testing-library__jest-dom": "^5.14.6",
"@types/uuid": "^9.0.2",
"@types/validator": "^13.7.17",
"@types/web-push": "^3.3.2",
"@typescript-eslint/eslint-plugin": "^5.59.6",
@ -114,16 +117,16 @@
"dotenv-cli": "^7.2.1",
"drizzle-kit": "^0.18.0",
"esbuild": "^0.16.17",
"eslint": "8.41.0",
"eslint": "8.42.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-next": "13.4.3",
"eslint-config-next": "13.4.4",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-jest-dom": "^4.0.3",
"eslint-plugin-jsdoc": "^44.2.4",
"eslint-plugin-jest-dom": "^5.0.1",
"eslint-plugin-jsdoc": "^46.0.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
@ -137,7 +140,7 @@
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "5.0.4",
"typescript": "5.1.3",
"wait-for-expect": "^3.0.2",
"whatwg-fetch": "^3.6.2"
},

76
playwright.config.ts Normal file
View file

@ -0,0 +1,76 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: require.resolve('./e2e/helpers/global-setup'),
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: `http://${process.env.SERVER_IP}${process.env.SERVER_PORT ? `:${process.env.SERVER_PORT}` : ''}`,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
video: 'on',
},
// timeout: 5000,
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start:e2e',
// url: 'http://174.138.79.40',
// reuseExistingServer: true,
// },
});

1248
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -146,11 +146,6 @@ function install_app() {
exit 1
fi
# Copy default data dir to app data dir if it exists
if [[ -d "${app_dir}/data" ]]; then
cp -r "${app_dir}/data" "${app_data_dir}/data"
fi
ensure_permissions "${app}"
if ! compose "${app}" up -d; then

View file

@ -1,6 +1,8 @@
#!/usr/bin/env bash
ROOT_FOLDER="${PWD}"
STATE_FOLDER="${ROOT_FOLDER}/state"
# Get field from json file
function get_json_field() {
local json_file="$1"
@ -93,4 +95,181 @@ function kill_watcher() {
kill -9 $watcher_pid
fi
fi
# pkill -f "watcher.sh"
}
function generateTLSCert() {
local domain="$1"
# If the certificate already exists for this domain, don't generate it again
if [[ -f "traefik/tls/$domain.txt" ]] && [[ -f "traefik/tls/cert.pem" ]] && [[ -f "traefik/tls/key.pem" ]]; then
return
fi
rm -rf "traefik/tls/$domain.txt"
rm -rf "traefik/tls/cert.pem"
rm -rf "traefik/tls/key.pem"
echo "Generating TLS certificate..."
if ! openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "/O=runtipi.io/OU=IT/CN=*.${domain}/emailAddress=webmaster@${domain}" -addext "subjectAltName = DNS:*.${domain},DNS:${domain}" -nodes; then
echo "Failed to generate TLS certificate"
else
echo "TLS certificate generated"
# Create a file to indicate that the certificate has been generated for this domain
touch "traefik/tls/$domain.txt"
fi
}
function generate_env_file() {
echo "Generating .env file..."
env_variables=$1
json_file=$(mktemp)
echo "$env_variables" > "$json_file"
local default_tz="Etc\/UTC"
local tz="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g')"
if [[ -z "$tz" ]]; then
tz="$default_tz"
fi
local architecture="$(uname -m | tr '[:upper:]' '[:lower:]')"
if [[ "$architecture" == "aarch64" ]] || [[ "$architecture" == "armv8"* ]]; then
architecture="arm64"
elif [[ "$architecture" == "x86_64" ]]; then
architecture="amd64"
fi
# If none of the above conditions are met, the architecture is not supported
if [[ "$architecture" != "arm64" ]] && [[ "$architecture" != "amd64" ]]; then
echo "Architecture ${architecture} not supported if you think this is a mistake, please open an issue on GitHub."
exit 1
fi
local dns_ip=$(get_json_field "$json_file" dns_ip)
local internal_ip=$(get_json_field "$json_file" internal_ip)
local jwt_secret=$(get_json_field "$json_file" jwt_secret)
local tipi_version=$(get_json_field "$json_file" tipi_version)
local nginx_port=$(get_json_field "$json_file" nginx_port)
local nginx_port_ssl=$(get_json_field "$json_file" nginx_port_ssl)
local repo_id=$(get_json_field "$json_file" repo_id)
local domain=$(get_json_field "$json_file" domain)
local postgres_password=$(get_json_field "$json_file" postgres_password)
local postgres_username=$(get_json_field "$json_file" postgres_username)
local postgres_dbname=$(get_json_field "$json_file" postgres_dbname)
local postgres_host=$(get_json_field "$json_file" postgres_host)
local postgres_port=$(get_json_field "$json_file" postgres_port)
local redis_host=$(get_json_field "$json_file" redis_host)
local demo_mode=$(get_json_field "$json_file" demo_mode)
local docker_tag=$(get_json_field "$json_file" docker_tag)
local local_domain=$(get_json_field "$json_file" local_domain)
local root_folder=$(get_json_field "$json_file" root_folder | sed 's/\//\\\//g')
local apps_repository=$(get_json_field "$json_file" apps_repository | sed 's/\//\\\//g')
local storage_path=$(get_json_field "$json_file" storage_path | sed 's/\//\\\//g')
env_file=$(mktemp)
[[ -f "${ROOT_FOLDER}/.env" ]] && rm -f "${ROOT_FOLDER}/.env"
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$env_file"
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
# If dnsIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)" != "null" ]]; then
dns_ip=$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)
fi
# If domain is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" domain)" != "null" ]]; then
domain=$(get_json_field "${STATE_FOLDER}/settings.json" domain)
fi
# If appsRepoUrl is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
apps_repository_temp=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)
apps_repository="$(echo "${apps_repository_temp}" | sed 's/\//\\\//g')"
repo_id="$("${ROOT_FOLDER}"/scripts/git.sh get_hash "${apps_repository_temp}")"
fi
# If port is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" port)" != "null" ]]; then
nginx_port=$(get_json_field "${STATE_FOLDER}/settings.json" port)
fi
# If sslPort is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)" != "null" ]]; then
nginx_port_ssl=$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)
fi
# If listenIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
internal_ip=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
fi
# If demoMode is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" demoMode)" == "true" ]]; then
demo_mode="true"
fi
# If storagePath is set in settings.json, use it
storage_path_settings=$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)
if [[ "${storage_path_settings}" != "null" && "${storage_path_settings}" != "" ]]; then
storage_path_temp="${storage_path_settings}"
storage_path="$(echo "${storage_path_temp}" | sed 's/\//\\\//g')"
fi
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" localDomain)" != "null" ]]; then
local_domain=$(get_json_field "${STATE_FOLDER}/settings.json" localDomain)
fi
fi
echo "Using domain ${domain} and port ${nginx_port}"
# If port is not 80 and domain is not example.com or tipi.localhost, we exit
if [[ "${nginx_port}" != "80" ]] && [[ "${domain}" != "example.com" ]] && [[ "${domain}" != "tipi.localhost" ]]; then
echo "Using a custom domain with a custom port is not supported"
exit 1
fi
os=$(uname)
sed_args=(-i)
# If os is macos, use gnu sed
if [[ "$os" == "Darwin" ]]; then
echo "Using gnu sed"
sed_args=(-i '')
fi
# Function below is modified from Umbrel
# Required Notice: Copyright
# Umbrel (https://umbrel.com)
for template in ${env_file}; do
sed "${sed_args[@]}" "s/<dns_ip>/${dns_ip}/g" "${template}"
sed "${sed_args[@]}" "s/<internal_ip>/${internal_ip}/g" "${template}"
sed "${sed_args[@]}" "s/<tz>/${tz}/g" "${template}"
sed "${sed_args[@]}" "s/<jwt_secret>/${jwt_secret}/g" "${template}"
sed "${sed_args[@]}" "s/<root_folder>/${root_folder}/g" "${template}"
sed "${sed_args[@]}" "s/<tipi_version>/${tipi_version}/g" "${template}"
sed "${sed_args[@]}" "s/<architecture>/${architecture}/g" "${template}"
sed "${sed_args[@]}" "s/<nginx_port>/${nginx_port}/g" "${template}"
sed "${sed_args[@]}" "s/<nginx_port_ssl>/${nginx_port_ssl}/g" "${template}"
sed "${sed_args[@]}" "s/<apps_repo_id>/${repo_id}/g" "${template}"
sed "${sed_args[@]}" "s/<apps_repo_url>/${apps_repository}/g" "${template}"
sed "${sed_args[@]}" "s/<domain>/${domain}/g" "${template}"
sed "${sed_args[@]}" "s/<storage_path>/${storage_path}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_password>/${postgres_password}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_username>/${postgres_username}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_dbname>/${postgres_dbname}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_port>/${postgres_port}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_host>/${postgres_host}/g" "${template}"
sed "${sed_args[@]}" "s/<redis_host>/${redis_host}/g" "${template}"
sed "${sed_args[@]}" "s/<demo_mode>/${demo_mode}/g" "${template}"
sed "${sed_args[@]}" "s/<docker_tag>/${docker_tag}/g" "${template}"
sed "${sed_args[@]}" "s/<local_domain>/${local_domain}/g" "${template}"
done
generateTLSCert "$local_domain"
mv -f "$env_file" "$ROOT_FOLDER/.env"
chmod a+rwx "$ROOT_FOLDER/.env"
}

View file

@ -8,12 +8,10 @@ function install_generic() {
local os="${2}"
if [[ "${os}" == "debian" ]]; then
sudo apt-get update
sudo apt-get install -y "${dependency}"
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${dependency}"
return 0
elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
sudo apt-get update
sudo apt-get install -y "${dependency}"
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${dependency}"
return 0
elif [[ "${os}" == "centos" ]]; then
sudo yum install -y --allowerasing "${dependency}"
@ -31,27 +29,23 @@ function install_generic() {
function install_docker() {
local os="${1}"
echo "Installing docker for os ${os}" >/dev/tty
echo "Installing docker for os ${os}"
if [[ "${os}" == "debian" ]]; then
sudo apt-get update
sudo apt-get upgrade
sudo apt-get install -y ca-certificates curl gnupg lsb-release
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo DEBIAN_FRONTEND=noninteractive apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
return 0
elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
sudo apt-get update
sudo apt-get upgrade
sudo apt-get install -y ca-certificates curl gnupg lsb-release
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo DEBIAN_FRONTEND=noninteractive apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
return 0
elif [[ "${os}" == "centos" ]]; then
sudo yum install -y yum-utils
@ -82,12 +76,10 @@ function update_docker() {
echo "Updating Docker for os ${os}" >/dev/tty
if [[ "${os}" == "debian" ]]; then
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
return 0
elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
return 0
elif [[ "${os}" == "centos" ]]; then
sudo yum install -y --allowerasing docker-ce docker-ce-cli containerd.io docker-compose-plugin
@ -103,7 +95,13 @@ function update_docker() {
fi
}
echo "Updating system"
sudo DEBIAN_FRONTEND=noninteractive apt-get update -y
echo "Upgrading system"
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
if ! command -v docker >/dev/null; then
echo "Installing docker"
install_docker "${OS}"
docker_result=$?

View file

@ -79,19 +79,22 @@ mkdir -p app-data
mkdir -p state
mkdir -p repos
mkdir -p traefik/shared
mkdir -p traefik/tls
mkdir -p media/torrents
mkdir -p media/torrents/watch
mkdir -p media/torrents/completed
mkdir -p media/torrents/complete
mkdir -p media/torrents/incomplete
mkdir -p media/usenet
mkdir -p media/usenet/watch
mkdir -p media/usenet/completed
mkdir -p media/usenet/complete
mkdir -p media/usenet/incomplete
mkdir -p media/downloads
mkdir -p media/downloads/watch
mkdir -p media/downloads/completed
mkdir -p media/downloads/complete
mkdir -p media/downloads/incomplete
mkdir -p media/data

View file

@ -15,57 +15,42 @@ clean_logs
### --------------------------------
ROOT_FOLDER="${PWD}"
STATE_FOLDER="${ROOT_FOLDER}/state"
SED_ROOT_FOLDER="$(echo "$ROOT_FOLDER" | sed 's/\//\\\//g')"
NGINX_PORT=3000
NGINX_PORT_SSL=443
DOMAIN=tipi.localhost
DNS_IP="9.9.9.9" # Default to Quad9 DNS
ARCHITECTURE="$(uname -m)"
TZ="UTC"
JWT_SECRET=secret
POSTGRES_PASSWORD=postgres
POSTGRES_USERNAME=tipi
POSTGRES_DBNAME=tipi
POSTGRES_PORT=5432
POSTGRES_HOST=tipi-db
REDIS_HOST=tipi-redis
TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
INTERNAL_IP=localhost
DEMO_MODE=false
storage_path="${ROOT_FOLDER}"
STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
if [[ "$ARCHITECTURE" == "aarch64" ]]; then
ARCHITECTURE="arm64"
elif [[ "$ARCHITECTURE" == "armv7l" ]]; then
ARCHITECTURE="arm"
elif [[ "$ARCHITECTURE" == "x86_64" ]]; then
ARCHITECTURE="amd64"
fi
# If none of the above conditions are met, the architecture is not supported
if [[ "$ARCHITECTURE" != "arm64" ]] && [[ "$ARCHITECTURE" != "arm" ]] && [[ "$ARCHITECTURE" != "amd64" ]]; then
echo "Architecture not supported!"
exit 1
fi
### --------------------------------
### Apps repository configuration
### --------------------------------
apps_repository="https://github.com/meienberger/runtipi-appstore"
APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})"
# Override configs with settings.json
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
apps_repository=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)
APPS_REPOSITORY_ESCAPED="$(echo "${apps_repository}" | sed 's/\//\\\//g')"
REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash "${apps_repository}")"
fi
fi
env_variables_json=$(cat <<EOF
{
"dns_ip": "9.9.9.9",
"domain": "example.com",
"root_folder": "${ROOT_FOLDER}",
"nginx_port": 3000,
"nginx_port_ssl": 443,
"jwt_secret": "secret",
"postgres_password": "postgres",
"postgres_username": "tipi",
"postgres_dbname": "tipi",
"postgres_port": 5432,
"postgres_host": "tipi-db",
"redis_host": "tipi-redis",
"tipi_version": "$(get_json_field "${ROOT_FOLDER}/package.json" version)",
"internal_ip": "localhost",
"demo_mode": false,
"local_domain": "tipi.lan",
"apps_repository": "${apps_repository}",
"storage_path": "${ROOT_FOLDER}",
"repo_id": "$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})"
}
EOF
)
### --------------------------------
### Watcher and system-info
### --------------------------------
mkdir -p "${ROOT_FOLDER}/state"
if [[ ! -f "${ROOT_FOLDER}/state/events" ]]; then
touch "${ROOT_FOLDER}/state/events"
fi
@ -82,91 +67,9 @@ kill_watcher
### --------------------------------
### env file generation
### --------------------------------
ENV_FILE=$(mktemp)
[[ -f "${ROOT_FOLDER}/.env" ]] && rm -f "${ROOT_FOLDER}/.env"
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
OS=$(uname)
sed_args=(-i)
# If os is macos, use gnu sed
if [[ "$OS" == "Darwin" ]]; then
echo "Using gnu sed"
sed_args=(-i '')
fi
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
# If dnsIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)" != "null" ]]; then
DNS_IP=$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)
fi
# If domain is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" domain)" != "null" ]]; then
DOMAIN=$(get_json_field "${STATE_FOLDER}/settings.json" domain)
fi
# If appsRepoUrl is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
apps_repository=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)
APPS_REPOSITORY_ESCAPED="$(echo "${apps_repository}" | sed 's/\//\\\//g')"
REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash "${apps_repository}")"
fi
# If port is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" port)" != "null" ]]; then
NGINX_PORT=$(get_json_field "${STATE_FOLDER}/settings.json" port)
fi
# If sslPort is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)" != "null" ]]; then
NGINX_PORT_SSL=$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)
fi
# If listenIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
fi
# If storagePath is set in settings.json, use it
storage_path_settings=$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)
if [[ "${storage_path_settings}" != "null" && "${storage_path_settings}" != "" ]]; then
storage_path="${storage_path_settings}"
STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
fi
fi
# Function below is modified from Umbrel
# Required Notice: Copyright
# Umbrel (https://umbrel.com)
for template in ${ENV_FILE}; do
sed "${sed_args[@]}" "s/<dns_ip>/${DNS_IP}/g" "${template}"
sed "${sed_args[@]}" "s/<internal_ip>/${INTERNAL_IP}/g" "${template}"
sed "${sed_args[@]}" "s/<tz>/${TZ}/g" "${template}"
sed "${sed_args[@]}" "s/<jwt_secret>/${JWT_SECRET}/g" "${template}"
sed "${sed_args[@]}" "s/<root_folder>/${SED_ROOT_FOLDER}/g" "${template}"
sed "${sed_args[@]}" "s/<tipi_version>/${TIPI_VERSION}/g" "${template}"
sed "${sed_args[@]}" "s/<architecture>/${ARCHITECTURE}/g" "${template}"
sed "${sed_args[@]}" "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
sed "${sed_args[@]}" "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
sed "${sed_args[@]}" "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
sed "${sed_args[@]}" "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
sed "${sed_args[@]}" "s/<domain>/${DOMAIN}/g" "${template}"
sed "${sed_args[@]}" "s/<storage_path>/${STORAGE_PATH_ESCAPED}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_username>/${POSTGRES_USERNAME}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_dbname>/${POSTGRES_DBNAME}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_port>/${POSTGRES_PORT}/g" "${template}"
sed "${sed_args[@]}" "s/<postgres_host>/${POSTGRES_HOST}/g" "${template}"
sed "${sed_args[@]}" "s/<redis_host>/${REDIS_HOST}/g" "${template}"
sed "${sed_args[@]}" "s/<demo_mode>/${DEMO_MODE}/g" "${template}"
done
mv -f "$ENV_FILE" "$ROOT_FOLDER/.env.dev"
cp "$ROOT_FOLDER/.env.dev" "$ROOT_FOLDER/.env"
chmod a+rwx "$ROOT_FOLDER/.env"
chmod a+rwx "${ROOT_FOLDER}/.env.dev"
generate_env_file "${env_variables_json}"
### --------------------------------
### Start the project
### --------------------------------
docker compose -f docker-compose.dev.yml --env-file "${ROOT_FOLDER}/.env.dev" up --build
docker compose -f docker-compose.dev.yml up --build

107
scripts/start-e2e.sh Executable file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
if [[ "${TRACE-0}" == "1" ]]; then
set -o xtrace
fi
export DEBIAN_FRONTEND=noninteractive
source "${BASH_SOURCE%/*}/common.sh"
clean_logs
### --------------------------------
### General variables
### --------------------------------
ROOT_FOLDER="${PWD}"
STATE_FOLDER="${ROOT_FOLDER}/state"
## Comes from first argument
DOCKER_TAG="${1}"
echo "Starting e2e tests with tag meienberger/runtipi:${DOCKER_TAG}"
### --------------------------------
### Pre-configuration
### --------------------------------
sudo "${ROOT_FOLDER}/scripts/configure.sh"
mkdir -p "${ROOT_FOLDER}/state"
STATE_FOLDER="${ROOT_FOLDER}/state"
mkdir -p traefik
mkdir -p traefik/shared
mkdir -p traefik/tls
if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
echo "Generating seed..."
mkdir -p "${STATE_FOLDER}"
touch "${STATE_FOLDER}/seed"
if ! tr </dev/urandom -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 >"${STATE_FOLDER}/seed"; then
echo "Created seed file..."
fi
fi
### --------------------------------
### Apps repository configuration
### --------------------------------
apps_repository="https://github.com/meienberger/runtipi-appstore"
env_variables_json=$(cat <<EOF
{
"dns_ip": "9.9.9.9",
"domain": "example.com",
"root_folder": "${ROOT_FOLDER}",
"nginx_port": 80,
"nginx_port_ssl": 443,
"jwt_secret": "secret",
"postgres_password": "postgres",
"postgres_username": "tipi",
"postgres_dbname": "tipi",
"postgres_port": 5432,
"postgres_host": "tipi-db",
"redis_host": "tipi-redis",
"local_domain": "tipi.lan",
"tipi_version": "$(get_json_field "${ROOT_FOLDER}/package.json" version)",
"internal_ip": "localhost",
"demo_mode": false,
"apps_repository": "${apps_repository}",
"storage_path": "${ROOT_FOLDER}",
"repo_id": "$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})",
"docker_tag": "${DOCKER_TAG}"
}
EOF
)
### --------------------------------
### Watcher and system-info
### --------------------------------
echo "creating events file"
if [[ ! -f "${ROOT_FOLDER}/state/events" ]]; then
touch "${ROOT_FOLDER}/state/events"
fi
echo "creating system-info file"
if [[ ! -f "${ROOT_FOLDER}/state/system-info.json" ]]; then
echo "{}" >"${ROOT_FOLDER}/state/system-info.json"
fi
chmod -R a+rwx "${ROOT_FOLDER}/state/events"
chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json"
echo "kill previous watcher"
kill_watcher
echo "starting watcher"
nohup "${ROOT_FOLDER}/scripts/watcher.sh" > /dev/null 2>&1 &
### --------------------------------
### env file generation
### --------------------------------
echo "Generating env file..."
generate_env_file "${env_variables_json}"
### --------------------------------
### Start the project
### --------------------------------
echo "Starting docker-compose..."
docker compose -f docker-compose.e2e.yml up -d --build

View file

@ -22,7 +22,6 @@ clean_logs
"${ROOT_FOLDER}/scripts/configure.sh"
STATE_FOLDER="${ROOT_FOLDER}/state"
# Create seed file with cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
echo "Generating seed..."
if ! tr </dev/urandom -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 >"${STATE_FOLDER}/seed"; then
@ -33,106 +32,17 @@ fi
### --------------------------------
### General variables
### --------------------------------
DEFAULT_TZ="Etc\/UTC"
TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g')"
if [[ -z "$TZ" ]]; then
TZ="$DEFAULT_TZ"
fi
NGINX_PORT=80
NGINX_PORT_SSL=443
DOMAIN=tipi.localhost
SED_ROOT_FOLDER="$(echo "$ROOT_FOLDER" | sed 's/\//\\\//g')"
DNS_IP="9.9.9.9" # Default to Quad9 DNS
ARCHITECTURE="$(uname -m | tr '[:upper:]' '[:lower:]')"
apps_repository="https://github.com/meienberger/runtipi-appstore"
REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})"
APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
JWT_SECRET=$(derive_entropy "jwt")
POSTGRES_PASSWORD=$(derive_entropy "postgres")
POSTGRES_USERNAME=tipi
POSTGRES_DBNAME=tipi
POSTGRES_PORT=5432
POSTGRES_HOST=tipi-db
TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
storage_path="${ROOT_FOLDER}"
STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
REDIS_HOST=tipi-redis
DEMO_MODE=false
INTERNAL_IP=
if [[ "$ARCHITECTURE" == "aarch64" ]] || [[ "$ARCHITECTURE" == "armv8"* ]]; then
ARCHITECTURE="arm64"
elif [[ "$ARCHITECTURE" == "x86_64" ]]; then
ARCHITECTURE="amd64"
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
# If listenIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
fi
fi
# If none of the above conditions are met, the architecture is not supported
if [[ "$ARCHITECTURE" != "arm64" ]] && [[ "$ARCHITECTURE" != "amd64" ]]; then
echo "Architecture ${ARCHITECTURE} not supported if you think this is a mistake, please open an issue on GitHub."
exit 1
fi
### --------------------------------
### CLI arguments
### --------------------------------
while [ -n "${1-}" ]; do
case "$1" in
--rc) rc="true" ;;
--ci) ci="true" ;;
--demo) DEMO_MODE=true ;;
--port)
port="${2-}"
if [[ "${port}" =~ ^[0-9]+$ ]]; then
NGINX_PORT="${port}"
else
echo "--port must be a number"
exit 1
fi
shift
;;
--ssl-port)
ssl_port="${2-}"
if [[ "${ssl_port}" =~ ^[0-9]+$ ]]; then
NGINX_PORT_SSL="${ssl_port}"
else
echo "--ssl-port must be a number"
exit 1
fi
shift
;;
--domain)
domain="${2-}"
if [[ "${domain}" =~ ^[a-zA-Z0-9.-]+$ ]]; then
DOMAIN="${domain}"
else
echo "--domain must be a valid domain"
exit 1
fi
shift
;;
--listen-ip)
listen_ip="${2-}"
if [[ "${listen_ip}" =~ ^[a-fA-F0-9.:]+$ ]]; then
INTERNAL_IP="${listen_ip}"
else
echo "--listen-ip must be a valid IP address"
exit 1
fi
shift
;;
--)
shift # The double dash makes them parameters
break
;;
*) echo "Option $1 not recognized" && exit 1 ;;
esac
shift
done
if [[ -z "${INTERNAL_IP:-}" ]]; then
network_interface="$(ip route | grep default | awk '{print $5}' | uniq)"
network_interface_count=$(echo "$network_interface" | wc -l)
@ -175,11 +85,34 @@ if [[ -z "${INTERNAL_IP:-}" ]]; then
fi
fi
# If port is not 80 and domain is not tipi.localhost, we exit
if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
echo "Using a custom domain with a custom port is not supported"
exit 1
fi
env_variables_json=$(cat <<EOF
{
"dns_ip": "9.9.9.9",
"internal_ip": "${INTERNAL_IP}",
"jwt_secret": "$(derive_entropy "jwt")",
"root_folder": "${ROOT_FOLDER}",
"tipi_version": "$(get_json_field "${ROOT_FOLDER}/package.json" version)",
"nginx_port": 80,
"nginx_port_ssl": 443,
"postgres_password": "$(derive_entropy "postgres")
"postgres_username": "tipi",
"postgres_dbname": "tipi",
"postgres_port": 5432,
"postgres_host": "tipi-db",
"redis_host": "tipi-redis",
"local_domain": "tipi.lan",
"repo_id": "$("${ROOT_FOLDER}"/scripts/git.sh get_hash "${apps_repository}")",
"apps_repository": "${apps_repository}",
"domain": "example.com",
"storage_path": "${ROOT_FOLDER}",
"demo_mode": false,
}
EOF
)
echo "Generating config files..."
write_log "Final values: \n${env_variables_json}"
generate_env_file "${env_variables_json}"
### --------------------------------
### Watcher and system-info
@ -190,117 +123,24 @@ echo "Running system-info.sh..."
kill_watcher
"${ROOT_FOLDER}/scripts/watcher.sh" &
### --------------------------------
### settings.json overrides
### --------------------------------
echo "Generating config files..."
# Override vars with values from settings.json
if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
# If dnsIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)" != "null" ]]; then
DNS_IP=$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)
fi
# If domain is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" domain)" != "null" ]]; then
DOMAIN=$(get_json_field "${STATE_FOLDER}/settings.json" domain)
fi
# If appsRepoUrl is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
apps_repository=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)
APPS_REPOSITORY_ESCAPED="$(echo "${apps_repository}" | sed 's/\//\\\//g')"
REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash "${apps_repository}")"
fi
# If port is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" port)" != "null" ]]; then
NGINX_PORT=$(get_json_field "${STATE_FOLDER}/settings.json" port)
fi
# If sslPort is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)" != "null" ]]; then
NGINX_PORT_SSL=$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)
fi
# If listenIp is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
fi
# If demoMode is set in settings.json, use it
if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" demoMode)" == "true" ]]; then
DEMO_MODE="true"
fi
# If storagePath is set in settings.json, use it
storage_path_settings=$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)
if [[ "${storage_path_settings}" != "null" && "${storage_path_settings}" != "" ]]; then
storage_path="${storage_path_settings}"
STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
fi
fi
new_values="DOMAIN=${DOMAIN}\nDNS_IP=${DNS_IP}\nAPPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}\nREPO_ID=${REPO_ID}\nNGINX_PORT=${NGINX_PORT}\nNGINX_PORT_SSL=${NGINX_PORT_SSL}\nINTERNAL_IP=${INTERNAL_IP}\nSTORAGE_PATH=${STORAGE_PATH_ESCAPED}\nTZ=${TZ}\nJWT_SECRET=${JWT_SECRET}\nROOT_FOLDER=${SED_ROOT_FOLDER}\nTIPI_VERSION=${TIPI_VERSION}\nARCHITECTURE=${ARCHITECTURE}"
write_log "Final values: \n${new_values}"
### --------------------------------
### env file generation
### --------------------------------
ENV_FILE=$(mktemp)
[[ -f "${ROOT_FOLDER}/.env" ]] && rm -f "${ROOT_FOLDER}/.env"
[[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
# Function below is modified from Umbrel
# Required Notice: Copyright
# Umbrel (https://umbrel.com)
for template in ${ENV_FILE}; do
sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
sed -i "s/<internal_ip>/${INTERNAL_IP}/g" "${template}"
sed -i "s/<tz>/${TZ}/g" "${template}"
sed -i "s/<jwt_secret>/${JWT_SECRET}/g" "${template}"
sed -i "s/<root_folder>/${SED_ROOT_FOLDER}/g" "${template}"
sed -i "s/<tipi_version>/${TIPI_VERSION}/g" "${template}"
sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
sed -i "s/<postgres_username>/${POSTGRES_USERNAME}/g" "${template}"
sed -i "s/<postgres_dbname>/${POSTGRES_DBNAME}/g" "${template}"
sed -i "s/<postgres_port>/${POSTGRES_PORT}/g" "${template}"
sed -i "s/<postgres_host>/${POSTGRES_HOST}/g" "${template}"
sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
sed -i "s/<domain>/${DOMAIN}/g" "${template}"
sed -i "s/<storage_path>/${STORAGE_PATH_ESCAPED}/g" "${template}"
sed -i "s/<redis_host>/${REDIS_HOST}/g" "${template}"
sed -i "s/<demo_mode>/${DEMO_MODE}/g" "${template}"
done
mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
### --------------------------------
### Start the project
### --------------------------------
if [[ ! "${ci-false}" == "true" ]]; then
if [[ "${rc-false}" == "true" ]]; then
docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" pull
# Run docker compose
docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
echo "Failed to start containers"
exit 1
}
else
docker compose --env-file "${ROOT_FOLDER}/.env" pull
# Run docker compose
docker compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
echo "Failed to start containers"
exit 1
}
fi
if [[ "${rc-false}" == "true" ]]; then
docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" pull
# Run docker compose
docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
echo "Failed to start containers"
exit 1
}
else
docker compose --env-file "${ROOT_FOLDER}/.env" pull
# Run docker compose
docker compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
echo "Failed to start containers"
exit 1
}
fi
echo "Tipi is now running"
@ -328,11 +168,6 @@ cat <<"EOF"
()`
EOF
port_display=""
if [[ $NGINX_PORT != "80" ]]; then
port_display=":${NGINX_PORT}"
fi
echo ""
echo "Visit http://${INTERNAL_IP}${port_display}/ to view the dashboard"
echo "Visit http://${INTERNAL_IP}/ to view the dashboard"
echo ""

View file

@ -11,7 +11,9 @@ type SessionContent = {
};
declare module 'express-session' {
export type SessionData = SessionContent;
interface SessionData extends SessionContent {
userId?: number;
}
}
interface ExtendedGetServerSidePropsContext<Params, Preview> extends GetServerSidePropsContext<Params, Preview> {

View file

@ -22,6 +22,7 @@ export const LanguageSelector = (props: IProps) => {
<Select value={locale} defaultValue={locale} onValueChange={onChange}>
<SelectTrigger
className="mb-3"
name="language"
label={
showLabel && (
<span>
@ -34,7 +35,7 @@ export const LanguageSelector = (props: IProps) => {
)
}
>
<SelectValue placeholder="test" />
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LOCALE_OPTIONS.map((option) => (

View file

@ -6,7 +6,7 @@ interface IProps {
type?: 'submit' | 'reset' | 'button';
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
width?: number | null;
}

View file

@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { IconChevronRight } from '@tabler/icons-react';
import clsx from 'clsx';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger ref={ref} className={clsx('', inset && 'ps-8', className)} {...props}>
{children}
<IconChevronRight className="" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>>(
({ className, ...props }, ref) => <DropdownMenuPrimitive.SubContent ref={ref} className={clsx('', className)} {...props} />,
);
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>>(
({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content ref={ref} sideOffset={sideOffset} className={clsx('dropdown-menu d-block position-relative', className)} {...props} />
</DropdownMenuPrimitive.Portal>
),
);
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => <DropdownMenuPrimitive.Item ref={ref} className={clsx('dropdown-item cursor-pointer', inset && 'ps-1', className)} {...props} />);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => <DropdownMenuPrimitive.Label ref={ref} className={clsx('dropdown-header', inset && 'pl-8', className)} {...props} />);
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuGroup };

View file

@ -0,0 +1 @@
export { DropdownMenu, DropdownMenuItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuContent, DropdownMenuTrigger } from './DropdownMenu';

View file

@ -22,7 +22,7 @@ export const EmptyPage: React.FC<IProps> = ({ title, subtitle, onAction, actionL
className={clsx(styles.emptyImage, 'mb-3')}
style={{
maxWidth: '100%',
height: 'auto',
height: '80px',
}}
/>
<p className="empty-title">{title}</p>

View file

@ -52,7 +52,7 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
</h1>
</Link>
<div className="navbar-nav flex-row order-md-last">
<div className="nav-item d-none d-lg-flex me-3">
<div className="nav-item d-none d-xl-flex me-3">
<div className="btn-list">
<a href="https://github.com/meienberger/runtipi" target="_blank" rel="noreferrer" className="btn btn-dark">
<IconBrandGithub data-testid="icon-github" className="me-1 icon" size={24} />

View file

@ -19,7 +19,7 @@ const SelectValue = SelectPrimitive.Value;
// Button
const SelectTrigger = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & TriggerProps>(
({ className, error, label, children, ...props }, ref) => (
<label htmlFor={props.name} aria-labelledby={props.name} className={clsx('w-100', className)}>
<label htmlFor={props.name} className={clsx('w-100', className)}>
{Boolean(label) && (
<span id={props.name} className="form-label">
{label}
@ -27,8 +27,7 @@ const SelectTrigger = React.forwardRef<React.ElementRef<typeof SelectPrimitive.T
)}
<SelectPrimitive.Trigger
id={props.name}
aria-label={props.name}
name={props.name}
aria-labelledby={props.name}
ref={ref}
className={clsx('d-flex w-100 align-items-center justify-content-between form-select', { 'is-invalid is-invalid-lite': error })}
{...props}

View file

@ -1,11 +1,11 @@
import { getTRPCMock } from '@/client/mocks/getTrpcMock';
import { server } from '@/client/mocks/server';
import Cookies from 'js-cookie';
import { deleteCookie, setCookie, getCookie } from 'cookies-next';
import { renderHook, waitFor } from '../../../../tests/test-utils';
import { useLocale } from '../useLocale';
beforeEach(() => {
Cookies.remove('locale');
deleteCookie('tipi-locale');
});
describe('test: useLocale()', () => {
@ -28,7 +28,7 @@ describe('test: useLocale()', () => {
it('should return cookie locale if not logged in', async () => {
// arrange
const locale = 'fr-FR';
Cookies.set('locale', locale);
setCookie('tipi-locale', locale);
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
// act
@ -82,7 +82,7 @@ describe('test: useLocale()', () => {
// assert
await waitFor(() => {
expect(Cookies.get('locale')).toEqual('fr-FR');
expect(getCookie('tipi-locale')).toEqual('fr-FR');
});
});
@ -102,7 +102,7 @@ describe('test: useLocale()', () => {
// assert
await waitFor(() => {
expect(Cookies.get('locale')).toEqual(locale);
expect(getCookie('tipi-locale')).toEqual(locale);
});
});
});

View file

@ -1,5 +1,5 @@
import { setCookie, getCookie } from 'cookies-next';
import { useRouter } from 'next/router';
import Cookies from 'js-cookie';
import { trpc } from '@/utils/trpc';
import { Locale, getLocaleFromString } from '@/shared/internationalization/locales';
@ -8,9 +8,9 @@ export const useLocale = () => {
const me = trpc.auth.me.useQuery();
const changeUserLocale = trpc.auth.changeLocale.useMutation();
const browserLocale = typeof window !== 'undefined' ? window.navigator.language : undefined;
const cookieLocale = Cookies.get('locale');
const cookieLocale = getCookie('tipi-locale');
const locale = me.data?.locale || cookieLocale || browserLocale || 'en';
const locale = String(me.data?.locale || cookieLocale || browserLocale || 'en');
const ctx = trpc.useContext();
const changeLocale = async (l: Locale) => {
@ -19,9 +19,8 @@ export const useLocale = () => {
await ctx.invalidate();
}
Cookies.set('locale', l, {
expires: 30,
path: '/',
setCookie('tipi-locale', l, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
router.reload();

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -1,89 +1,89 @@
{
"server-messages": {
"errors": {
"invalid-credentials": "Invalid credentials",
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
"missing-email-or-password": "Missing email or password",
"invalid-username": "Invalid username",
"user-already-exists": "User already exists",
"error-creating-user": "Error creating user",
"no-change-password-request": "No change password request found",
"operator-not-found": "Operator user not found",
"user-not-found": "User not found",
"not-allowed-in-demo": "Not allowed in demo mode",
"not-allowed-in-dev": "Not allowed in dev mode",
"invalid-password": "Invalid password",
"invalid-password-length": "Password must be at least 8 characters long",
"invalid-locale": "Invalid locale",
"totp-session-not-found": "2FA session not found",
"totp-not-enabled": "2FA is not enabled for this user",
"totp-invalid-code": "Invalid 2FA code",
"totp-already-enabled": "2FA is already enabled for this user",
"app-not-found": "App {id} not found",
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
"app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
"app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
"app-failed-to-update": "Failed to update app {id}, see logs for more details",
"domain-required-if-expose-app": "Domain is required if app is exposed",
"domain-not-valid": "Domain {domain} is not a valid domain",
"invalid-config": "App {id} has an invalid config.json file",
"app-not-exposable": "App {id} is not exposable",
"app-force-exposed": "App {id} works only with exposed domain",
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
"could-not-get-latest-version": "Could not get latest version",
"current-version-is-latest": "Current version is already up to date",
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
"invalid-credentials": "Ungültige Zugangsdaten",
"admin-already-exists": "Es existiert bereits ein Admin-Benutzer. Bitte melde dich an, um einen neuen Benutzer im Admin-Panel zu erzeugen.",
"missing-email-or-password": "E-Mail oder Passwort fehlt",
"invalid-username": "Ungültiger Nutzername",
"user-already-exists": "Benutzer existiert bereits",
"error-creating-user": "Fehler bei der Erstellung des Nutzers",
"no-change-password-request": "Keine Anfrage, das Passwort zu ändern gefunden",
"operator-not-found": "Operator-Benutzer nicht gefunden",
"user-not-found": "Benutzer nicht gefunden",
"not-allowed-in-demo": "Im Demo-Modus nicht erlaubt",
"not-allowed-in-dev": "Im Dev-Modus nicht erlaubt",
"invalid-password": "Ungültiges Passwort",
"invalid-password-length": "Das Passwort muss mindestens 8 Zeichen lang sein",
"invalid-locale": "Ungültige Region",
"totp-session-not-found": "2FA-Sitzung nicht gefunden",
"totp-not-enabled": "2FA ist für diesen Nutzer nicht aktiviert",
"totp-invalid-code": "Ungültiger 2FA-Code",
"totp-already-enabled": "2FA wurde für diesen Nutzer bereits aktiviert",
"app-not-found": "App {id} nicht gefunden",
"app-failed-to-start": "Starten der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen",
"app-failed-to-install": "Installieren der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen",
"app-failed-to-stop": "Stoppen der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen",
"app-failed-to-uninstall": "Deinstallieren der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen",
"app-failed-to-update": "Aktualisieren der App {id} fehlgeschlagen. Siehe die Logs für weitere Informationen",
"domain-required-if-expose-app": "Eine Domain ist erforderlich, wenn die App veröffentlicht ist",
"domain-not-valid": "Die Domain {domain} ist ungültig",
"invalid-config": "Die App {id} hat eine ungültige config.json Datei",
"app-not-exposable": "App {id} nicht veröffentlichbar",
"app-force-exposed": "App {id} funktioniert nur mit veröffentlichter Domain",
"domain-already-in-use": "Die Domain {domain} ist bereits in Verwendung von der App {id}",
"could-not-get-latest-version": "Konnte aktuellste Version nicht abfragen",
"current-version-is-latest": "Die aktuelle Version ist bereits auf dem neuesten Stand",
"major-version-update": "Die Hauptversion hat sich geändert. Bitte aktualisieren Sie manuell. (Anleitung auf GitHub)"
},
"success": {}
},
"auth": {
"login": {
"title": "Login to your account",
"submit": "Login"
"title": "In Konto einloggen",
"submit": "Anmelden"
},
"totp": {
"title": "Two-factor authentication",
"instructions": "Enter the code from your authenticator app",
"submit": "Confirm"
"title": "Zwei-Faktor Authentifizierung",
"instructions": "Code aus der Authenticator-App eingeben",
"submit": "Bestätigen"
},
"register": {
"title": "Register your account",
"submit": "Register"
"title": "Ihr Konto erstellen",
"submit": "Registrieren"
},
"reset-password": {
"title": "Reset your password",
"submit": "Reset password",
"cancel": "Cancel password change request",
"instructions": "Run this command on your server and then refresh this page",
"success-title": "Password reset",
"success": "Your password has been reset. You can now login with your new password. And your email {email}",
"back-to-login": "Back to login"
"title": "Passwort zurücksetzen",
"submit": "Zurücksetzen",
"cancel": "Passwortänderung abbrechen",
"instructions": "Führen Sie diesen Befehl auf Ihrem Server aus und aktualisieren Sie die Seite",
"success-title": "Passwort zurückgesetzt",
"success": "Ihr Passwort wurde gespeichert. Sie können sich nun mit Ihrem neuen Passwort und ihrer E-Mail {email} anmelden",
"back-to-login": "Zurück zur Anmeldung"
},
"form": {
"email": "Email address",
"email-placeholder": "you@example.com",
"password": "Password",
"password-placeholder": "Enter your password",
"password-confirmation": "Confirm password",
"password-confirmation-placeholder": "Confirm your password",
"forgot": "Forgot password?",
"new-password-placeholder": "Your new password",
"new-password-confirmation-placeholder": "Confirm your new password",
"email": "E-Mail Adresse",
"email-placeholder": "name@beispiel.com",
"password": "Passwort",
"password-placeholder": "Passwort eingeben",
"password-confirmation": "Passwort bestätigen",
"password-confirmation-placeholder": "Passwort bestätigen",
"forgot": "Passwort vergessen?",
"new-password-placeholder": "Neues Passwort",
"new-password-confirmation-placeholder": "Neues Passwort bestätigen",
"errors": {
"email": {
"required": "Email address is required",
"email": "Email address is invalid",
"invalid": "Email address is invalid"
"required": "E-Mail-Adresse erforderlich",
"email": "E-Mail-Adresse ist ungültig",
"invalid": "E-Mail-Adresse ist ungültig"
},
"password": {
"required": "Password is required",
"minlength": "Password must be at least 8 characters"
"required": "Passwort erforderlich",
"minlength": "Das Passwort muss mindestens 8 Zeichen lang sein"
},
"password-confirmation": {
"required": "Password confirmation is required",
"minlength": "Password confirmation must be at least 8 characters",
"match": "Passwords do not match"
"required": "Passwortbestätigung erforderlich",
"minlength": "Die Passwortbestätigung muss mindestens 8 Zeichen lang sein",
"match": "Passwörter stimmen nicht überein"
}
}
}
@ -92,204 +92,208 @@
"title": "Dashboard",
"cards": {
"disk": {
"title": "Disk Space",
"subtitle": "Used out of {total} GB"
"title": "Speicherplatz",
"subtitle": "Wird von {total} GB verwendet"
},
"memory": {
"title": "Memory Used"
"title": "Speichernutzung"
},
"cpu": {
"title": "CPU Load",
"subtitle": "Uninstall apps to reduce load"
"title": "CPU-Auslastung",
"subtitle": "Apps deinstallieren um Last zu reduzieren"
}
}
},
"apps": {
"status-running": "Running",
"status-stopped": "Stopped",
"status-starting": "Starting",
"status-stopping": "Stopping",
"status-updating": "Updating",
"status-missing": "Missing",
"status-installing": "Installing",
"status-uninstalling": "Uninstalling",
"update-available": "Update available",
"status-running": "Aktiv",
"status-stopped": "Angehalten",
"status-starting": "Startet",
"status-stopping": "Stoppen",
"status-updating": "Aktualisieren",
"status-missing": "Fehlt",
"status-installing": "Installieren",
"status-uninstalling": "Deinstallieren",
"update-available": "Aktualisierung verfügbar",
"my-apps": {
"title": "My Apps",
"empty-title": "No app installed",
"empty-subtitle": "Install an app from the app store to get started",
"empty-action": "Go to app store"
"title": "Meine Apps",
"empty-title": "Keine Apps installiert",
"empty-subtitle": "Installiere eine App aus dem App Store, um loszulegen",
"empty-action": "Zum App Store"
},
"app-store": {
"title": "App Store",
"search-placeholder": "Search apps",
"category-placeholder": "Select a category",
"no-results": "No app found",
"no-results-subtitle": "Try to refine your search"
"search-placeholder": "Apps suchen",
"category-placeholder": "Kategorie wählen",
"no-results": "Keine App gefunden",
"no-results-subtitle": "Versuche, deine Suche zu verbessern"
},
"app-details": {
"install-success": "App installed successfully",
"uninstall-success": "App uninstalled successfully",
"stop-success": "App stopped successfully",
"update-success": "App updated successfully",
"start-success": "App started successfully",
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
"install-success": "App erfolgreich installiert",
"uninstall-success": "App erfolgreich deinstalliert",
"stop-success": "App erfolgreich angehalten",
"update-success": "App erfolgreich aktualisiert",
"start-success": "App erfolgreich gestartet",
"update-config-success": "App-Konfiguration erfolgreich aktualisiert. Starte die App neu, um die Änderungen zu übernehmen",
"version": "Version",
"description": "Description",
"base-info": "Base info",
"source-code": "Source code",
"author": "Author",
"description": "Beschreibung",
"base-info": "Basisinfo",
"source-code": "Quellcode",
"author": "Autor",
"port": "Port",
"categories-title": "Categories",
"categories-title": "Kategorien",
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"supported-arch": "Unterstütze Architekturen",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
"data": "Daten",
"network": "Netzwerk",
"media": "Media",
"development": "Development",
"automation": "Automation",
"social": "Social",
"utilities": "Utilities",
"security": "Security",
"photography": "Photography",
"featured": "Featured",
"books": "Books",
"music": "Music",
"finance": "Finance",
"development": "Entwicklung",
"automation": "Automatisierung",
"social": "Soziale Medien",
"utilities": "Dienstprogramme",
"security": "Sicherheit",
"photography": "Fotografie",
"featured": "Empfohlen",
"books": "Bücher",
"music": "Musik",
"finance": "Finanzen",
"gaming": "Gaming",
"ai": "AI"
"ai": "KI"
},
"actions": {
"start": "Start",
"remove": "Remove",
"settings": "Settings",
"stop": "Stop",
"open": "Open",
"loading": "Loading",
"cancel": "Cancel",
"install": "Install",
"update": "Update"
"remove": "Löschen",
"settings": "Einstellungen",
"stop": "Anhalten",
"open": "Öffnen",
"loading": "Laden",
"cancel": "Abbrechen",
"install": "Installieren",
"update": "Aktualisieren"
},
"install-form": {
"title": "Install {name}",
"expose-app": "Expose app",
"domain-name": "Domain name",
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
"choose-option": "Choose an option...",
"sumbit-install": "Install",
"submit-update": "Update",
"title": "{name} installieren",
"expose-app": "App veröffentlichen",
"domain-name": "Domainname",
"domain-name-hint": "Stellen Sie sicher, dass diese Domain einen A-Eintrag enthält, der auf Ihre IP verweist.",
"choose-option": "Wählen Sie eine Option...",
"sumbit-install": "Installieren",
"submit-update": "Aktualisieren",
"errors": {
"required": "{label} is required",
"regex": "{label} must match the pattern {pattern}",
"max-length": "{label} must be less than {max} characters",
"min-length": "{label} must be at least {min} characters",
"between-length": "{label} must be between {min} and {max} characters",
"invalid-email": "{label} must be a valid email address",
"number": "{label} must be a number",
"fqdn": "{label} must be a valid domain",
"ip": "{label} must be a valid IP address",
"fqdnip": "{label} must be a valid domain or IP address",
"url": "{label} must be a valid URL"
"required": "{label} ist erforderlich",
"regex": "{label} stimmt nicht mit dem Format {pattern} überein",
"max-length": "{label} muss kleiner als {max} Zeichen sein",
"min-length": "{label} muss mindestens {min} Zeichen lang sein",
"between-length": "{label} muss zwischen {min} und {max} Zeichen lang sein",
"invalid-email": "{label} muss eine gültige E-Mail Adresse sein",
"number": "{label} muss eine Nummer sein",
"fqdn": "{label} muss eine gültige Domain sein",
"ip": "{label} muss eine gültige IP-Adresse sein",
"fqdnip": "{label} muss eine gültige Domain oder IP-Addresse sein",
"url": "{label} muss eine gültige URL sein"
}
},
"stop-form": {
"title": "Stop {name} ?",
"subtitle": "All data will be retained",
"submit": "Stop"
"title": "{name} anhalten?",
"subtitle": "Alle Daten werden aufbewahrt",
"submit": "Anhalten"
},
"uninstall-form": {
"title": "Uninstall {name} ?",
"subtitle": "All data for this app will be lost.",
"warning": "Are you sure? This action cannot be undone.",
"submit": "Uninstall"
"title": "{name} deinstallieren?",
"subtitle": "Alle Daten für diese Anwendung werden gelöscht.",
"warning": "Sind Sie sicher? Dieser Schritt kann nicht rückgängig gemacht werden.",
"submit": "Deinstallieren"
},
"update-form": {
"title": "Update {name} ?",
"subtitle1": "Update app to latest verion :",
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
"submit": "Update"
"title": "Aktualisieren {name}?",
"subtitle1": "App auf die neueste Version aktualisieren:",
"subtitle2": "Dies wird Ihre benutzerdefinierte Konfiguration zurücksetzen (z.B. Änderungen in docker-compose.yml)",
"submit": "Aktualisieren"
},
"update-settings-form": {
"title": "Update {name} config"
"title": "Aktualisieren {name} Konfiguration"
}
}
},
"settings": {
"title": "Settings",
"title": "Einstellungen",
"actions": {
"tab-title": "Actions",
"title": "Actions",
"current-version": "Current version: {version}",
"stay-up-to-date": "Stay up to date with the latest version of Tipi",
"new-version": "A new version ({version}) of Tipi is available",
"maintenance-title": "Maintenance",
"maintenance-subtitle": "Common actions to perform on your instance",
"restart": "Restart",
"update": "Update to {version}",
"already-latest": "Already up to date"
"tab-title": "Aktionen",
"title": "Aktionen",
"current-version": "Aktuelle Version: {version}",
"stay-up-to-date": "Bleiben Sie auf dem Laufenden mit der neuesten Version von Tipi",
"new-version": "Eine neue Version ({version}) von Tipi ist verfügbar",
"maintenance-title": "Wartung",
"maintenance-subtitle": "Beliebte Aktionen, die Sie auf Ihrer Instanz durchführen können",
"restart": "Neustart",
"update": "Aktualisieren zu {version}",
"already-latest": "Bereits auf dem neuesten Stand"
},
"settings": {
"tab-title": "Settings",
"title": "General settings",
"subtitle": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
"settings-updated": "Settings updated. Restart your instance to apply new settings.",
"invalid-ip": "Invalid IP address",
"invalid-url": "Invalid URL",
"invalid-domain": "Invalid domain",
"domain-name": "Domain name",
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
"tab-title": "Einstellungen",
"title": "Allgemeine Einstellungen",
"subtitle": "Dadurch wird Ihre Datei settings.json aktualisiert. Stellen Sie sicher, dass Sie wissen, was Sie tun, bevor Sie diese Werte aktualisieren.",
"settings-updated": "Einstellungen aktualisiert. Starten Sie Ihre Instanz neu, um die Einstellungen zu übernehmen.",
"invalid-ip": "Ungültige IP-Adresse",
"invalid-url": "Ungültige URL",
"invalid-domain": "Ungültige Domain",
"domain-name": "Domänenname",
"domain-name-hint": "Stellen Sie sicher, dass genau diese Domain einen A-Eintrag enthält, der auf Ihre IP verweist.",
"dns-ip": "DNS IP",
"internal-ip": "Internal IP",
"internal-ip-hint": "IP address your server is listening on.",
"apps-repo": "Apps repo URL",
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"internal-ip": "Interne IP",
"internal-ip-hint": "IP-Adresse, die Ihr Server abhört.",
"apps-repo": "App-Repo-URL",
"apps-repo-hint": "URL zum App-Repository.",
"storage-path": "Speicherpfad",
"storage-path-hint": "Pfad zum Speicherverzeichnis. Leer lassen für Standard (runtipi/app-data). Stelle sicher, dass es ein absoluter Pfad ist, der existiert",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Speichern",
"user-settings-title": "Benutzereinstellungen",
"language": "Sprache",
"help-translate": "Beim Übersetzen helfen",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",
"change-password-title": "Change password",
"change-password-subtitle": "Changing your password will log you out of all devices.",
"password-change-success": "Password changed successfully",
"2fa-title": "Two-factor authentication",
"2fa-subtitle": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
"2fa-subtitle-2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
"2fa-enable-success": "Two-factor authentication enabled",
"2fa-disable-success": "Two-factor authentication disabled",
"scan-qr-code": "Scan this QR code with your authenticator app.",
"enter-key-manually": "Or enter this key manually.",
"enter-2fa-code": "Enter the 6-digit code from your authenticator app",
"enable-2fa": "Enable two-factor authentication",
"disable-2fa": "Disable two-factor authentication",
"password-needed": "Password needed",
"password-needed-hint": "Your password is required to change two-factor authentication settings.",
"tab-title": "Sicherheit",
"change-password-title": "Passwort ändern",
"change-password-subtitle": "Das Ändern des Passworts wird Sie von allen Geräten abmelden.",
"password-change-success": "Passwort erfolgreich geändert",
"2fa-title": "Zwei-Faktor Authentifizierung",
"2fa-subtitle": "Zwei-Faktor Authentifizierung (2FA) fügt deinem Konto eine weitere Sicherheitsebene zu.",
"2fa-subtitle-2": "Wenn aktiviert, werden Sie bei der Anmeldung aufgefordert, einen Code aus ihrer Authentifizierungs-App einzugeben.",
"2fa-enable-success": "Zwei-Faktor Authentifizierung aktiviert",
"2fa-disable-success": "ZweiFaktorAuthentifizierung deaktiviert",
"scan-qr-code": "Scannen Sie diesen QR-Code mit Ihrer Authentifizierungs-App.",
"enter-key-manually": "Oder diesen Code manuell eingeben.",
"enter-2fa-code": "Geben Sie den 6-stelligen Code aus\nihrer Authentifizierungs-App ein",
"enable-2fa": "Zwei-Faktor-Authentifizierung aktivieren",
"disable-2fa": "Zwei-Faktor-Authentifizierung deaktivieren",
"password-needed": "Passwort erforderlich",
"password-needed-hint": "Ihr Passwort wird benötigt, um die Zwei-Faktor-Authentifizierungseinstellungen zu ändern.",
"form": {
"password-length": "Password must be at least 8 characters",
"password-match": "Passwords do not match",
"current-password": "Current password",
"new-password": "New password",
"confirm-password": "Confirm new password",
"change-password": "Change password",
"password": "Password"
"password-length": "Das Passwort muss mindestens 8 Zeichen lang sein",
"password-match": "Passwörter stimmen nicht überein",
"current-password": "Aktuelles Passwort",
"new-password": "Neues Passwort",
"confirm-password": "Neues Passwort bestätigen",
"change-password": "Passwort ändern",
"password": "Passwort"
}
}
},
"header": {
"dashboard": "Dashboard",
"my-apps": "My Apps",
"my-apps": "Meine Apps",
"app-store": "App Store",
"settings": "Settings",
"logout": "Logout",
"dark-mode": "Dark Mode",
"light-mode": "Light Mode",
"settings": "Einstellungen",
"logout": "Abmelden",
"dark-mode": "Dunkler Modus",
"light-mode": "Heller Modus",
"sponsor": "Sponsor",
"source-code": "Source code",
"update-available": "Update available"
"source-code": "Quellcode",
"update-available": "Aktualisierung verfügbar"
}
}

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -1,24 +1,24 @@
{
"server-messages": {
"errors": {
"invalid-credentials": "Invalid credentials",
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
"missing-email-or-password": "Missing email or password",
"invalid-username": "Invalid username",
"user-already-exists": "User already exists",
"error-creating-user": "Error creating user",
"no-change-password-request": "No change password request found",
"operator-not-found": "Operator user not found",
"user-not-found": "User not found",
"not-allowed-in-demo": "Not allowed in demo mode",
"not-allowed-in-dev": "Not allowed in dev mode",
"invalid-password": "Invalid password",
"invalid-password-length": "Password must be at least 8 characters long",
"invalid-locale": "Invalid locale",
"totp-session-not-found": "2FA session not found",
"totp-not-enabled": "2FA is not enabled for this user",
"totp-invalid-code": "Invalid 2FA code",
"totp-already-enabled": "2FA is already enabled for this user",
"invalid-credentials": "Credenciales inválidas",
"admin-already-exists": "Ya existe un usuario administrador. Inicie sesión para crear un nuevo usuario desde el panel de administración.",
"missing-email-or-password": "Falta el correo electrónico o la contraseña",
"invalid-username": "Nombre de usuario inválido",
"user-already-exists": "El usuario ya existe",
"error-creating-user": "Error al crear usuario",
"no-change-password-request": "No se ha encontrado ninguna solicitud de cambio de contraseña",
"operator-not-found": "Usuario operador no encontrado",
"user-not-found": "Usuario no encontrado",
"not-allowed-in-demo": "No se permite en modo demo",
"not-allowed-in-dev": "No está permitido en modo dev",
"invalid-password": "Contraseña invalida",
"invalid-password-length": "La contraseña debe tener al menos 8 caracteres",
"invalid-locale": "Local inválido",
"totp-session-not-found": "Sesión 2FA no encontrado",
"totp-not-enabled": "2FA no está habilitado para este usuario",
"totp-invalid-code": "Código 2FA no válido",
"totp-already-enabled": "2FA ya está habilitado para este usuario",
"app-not-found": "App {id} not found",
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Lien",
"website": "Site Web",
"supported-arch": "Architectures supportées",
"choose-open-method": "Mode d'ouverture",
"categories": {
"data": "Données",
"network": "Réseau",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL vers le repo des applications.",
"storage-path": "Répertoire de stockage",
"storage-path-hint": "Chemin d'accès au répertoire de stockage. Laisser vide pour les valeurs par défaut (runtipi/app-data). Assurez-vous qu'il s'agit d'un chemin absolu et qu'il existe",
"local-domain": "Domaine local",
"local-domain-hint": "Nom de domaine utilisé pour accéder aux applications dans votre réseau local. Vos applications seront accessibles sur app-name.local-domain.",
"submit": "Sauvegarder",
"user-settings-title": "Paramètres utilisateur",
"language": "Langue",
"help-translate": "Aidez à traduire Tipi"
"help-translate": "Aidez à traduire Tipi",
"download-certificate": "Télécharger le certificat"
},
"security": {
"tab-title": "Sécurité",

View file

@ -1,295 +1,299 @@
{
"server-messages": {
"errors": {
"invalid-credentials": "Invalid credentials",
"admin-already-exists": "There is already an admin user. Please login to create a new user from the admin panel.",
"missing-email-or-password": "Missing email or password",
"invalid-username": "Invalid username",
"user-already-exists": "User already exists",
"error-creating-user": "Error creating user",
"no-change-password-request": "No change password request found",
"operator-not-found": "Operator user not found",
"user-not-found": "User not found",
"not-allowed-in-demo": "Not allowed in demo mode",
"not-allowed-in-dev": "Not allowed in dev mode",
"invalid-password": "Invalid password",
"invalid-password-length": "Password must be at least 8 characters long",
"invalid-locale": "Invalid locale",
"totp-session-not-found": "2FA session not found",
"totp-not-enabled": "2FA is not enabled for this user",
"totp-invalid-code": "Invalid 2FA code",
"totp-already-enabled": "2FA is already enabled for this user",
"app-not-found": "App {id} not found",
"app-failed-to-start": "Failed to start app {id}, see logs for more details",
"app-failed-to-install": "Failed to install app {id}, see logs for more details",
"app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
"app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
"app-failed-to-update": "Failed to update app {id}, see logs for more details",
"domain-required-if-expose-app": "Domain is required if app is exposed",
"domain-not-valid": "Domain {domain} is not a valid domain",
"invalid-config": "App {id} has an invalid config.json file",
"app-not-exposable": "App {id} is not exposable",
"app-force-exposed": "App {id} works only with exposed domain",
"domain-already-in-use": "Domain {domain} is already in use by app {id}",
"could-not-get-latest-version": "Could not get latest version",
"current-version-is-latest": "Current version is already up to date",
"major-version-update": "The major version has changed. Please update manually (instructions on GitHub)"
"invalid-credentials": "Érvénytelen hitelesítő adatok",
"admin-already-exists": "Már van egy admin felhasználó. Kérjük, jelentkezzen be, hogy új felhasználót hozzon létre az admin panelen.",
"missing-email-or-password": "Hiányzó e-mail cím vagy jelszó",
"invalid-username": "Érvénytelen felhasználónév",
"user-already-exists": "A felhasználó már létezik",
"error-creating-user": "Hiba a felhasználó létrehozásakor",
"no-change-password-request": "Nincs jelszóváltoztatási kérelem",
"operator-not-found": "Az operátor felhasználó nem található",
"user-not-found": "A felhasználó nem található",
"not-allowed-in-demo": "Demó üzemmódban nem engedélyezett",
"not-allowed-in-dev": "Dev módban nem megengedett",
"invalid-password": "Érvénytelen jelszó",
"invalid-password-length": "A jelszónak legalább 8 karakter hosszúnak kell lennie",
"invalid-locale": "Érvénytelen nyelvi beállítás",
"totp-session-not-found": "2FA munkamenet nem található",
"totp-not-enabled": "A 2FA nincs engedélyezve ennél a felhasználónál",
"totp-invalid-code": "Érvénytelen 2FA kód",
"totp-already-enabled": "A 2FA már engedélyezve van ennél a felhasználónál",
"app-not-found": "A(z) {id} alkalmazás nem található",
"app-failed-to-start": "Nem sikerült elindítani az {id} alkalmazást. További részletekért tekintse meg a naplókat",
"app-failed-to-install": "Nem sikerült telepíteni az {id} alkalmazást. További részletekért tekintse meg a naplókat",
"app-failed-to-stop": "Nem sikerült leállítani az {id} alkalmazást. További részletekért tekintse meg a naplókat",
"app-failed-to-uninstall": "Nem sikerült eltávolítani az {id} alkalmazást. További részletekért tekintse meg a naplókat",
"app-failed-to-update": "Nem sikerült frissíteni az {id} alkalmazást. További részletekért tekintse meg a naplókat",
"domain-required-if-expose-app": "Domain szükséges, ha az alkalmazás exponálva van",
"domain-not-valid": "A(z) {domain} domain nem érvényes domain",
"invalid-config": "A(z) {id} alkalmazás érvénytelen config.json fájlt tartalmaz",
"app-not-exposable": "Az {id} alkalmazás nem hozzáférhető",
"app-force-exposed": "Az {id} alkalmazás csak nyilvános domainnel működik",
"domain-already-in-use": "A(z) {domain} domaint már használja a(z) {id} alkalmazás",
"could-not-get-latest-version": "Nem sikerült letölteni a legújabb verziót",
"current-version-is-latest": "A jelenlegi verzió már naprakész",
"major-version-update": "A fő verzió megváltozott. Kérjük, frissítse manuálisan (utasítások a GitHubon)"
},
"success": {}
},
"auth": {
"login": {
"title": "Login to your account",
"submit": "Login"
"title": "Jelentkezz be a fiókodba",
"submit": "Belépés"
},
"totp": {
"title": "Two-factor authentication",
"instructions": "Enter the code from your authenticator app",
"submit": "Confirm"
"title": "Kétlépcsős hitelesítés",
"instructions": "Írja be a kódot a hitelesítő alkalmazásból",
"submit": "Megerősít"
},
"register": {
"title": "Register your account",
"submit": "Register"
"title": "Fiók regisztráció",
"submit": "Regisztráció"
},
"reset-password": {
"title": "Reset your password",
"submit": "Reset password",
"cancel": "Cancel password change request",
"instructions": "Run this command on your server and then refresh this page",
"success-title": "Password reset",
"success": "Your password has been reset. You can now login with your new password. And your email {email}",
"back-to-login": "Back to login"
"title": "Jelszó visszaállítása",
"submit": "Jelszó visszaállítása",
"cancel": "Jelszómódosítási kérés visszavonása",
"instructions": "Futtassa ezt a parancsot a szerverén, majd frissítse ezt az oldalt",
"success-title": "Jelszó visszaállítása",
"success": "A jelszava vissza lett állítva. Most már bejelentkezhet az új jelszavával. Ez az e-mail címe {email}",
"back-to-login": "Vissza a belépéshez"
},
"form": {
"email": "Email address",
"email-placeholder": "you@example.com",
"password": "Password",
"password-placeholder": "Enter your password",
"password-confirmation": "Confirm password",
"password-confirmation-placeholder": "Confirm your password",
"forgot": "Forgot password?",
"new-password-placeholder": "Your new password",
"new-password-confirmation-placeholder": "Confirm your new password",
"email": "E-mail cím",
"email-placeholder": "nev@pelda.com",
"password": "Jelszó",
"password-placeholder": "Jelszó megadása",
"password-confirmation": "Jelszó megerősítése",
"password-confirmation-placeholder": "Jelszó megerősítése",
"forgot": "Elfelejtette jelszavát?",
"new-password-placeholder": "Az új jelszava",
"new-password-confirmation-placeholder": "Erősítse meg az új jelszavát",
"errors": {
"email": {
"required": "Email address is required",
"email": "Email address is invalid",
"invalid": "Email address is invalid"
"required": "E-mail cím megadása kötelező",
"email": "E-mail cím érvénytelen",
"invalid": "Az e-mail cím érvénytelen"
},
"password": {
"required": "Password is required",
"minlength": "Password must be at least 8 characters"
"required": "Jelszó megadása szükséges",
"minlength": "A jelszónak minimum 8 karakternek kell lennie"
},
"password-confirmation": {
"required": "Password confirmation is required",
"minlength": "Password confirmation must be at least 8 characters",
"match": "Passwords do not match"
"required": "Jelszó megerősítés szükséges",
"minlength": "A jelszónak legalább 8 karakterből kell állnia",
"match": "A jelszavak nem egyeznek"
}
}
}
},
"dashboard": {
"title": "Dashboard",
"title": "Irányítópult",
"cards": {
"disk": {
"title": "Disk Space",
"subtitle": "Used out of {total} GB"
"title": "Lemezterület",
"subtitle": "A {total} GB-ból használható"
},
"memory": {
"title": "Memory Used"
"title": "Memória használat"
},
"cpu": {
"title": "CPU Load",
"subtitle": "Uninstall apps to reduce load"
"title": "CPU kihasználtság",
"subtitle": "Távolítson el alkalmazásokat a terhelés csökkentése érdekében"
}
}
},
"apps": {
"status-running": "Running",
"status-stopped": "Stopped",
"status-starting": "Starting",
"status-stopping": "Stopping",
"status-updating": "Updating",
"status-missing": "Missing",
"status-installing": "Installing",
"status-uninstalling": "Uninstalling",
"update-available": "Update available",
"status-running": "Fut",
"status-stopped": "Megállítva",
"status-starting": "Indítás",
"status-stopping": "Leállítás",
"status-updating": "Frissítés",
"status-missing": "Hiányzó",
"status-installing": "Telepítés folyamatban",
"status-uninstalling": "Eltávolítás",
"update-available": "Frissítés elérhető",
"my-apps": {
"title": "My Apps",
"empty-title": "No app installed",
"empty-subtitle": "Install an app from the app store to get started",
"empty-action": "Go to app store"
"title": "Alkalmazásaim",
"empty-title": "Nincsenek alkalmazások telepítve",
"empty-subtitle": "Telepítsen egy alkalmazást az Alkalmazásboltból az induláshoz",
"empty-action": "Ugrás az Alkalmazásboltba"
},
"app-store": {
"title": "App Store",
"search-placeholder": "Search apps",
"category-placeholder": "Select a category",
"no-results": "No app found",
"no-results-subtitle": "Try to refine your search"
"title": "Alkalmazásbolt",
"search-placeholder": "Alkalmazások keresése",
"category-placeholder": "Kategória választása",
"no-results": "Nem található alkalmazás",
"no-results-subtitle": "Próbálja meg finomítani a keresést"
},
"app-details": {
"install-success": "App installed successfully",
"uninstall-success": "App uninstalled successfully",
"stop-success": "App stopped successfully",
"update-success": "App updated successfully",
"start-success": "App started successfully",
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
"version": "Version",
"description": "Description",
"base-info": "Base info",
"source-code": "Source code",
"author": "Author",
"install-success": "Az alkalmazás sikeresen telepítve",
"uninstall-success": "Az alkalmazás sikeresen eltávolítva",
"stop-success": "Az alkalmazás sikeresen leállítva",
"update-success": "Az alkalmazás sikeresen frissítve",
"start-success": "Az alkalmazás sikeresen elindítva",
"update-config-success": "Az alkalmazás konfigurációja sikeresen frissült. Indítsa újra az alkalmazást a módosítások alkalmazásához",
"version": "Verzió",
"description": "Leírás",
"base-info": "Alapinformáció",
"source-code": "Forráskód",
"author": "Szerző",
"port": "Port",
"categories-title": "Categories",
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"categories-title": "Kategóriák",
"link": "Hivatkozás",
"website": "Weboldal",
"supported-arch": "Támogatott architektúrák",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
"media": "Media",
"development": "Development",
"automation": "Automation",
"social": "Social",
"utilities": "Utilities",
"security": "Security",
"photography": "Photography",
"featured": "Featured",
"books": "Books",
"music": "Music",
"finance": "Finance",
"gaming": "Gaming",
"data": "Adat",
"network": "Hálózat",
"media": "Média",
"development": "Fejlesztés",
"automation": "Automatizálás",
"social": "Közösségi",
"utilities": "Segédprogramok",
"security": "Biztonság",
"photography": "Fényképészet",
"featured": "Ajánlott",
"books": "Könyvek",
"music": "Zene",
"finance": "Pénzügyek",
"gaming": "Játék",
"ai": "AI"
},
"actions": {
"start": "Start",
"remove": "Remove",
"settings": "Settings",
"stop": "Stop",
"open": "Open",
"loading": "Loading",
"cancel": "Cancel",
"install": "Install",
"update": "Update"
"start": "Indít",
"remove": "Eltávolít",
"settings": "Beállítások",
"stop": "Megállít",
"open": "Megnyitás",
"loading": "Betöltés",
"cancel": "Mégsem",
"install": "Telepítés",
"update": "Frissítés"
},
"install-form": {
"title": "Install {name}",
"expose-app": "Expose app",
"domain-name": "Domain name",
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
"choose-option": "Choose an option...",
"sumbit-install": "Install",
"submit-update": "Update",
"title": "{name} telepítése",
"expose-app": "Tegye ki az alkalmazást",
"domain-name": "Domain név",
"domain-name-hint": "Győződjön meg arról, hogy ez a domain tartalmaz egy A rekordot, amely az Ön IP-jére mutat.",
"choose-option": "Válasszon egy lehetőséget...",
"sumbit-install": "Telepítés",
"submit-update": "Frissítés",
"errors": {
"required": "{label} is required",
"regex": "{label} must match the pattern {pattern}",
"max-length": "{label} must be less than {max} characters",
"min-length": "{label} must be at least {min} characters",
"between-length": "{label} must be between {min} and {max} characters",
"invalid-email": "{label} must be a valid email address",
"number": "{label} must be a number",
"fqdn": "{label} must be a valid domain",
"ip": "{label} must be a valid IP address",
"fqdnip": "{label} must be a valid domain or IP address",
"url": "{label} must be a valid URL"
"required": "{label} megadása kötelező",
"regex": "{label} meg kell egyeznie a mintával {pattern}",
"max-length": "A {label} karakternek kevesebbnek kell lennie, mint a {max} karakter(ek)",
"min-length": "{label} legalább {min} karakter hosszú legyen",
"between-length": "A {label} {min} és {max} karakterek között kell lennie",
"invalid-email": "{label} érvényes e-mail címnek kell lennie",
"number": "{label} számnak kell lennie",
"fqdn": "A {label} érvényes domainnek kell lennie",
"ip": "{label} érvényes IP-címnek kell lennie",
"fqdnip": "{label} érvényes domain vagy IP-cím lehet",
"url": "{label} érvényes URL címnek kell lennie"
}
},
"stop-form": {
"title": "Stop {name} ?",
"subtitle": "All data will be retained",
"submit": "Stop"
"title": "Megállít {name} ?",
"subtitle": "Minden adat megmarad",
"submit": "Megállít"
},
"uninstall-form": {
"title": "Uninstall {name} ?",
"subtitle": "All data for this app will be lost.",
"warning": "Are you sure? This action cannot be undone.",
"submit": "Uninstall"
"title": "Távolítsa el a {name} ?",
"subtitle": "Az alkalmazás összes adata elveszik.",
"warning": "Biztos benne? Ezt a műveletet nem lehet visszavonni.",
"submit": "Eltávolit"
},
"update-form": {
"title": "Update {name} ?",
"subtitle1": "Update app to latest verion :",
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
"submit": "Update"
"title": "Frissíti {name} ?",
"subtitle1": "Frissítse az alkalmazást a legújabb verzióra:",
"subtitle2": "Ez visszaállítja az egyéni konfigurációt (pl. változások a docker-compose.yml-ben)",
"submit": "Frissítés"
},
"update-settings-form": {
"title": "Update {name} config"
"title": "Frissítse a {name} konfigurációt"
}
}
},
"settings": {
"title": "Settings",
"title": "Beállítások",
"actions": {
"tab-title": "Actions",
"title": "Actions",
"current-version": "Current version: {version}",
"stay-up-to-date": "Stay up to date with the latest version of Tipi",
"new-version": "A new version ({version}) of Tipi is available",
"maintenance-title": "Maintenance",
"maintenance-subtitle": "Common actions to perform on your instance",
"restart": "Restart",
"update": "Update to {version}",
"already-latest": "Already up to date"
"tab-title": "Műveletek",
"title": "Műveletek",
"current-version": "Jelenlegi verzió: {version}",
"stay-up-to-date": "Legyen naprakész a Tipi legújabb verziójával",
"new-version": "A Tipi új verziója ({version}) elérhető",
"maintenance-title": "Karbantartás",
"maintenance-subtitle": "Általános műveletek ezen a példányon",
"restart": "Újraindítás",
"update": "Frissítés erre {version}",
"already-latest": "Már naprakész"
},
"settings": {
"tab-title": "Settings",
"title": "General settings",
"subtitle": "This will update your settings.json file. Make sure you know what you are doing before updating these values.",
"settings-updated": "Settings updated. Restart your instance to apply new settings.",
"invalid-ip": "Invalid IP address",
"invalid-url": "Invalid URL",
"invalid-domain": "Invalid domain",
"domain-name": "Domain name",
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
"dns-ip": "DNS IP",
"internal-ip": "Internal IP",
"internal-ip-hint": "IP address your server is listening on.",
"apps-repo": "Apps repo URL",
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"tab-title": "Beállítások",
"title": "Általános beállítások",
"subtitle": "Ez frissíti a settings.json fájlt. Mielőtt frissítené ezeket az értékeket, győződjön meg arról, hogy tudja, mit csinál.",
"settings-updated": "A beállítások frissítve. Indítsa újra a példányt az új beállítások alkalmazásához.",
"invalid-ip": "Érvénytelen IP-cím",
"invalid-url": "Érvénytelen URL",
"invalid-domain": "Érvénytelen domain név",
"domain-name": "Domain név",
"domain-name-hint": "Győződjön meg arról, hogy ez a domain tartalmaz egy A rekordot, amely az Ön IP-jére mutat.",
"dns-ip": "DNS IP címe",
"internal-ip": "Belső IP cím",
"internal-ip-hint": "A szerver által hallgatott IP-cím.",
"apps-repo": "Alkalmazás-tárház URL címe",
"apps-repo-hint": "Az alkalmazás-tárház URL-je.",
"storage-path": "A tárhely elérési útja",
"storage-path-hint": "Út a tárolókönyvtárhoz. Alapértelmezés szerint maradjon üres (runtipi/app-data). Ügyeljen arra, hogy abszolút út legyen, és hogy létezik",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Mentés",
"user-settings-title": "Felhasználói beállítások",
"language": "Nyelv",
"help-translate": "Segíts a Tipi fordításában",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",
"change-password-title": "Change password",
"change-password-subtitle": "Changing your password will log you out of all devices.",
"password-change-success": "Password changed successfully",
"2fa-title": "Two-factor authentication",
"2fa-subtitle": "Two-factor authentication (2FA) adds an additional layer of security to your account.",
"2fa-subtitle-2": "When enabled, you will be prompted to enter a code from your authenticator app when you log in.",
"2fa-enable-success": "Two-factor authentication enabled",
"2fa-disable-success": "Two-factor authentication disabled",
"scan-qr-code": "Scan this QR code with your authenticator app.",
"enter-key-manually": "Or enter this key manually.",
"enter-2fa-code": "Enter the 6-digit code from your authenticator app",
"enable-2fa": "Enable two-factor authentication",
"disable-2fa": "Disable two-factor authentication",
"password-needed": "Password needed",
"password-needed-hint": "Your password is required to change two-factor authentication settings.",
"tab-title": "Biztonság",
"change-password-title": "Jelszó módosítása",
"change-password-subtitle": "A jelszó megváltoztatása után minden eszközből kijelentkezik.",
"password-change-success": "A jelszó sikeresen megváltozott",
"2fa-title": "Kétlépcsős hitelesítés",
"2fa-subtitle": "Kétlépcsős azonosítás (2FA) használatával a fiók biztonsága tovább növelhető.",
"2fa-subtitle-2": "Ha engedélyezve van, akkor a rendszer kéri, hogy írjon be egy kódot a hitelesítő alkalmazásból, amikor bejelentkezik.",
"2fa-enable-success": "Kétlépcsős azonosítás engedélyezve",
"2fa-disable-success": "Kétlépcsős azonosítás kikapcsolva",
"scan-qr-code": "Ezt a QR kódot kell beolvasni a hitelesítő alkalmazással.",
"enter-key-manually": "Vagy adja meg a kulcsot manuálisan.",
"enter-2fa-code": "Írja be a 6 számjegyű kódot a hitelesítő alkalmazásból",
"enable-2fa": "Kétlépcsős azonosítás engedélyezése",
"disable-2fa": "Kétlépcsős azonosítás letiltása",
"password-needed": "Jelszó szükséges",
"password-needed-hint": "A kétlépcsős hitelesítési beállítások megváltoztatásához szükséges a jelszavad.",
"form": {
"password-length": "Password must be at least 8 characters",
"password-match": "Passwords do not match",
"current-password": "Current password",
"new-password": "New password",
"confirm-password": "Confirm new password",
"change-password": "Change password",
"password": "Password"
"password-length": "A jelszónak minimum 8 karakter hosszúnak kell lennie",
"password-match": "A jelszavak nem egyeznek",
"current-password": "Jelenlegi jelszó",
"new-password": "Új jelszó",
"confirm-password": "Új jelszó megerősítése",
"change-password": "Jelszó módosítása",
"password": "Jelszó"
}
}
},
"header": {
"dashboard": "Dashboard",
"my-apps": "My Apps",
"app-store": "App Store",
"settings": "Settings",
"logout": "Logout",
"dark-mode": "Dark Mode",
"light-mode": "Light Mode",
"sponsor": "Sponsor",
"source-code": "Source code",
"update-available": "Update available"
"dashboard": "Irányítópult",
"my-apps": "Alkalmazásaim",
"app-store": "Alkalmazásbolt",
"settings": "Beállítások",
"logout": "Kijelentkezés",
"dark-mode": "Sötét mód",
"light-mode": "Világos mód",
"sponsor": "Szponzor",
"source-code": "Forráskód",
"update-available": "Frissítés érhető el"
}
}

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "リンク",
"website": "ウェブサイト",
"supported-arch": "サポートされるアーキテクチャ",
"choose-open-method": "Choose open method",
"categories": {
"data": "データ",
"network": "ネットワーク",
@ -247,10 +248,13 @@
"apps-repo-hint": "アプリリポジトリの URL です。",
"storage-path": "ストレージの場所",
"storage-path-hint": "ストレージディレクトリへのパスです。デフォルトなら空のままにしてくださいruntipi/app-data。絶対パスであり、存在することを確認してください",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "保存",
"user-settings-title": "ユーザー設定",
"language": "言語設定",
"help-translate": "翻訳に協力"
"help-translate": "翻訳に協力",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "セキュリティ",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Strona",
"supported-arch": "Wspierane struktury",
"choose-open-method": "Choose open method",
"categories": {
"data": "Dane",
"network": "Sieć",
@ -247,10 +248,13 @@
"apps-repo-hint": "Adres URL do repozytorium aplikacji.",
"storage-path": "Ścieżka przechowywania",
"storage-path-hint": "Ścieżka do katalogu pamięci. Pozostaw domyślnie puste (runtipi/app-data). Upewnij się, że jest to ścieżka bezwzględna i że istnieje",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Zapisz",
"user-settings-title": "Ustawienia użytkownika",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Bezpieczeństwo",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Site web",
"supported-arch": "Arhitecturi suportate",
"choose-open-method": "Choose open method",
"categories": {
"data": "Informații",
"network": "Rețea",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL către repo-ul aplicațiilor.",
"storage-path": "Calea de stocare",
"storage-path-hint": "Calea către directorul de stocare. Păstrați gol pentru dosarul implicit (runtipi/app-data). Asigurați-vă că este o cale absolută și că există",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Salvează",
"user-settings-title": "Setările utilizatorului",
"language": "Limbă",
"help-translate": "Ajută la traducerea Tipi"
"help-translate": "Ajută la traducerea Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Securitate",

View file

@ -10,15 +10,15 @@
"no-change-password-request": "Не найден запрос на смену пароля",
"operator-not-found": "Оператор не найден",
"user-not-found": "Пользователь не найден",
"not-allowed-in-demo": "Не допускается в демо-режиме",
"not-allowed-in-dev": "Не разрешено в режиме разработки",
"not-allowed-in-demo": "Запрещено в демо режиме",
"not-allowed-in-dev": "Запрещено в режиме разработки",
"invalid-password": "Неверный пароль",
"invalid-password-length": "Пароль должен содержать не менее 8 символов",
"invalid-password-length": "Пароль должен быть не меньше 8 символов",
"invalid-locale": "Недопустимый язык",
"totp-session-not-found": "2FA сессия не найдена",
"totp-not-enabled": "2FA не включена для этого пользователя",
"totp-invalid-code": "Неверный 2FA код",
"totp-already-enabled": "2FA уже включена для этого пользователя",
"totp-session-not-found": "Сессия 2х факторной авторизации не найдена",
"totp-not-enabled": "2-х факторная авторизация не включена для этого пользователя",
"totp-invalid-code": "Неверный код 2-х факторной авторизации",
"totp-already-enabled": "2-х факторная авторизация уже включена для этого пользователя",
"app-not-found": "Приложение {id} не найдено",
"app-failed-to-start": "Не удалось запустить приложение {id}. См. подробности в логах",
"app-failed-to-install": "Не удалось установить приложение {id}. См. подробности в логах",
@ -52,11 +52,11 @@
"submit": "Зарегистрироваться"
},
"reset-password": {
"title": "Сбросить пароль",
"title": "Сбросить ваш пароль",
"submit": "Сбросить пароль",
"cancel": "Отменить запрос на смену пароля",
"instructions": "Выполните эту команду на вашем сервере, затем обновите эту страницу",
"success-title": "Восстановление пароля",
"success-title": "Пароль сброшен",
"success": "Ваш пароль был сброшен. Теперь вы можете войти с новым паролем. Ваш адрес электронной почты {email}",
"back-to-login": "Вернуться на страницу входа"
},
@ -144,6 +144,7 @@
"link": "Ссылка",
"website": "Сайт",
"supported-arch": "Поддерживаемые архитектуры",
"choose-open-method": "Выберите способ открытия",
"categories": {
"data": "Данные",
"network": "Сеть",
@ -159,7 +160,7 @@
"music": "Музыка",
"finance": "Финансы",
"gaming": "Игры",
"ai": "AI"
"ai": "ИИ"
},
"actions": {
"start": "Запустить",
@ -225,7 +226,7 @@
"stay-up-to-date": "Будьте в курсе последней версии Tipi",
"new-version": "Доступна новая версия ({version}) Tipi",
"maintenance-title": "Обслуживание",
"maintenance-subtitle": "Общие действия для выполнения на вашем сервере",
"maintenance-subtitle": "Общие действия для выполнения на вашем экземпляре",
"restart": "Перезапустить",
"update": "Обновить до {version}",
"already-latest": "Уже обновлено"
@ -245,12 +246,15 @@
"internal-ip-hint": "IP-адрес, на котором сервер ожидает запросы.",
"apps-repo": "URL репозитория приложений",
"apps-repo-hint": "URL-адрес репозитория приложений.",
"storage-path": "Путь к хранилищу",
"storage-path": "Путь хранилища",
"storage-path-hint": "Путь к каталогу хранения. Оставьте пустым для пути по умолчанию (runtipi/app-data). Убедитесь, что путь является абсолютным и существует",
"local-domain": "Локальный домен",
"local-domain-hint": "Доменное имя, используемое для доступа к приложениям в вашей локальной сети. Ваши приложения будут доступны по адресу app-name.local-domain.",
"submit": "Сохранить",
"user-settings-title": "Настройки пользователя",
"language": "Язык",
"help-translate": "Помочь с переводом Tipi"
"help-translate": "Помочь с переводом Tipi",
"download-certificate": "Скачать сертификат"
},
"security": {
"tab-title": "Безопасность",
@ -288,7 +292,7 @@
"logout": "Выйти",
"dark-mode": "Темный режим",
"light-mode": "Светлый режим",
"sponsor": "Спонсор",
"sponsor": "Спонсировать",
"source-code": "Исходный код",
"update-available": "Доступно обновление"
}

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Länk",
"website": "Webbsida",
"supported-arch": "Arkitekturer som stöds",
"choose-open-method": "Välj öppna metod",
"categories": {
"data": "Data",
"network": "Nätverk",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL till app-arkivet.",
"storage-path": "Lagring sökväg",
"storage-path-hint": "Sökväg till lagringskatalogen. Behåll tomt för standard (runtipi/app-data). Kontrollera att den är en absolut sökväg och att den finns",
"local-domain": "Lokal domän",
"local-domain-hint": "Domännamn som används för att komma åt appar i ditt lokala nätverk. Apparna kommer att vara tillgängliga på app-name.local-domain.",
"submit": "Spara",
"user-settings-title": "Användarinställningar",
"language": "Språk",
"help-translate": "Hjälp till att översätta Tipi"
"help-translate": "Hjälp till att översätta Tipi",
"download-certificate": "Ladda ner certifikat"
},
"security": {
"tab-title": "Säkerhet",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "Link",
"website": "Website",
"supported-arch": "Supported architectures",
"choose-open-method": "Choose open method",
"categories": {
"data": "Data",
"network": "Network",
@ -247,10 +248,13 @@
"apps-repo-hint": "URL to the apps repository.",
"storage-path": "Storage path",
"storage-path-hint": "Path to the storage directory. Keep empty for default (runtipi/app-data). Make sure it is an absolute path and that it exists",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "Save",
"user-settings-title": "User settings",
"language": "Language",
"help-translate": "Help translate Tipi"
"help-translate": "Help translate Tipi",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "Security",

View file

@ -144,6 +144,7 @@
"link": "链接",
"website": "网址",
"supported-arch": "支持的架构",
"choose-open-method": "Choose open method",
"categories": {
"data": "数据",
"network": "网络",
@ -247,10 +248,13 @@
"apps-repo-hint": "应用程序仓库的 URL",
"storage-path": "保存路径",
"storage-path-hint": "存储目录的路径。留空为默认 (Runtipi/app-data)。请确保它是一个绝对路径并存在",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "保存",
"user-settings-title": "用户设置",
"language": "语言设置",
"help-translate": "参与翻译"
"help-translate": "参与翻译",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "安全",

View file

@ -144,6 +144,7 @@
"link": "連結",
"website": "網址",
"supported-arch": "支持的架構",
"choose-open-method": "Choose open method",
"categories": {
"data": "數據",
"network": "網路",
@ -247,10 +248,13 @@
"apps-repo-hint": "應用存儲庫的 URL",
"storage-path": "儲存路徑",
"storage-path-hint": "存儲目錄的路徑。留空时為默認 (runtipi/app-data)。 確保它是絕對路徑並且它存在",
"local-domain": "Local domain",
"local-domain-hint": "Domain name used for accessing apps in your local network. Your apps will be accessible at app-name.local-domain.",
"submit": "儲存",
"user-settings-title": "使用者設定",
"language": "語言設定",
"help-translate": "協助翻譯"
"help-translate": "協助翻譯",
"download-certificate": "Download certificate"
},
"security": {
"tab-title": "安全性",

View file

@ -29,7 +29,7 @@ export const handlers = [
getTRPCMock({
path: ['system', 'getSettings'],
type: 'query',
response: { internalIp: 'localhost', dnsIp: '1.1.1.1', appsRepoUrl: 'https://test.com/test', domain: 'tipi.localhost' },
response: { internalIp: 'localhost', dnsIp: '1.1.1.1', appsRepoUrl: 'https://test.com/test', domain: 'tipi.localhost', localDomain: 'tipi.lan' },
}),
getTRPCMock({
path: ['system', 'updateSettings'],

View file

@ -18,7 +18,7 @@ const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
const t = useTranslations('apps.app-details');
return (
<Link className={clsx('cursor-pointer col-sm-6 col-lg-4 p-2 mt-4', styles.appTile)} href={`/app-store/${app.id}`} passHref>
<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>
<div key={app.id} className="d-flex overflow-hidden align-items-center py-2 ps-2">
<AppLogo className={styles.logo} id={app.id} />
<div className="card-body">

View file

@ -1,24 +1,28 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import React from 'react';
import { AppActions } from './AppActions';
import { cleanup, fireEvent, render, screen } from '../../../../../../tests/test-utils';
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
import { AppInfo } from '../../../../core/types';
afterEach(cleanup);
describe('Test: AppActions', () => {
const app = {
name: 'My App',
form_fields: [],
exposable: [],
id: 'test',
info: {
port: 3000,
id: 'test',
name: 'My App',
form_fields: [],
exposable: [],
},
} as unknown as AppInfo;
it('should call the callbacks when buttons are clicked', () => {
// arrange
const onStart = jest.fn();
const onRemove = jest.fn();
// @ts-expect-error
render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="stopped" app={app} onStart={onStart} onUninstall={onRemove} />);
// act
const startButton = screen.getByRole('button', { name: 'Start' });
@ -33,8 +37,8 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is running', () => {
// arrange
// @ts-expect-error
render(<AppActions status="running" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="running" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
@ -44,8 +48,8 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is starting', () => {
// arrange
// @ts-expect-error
render(<AppActions status="starting" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="starting" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
@ -54,8 +58,8 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is stopping', () => {
// arrange
// @ts-expect-error
render(<AppActions status="stopping" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="stopping" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
@ -64,8 +68,8 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is removing', () => {
// arrange
// @ts-expect-error
render(<AppActions status="uninstalling" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="uninstalling" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
@ -74,8 +78,8 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is installing', () => {
// arrange
// @ts-ignore
render(<AppActions status="installing" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="installing" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
@ -84,8 +88,8 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is updating', () => {
// arrange
// @ts-expect-error
render(<AppActions status="updating" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="updating" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
@ -94,10 +98,96 @@ describe('Test: AppActions', () => {
it('should render the correct buttons when app status is missing', () => {
// arrange
// @ts-expect-error
render(<AppActions status="missing" info={app} />);
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="missing" app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
});
it('should render update button if app is running and has an update available', () => {
// arrange
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="running" updateAvailable app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
});
it('should render update button if app is stopped and has an update available', () => {
// arrange
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions status="stopped" updateAvailable app={app} />);
// assert
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
});
it('should render domain button if app is running and has a domain', async () => {
// arrange
const appWithDomain = {
...app,
exposed: true,
domain: 'myapp.example.com',
};
const openFn = jest.fn();
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions onOpen={openFn} status="running" app={appWithDomain} />);
// act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/myapp.example.com/)).toBeInTheDocument();
});
const domainButton = screen.getByText(/myapp.example.com/);
// assert
userEvent.click(domainButton);
await waitFor(() => {
expect(openFn).toHaveBeenCalledWith('domain');
});
});
it('should render local_domain open button', async () => {
// arrange
const openFn = jest.fn();
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions localDomain="tipi.lan" onOpen={openFn} status="running" app={app} />);
// act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/test.tipi.lan/)).toBeInTheDocument();
});
const localButton = screen.getByText(/test.tipi.lan/);
// assert
userEvent.click(localButton);
await waitFor(() => {
expect(openFn).toHaveBeenCalledWith('local_domain');
});
});
it('should render local open button', async () => {
// arrange
const openFn = jest.fn();
// @ts-expect-error - we don't need to pass all props for this test
render(<AppActions localUrl="http://localhost:3000" onOpen={openFn} status="running" app={app} />);
// act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/localhost:3000/)).toBeInTheDocument();
});
const localButton = screen.getByText(/localhost:3000/);
// assert
userEvent.click(localButton);
await waitFor(() => {
expect(openFn).toHaveBeenCalledWith('local');
});
});
});

View file

@ -1,21 +1,23 @@
import { Icon, IconDownload, IconExternalLink, IconPlayerPause, IconPlayerPlay, IconSettings, IconTrash, IconX } from '@tabler/icons-react';
import { Icon, IconDownload, IconExternalLink, IconLock, IconLockOff, IconPlayerPause, IconPlayerPlay, IconSettings, IconTrash, IconX } from '@tabler/icons-react';
import clsx from 'clsx';
import React from 'react';
import type { AppStatus } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { Button } from '../../../../components/ui/Button';
import { AppInfo } from '../../../../core/types';
import { AppWithInfo } from '../../../../core/types';
interface IProps {
info: AppInfo;
app: AppWithInfo;
status?: AppStatus;
updateAvailable: boolean;
localDomain?: string;
onInstall: () => void;
onUninstall: () => void;
onStart: () => void;
onStop: () => void;
onOpen: () => void;
onOpen: (url: OpenType) => void;
onUpdate: () => void;
onUpdateSettings: () => void;
onCancel: () => void;
@ -23,7 +25,7 @@ interface IProps {
interface BtnProps {
IconComponent?: Icon;
onClick: () => void;
onClick?: () => void;
width?: number | null;
title?: string;
color?: string;
@ -43,7 +45,10 @@ const ActionButton: React.FC<BtnProps> = (props) => {
);
};
export const AppActions: React.FC<IProps> = ({ info, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
type OpenType = 'local' | 'domain' | 'local_domain';
export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
const { info } = app;
const t = useTranslations('apps.app-details');
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
@ -53,12 +58,41 @@ export const AppActions: React.FC<IProps> = ({ info, status, onInstall, onUninst
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 OpenButton = <ActionButton key="open" IconComponent={IconExternalLink} onClick={onOpen} title={t('actions.open')} />;
const LoadingButtion = <ActionButton key="loading" loading onClick={() => null} color="success" title={t('actions.loading')} />;
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 UpdateButton = <ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('actions.update')} color="success" />;
const OpenButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button width={140} className={clsx('me-2 px-4 mt-2')}>
{t('actions.open')}
<IconExternalLink className="ms-1" size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('choose-open-method')}</DropdownMenuLabel>
<DropdownMenuGroup>
{app.exposed && app.domain && (
<DropdownMenuItem onClick={() => onOpen('domain')}>
<IconLock className="text-green me-2" size={16} />
{app.domain}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onOpen('local_domain')}>
<IconLock className="text-muted me-2" size={16} />
{app.id}.{localDomain}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpen('local')}>
<IconLockOff className="text-muted me-2" size={16} />
{window.location.hostname}:{app.info.port}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
switch (status) {
case 'stopped':
buttons.push(StartButton, RemoveButton);

View file

@ -1,6 +1,6 @@
import React from 'react';
import { faker } from '@faker-js/faker';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, userEvent, waitFor } from '../../../../../../tests/test-utils';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
@ -86,10 +86,19 @@ describe('Test: AppDetailsContainer', () => {
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
openButton.click();
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/localhost:/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/localhost:/);
userEvent.click(openButtonItem);
// Assert
expect(spy).toHaveBeenCalledWith(`http://localhost:${app.info.port}`, '_blank', 'noreferrer');
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`http://localhost:${app.info.port}`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
it('should open with https when app info has https set to true', async () => {
@ -100,10 +109,68 @@ describe('Test: AppDetailsContainer', () => {
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
openButton.click();
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/localhost:/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/localhost:/);
userEvent.click(openButtonItem);
// Assert
expect(spy).toHaveBeenCalledWith(`https://localhost:${app.info.port}`, '_blank', 'noreferrer');
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`https://localhost:${app.info.port}`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
it('should open with domain when domain is clicked', async () => {
// Arrange
const app = createAppEntity({ overrides: { domain: 'test.com', exposed: true } });
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/test.com/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/test.com/);
userEvent.click(openButtonItem);
// Assert
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`https://test.com`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
it('should open with local domain when local domain is clicked', async () => {
// Arrange
const app = createAppEntity({});
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/.tipi.lan/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/.tipi.lan/);
userEvent.click(openButtonItem);
// Assert
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`https://${app.id}.tipi.lan`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
});

View file

@ -20,6 +20,7 @@ import { castAppConfig } from '../../helpers/castAppConfig';
interface IProps {
app: AppRouterOutput['getApp'];
}
type OpenType = 'local' | 'domain' | 'local_domain';
export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
const t = useTranslations();
@ -29,6 +30,8 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
const updateDisclosure = useDisclosure();
const updateSettingsDisclosure = useDisclosure();
const getSettings = trpc.system.getSettings.useQuery();
const utils = trpc.useContext();
const invalidate = () => {
@ -135,15 +138,26 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
update.mutate({ id: app.id });
};
const handleOpen = () => {
const handleOpen = (type: OpenType) => {
let url = '';
const { https } = app.info;
const protocol = https ? 'https' : 'http';
if (typeof window !== 'undefined') {
// Current domain
const domain = window.location.hostname;
window.open(`${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`, '_blank', 'noreferrer');
url = `${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`;
}
if (type === 'domain' && app.domain) {
url = `https://${app.domain}${app.info.url_suffix || ''}`;
}
if (type === 'local_domain') {
url = `https://${app.id}.${getSettings.data?.localDomain}`;
}
window.open(url, '_blank', 'noreferrer');
};
const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
@ -170,14 +184,10 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
<span className="badge bg-gray mt-2">{app.info.version}</span>
</div>
{app.domain && (
<a target="_blank" rel="noreferrer" className="mt-1" href={`https://${app.domain}`}>
https://{app.domain}
</a>
)}
<span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
<div className="mb-1">{app.status !== 'missing' && <AppStatus status={app.status} />}</div>
<AppActions
localDomain={getSettings.data?.localDomain}
updateAvailable={updateAvailable}
onUpdate={updateDisclosure.open}
onUpdateSettings={updateSettingsDisclosure.open}
@ -187,7 +197,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
onInstall={installDisclosure.open}
onOpen={handleOpen}
onStart={handleStartSubmit}
info={app.info}
app={app}
status={app.status}
/>
</div>

View file

@ -1,4 +1,5 @@
import { Icon } from '@tabler/icons-react';
import clsx from 'clsx';
import React from 'react';
interface IProps {
@ -7,18 +8,19 @@ interface IProps {
title: string;
subtitle: string;
metric: string;
isLoading?: boolean;
}
const SystemStat: React.FC<IProps> = ({ icon: IconComponent, progress, title, subtitle, metric }) => (
const SystemStat: React.FC<IProps> = ({ icon: IconComponent, progress, title, subtitle, metric, isLoading }) => (
<div className="col-sm-6 col-lg-4">
<div className="card">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start">
<div className="h2 mb-3 font-weight-bold">{title}</div>
<div className={clsx('h2 mb-3 font-weight-bold', { placeholder: isLoading })}>{title}</div>
<IconComponent />
</div>
<div className="h2">{metric}</div>
<div className="mb-3 text-muted">{subtitle}</div>
<div className={clsx('h2', { 'placeholder col-3': isLoading })}>{metric}</div>
<div className={clsx('mb-3 text-muted', { 'placeholder col-11': isLoading })}>{subtitle}</div>
<div className="progress progress-sm">
<div
className="progress-bar bg-primary"

View file

@ -4,9 +4,15 @@ import { useTranslations } from 'next-intl';
import { SystemRouterOutput } from '../../../../server/routers/system/system.router';
import SystemStat from '../components/SystemStat';
type IProps = { data: SystemRouterOutput['systemInfo'] };
type IProps = { data?: SystemRouterOutput['systemInfo']; isLoading: boolean };
export const DashboardContainer: React.FC<IProps> = ({ data }) => {
const defaultData: SystemRouterOutput['systemInfo'] = {
cpu: { load: 0 },
disk: { available: 0, total: 0, used: 0 },
memory: { available: 0, total: 0, used: 0 },
};
export const DashboardContainer: React.FC<IProps> = ({ data = defaultData, isLoading }) => {
const { disk, memory, cpu } = data;
const t = useTranslations('dashboard');
// Convert bytes to GB
@ -21,9 +27,9 @@ export const DashboardContainer: React.FC<IProps> = ({ data }) => {
return (
<div className="row row-deck row-cards">
<SystemStat title={t('cards.disk.title')} metric={`${diskUsed} GB`} subtitle={t('cards.disk.subtitle', { total: diskSize })} icon={IconDatabase} progress={percentUsed} />
<SystemStat title={t('cards.cpu.title')} metric={`${cpu.load.toFixed(2)}%`} subtitle={t('cards.cpu.subtitle')} icon={IconCpu} progress={cpu.load} />
<SystemStat title={t('cards.memory.title')} metric={`${percentUsedMemory || 0}%`} subtitle={`${memoryTotal} GB`} icon={IconCircuitResistor} progress={percentUsedMemory} />
<SystemStat isLoading={isLoading} title={t('cards.disk.title')} metric={`${diskUsed} GB`} subtitle={t('cards.disk.subtitle', { total: diskSize })} icon={IconDatabase} progress={percentUsed} />
<SystemStat isLoading={isLoading} title={t('cards.cpu.title')} metric={`${cpu.load.toFixed(2)}%`} subtitle={t('cards.cpu.subtitle')} icon={IconCpu} progress={cpu.load} />
<SystemStat isLoading={isLoading} title={t('cards.memory.title')} metric={`${percentUsedMemory || 0}%`} subtitle={`${memoryTotal} GB`} icon={IconCircuitResistor} progress={percentUsedMemory} />
</div>
);
};

View file

@ -9,11 +9,11 @@ import { ErrorPage } from '../../../../components/ui/ErrorPage';
export const DashboardPage: NextPage = () => {
const t = useTranslations();
const { data, error } = trpc.system.systemInfo.useQuery();
const { data, error, isLoading } = trpc.system.systemInfo.useQuery();
return (
<Layout title={t('dashboard.title')}>
{data && <DashboardContainer data={data} />}
<DashboardContainer data={data} isLoading={isLoading} />
{error && <ErrorPage error={t(error.data?.tError.message as MessageKey, { ...error.data?.tError.variables })} />}
</Layout>
);

View file

@ -39,6 +39,7 @@ describe('Test: SettingsForm', () => {
internalIp: 'invalid internal ip',
appsRepoUrl: 'invalid url',
storagePath: 'invalid path',
localDomain: 'invalid local domain',
};
render(<SettingsForm onSubmit={jest.fn()} submitErrors={submitErrors} />);
@ -50,29 +51,32 @@ describe('Test: SettingsForm', () => {
expect(screen.getByText(submitErrors.internalIp)).toBeInTheDocument();
expect(screen.getByText(submitErrors.appsRepoUrl)).toBeInTheDocument();
expect(screen.getByText(submitErrors.storagePath)).toBeInTheDocument();
expect(screen.getByText(submitErrors.localDomain)).toBeInTheDocument();
});
it('should correctly validate the form', async () => {
// arrange
render(<SettingsForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: 'Save' });
const dnsIpInput = screen.getByLabelText('DNS IP');
const domainInput = screen.getByLabelText('Domain name');
const internalIpInput = screen.getByLabelText('Internal IP');
const appsRepoUrlInput = screen.getByLabelText('Apps repo URL');
const dnsIpInput = screen.getByRole('textbox', { name: 'dnsIp' });
const domainInput = screen.getByRole('textbox', { name: 'domain' });
const internalIpInput = screen.getByRole('textbox', { name: 'internalIp' });
const appsRepoUrlInput = screen.getByRole('textbox', { name: 'appsRepoUrl' });
const localDomainInput = screen.getByRole('textbox', { name: 'localDomain' });
// act
fireEvent.change(dnsIpInput, { target: { value: 'invalid ip' } });
fireEvent.change(domainInput, { target: { value: 'invalid domain' } });
fireEvent.change(internalIpInput, { target: { value: 'invalid internal ip' } });
fireEvent.change(appsRepoUrlInput, { target: { value: 'invalid url' } });
fireEvent.change(localDomainInput, { target: { value: 'invalid local domain' } });
fireEvent.click(submitButton);
// assert
await waitFor(() => {
expect(screen.getAllByText('Invalid IP address')).toHaveLength(2);
});
expect(screen.getByText('Invalid domain')).toBeInTheDocument();
expect(screen.getAllByText('Invalid domain')).toHaveLength(2);
expect(screen.getByText('Invalid URL')).toBeInTheDocument();
});
@ -90,4 +94,19 @@ describe('Test: SettingsForm', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
it('should download the certificate when the download button is clicked', async () => {
// arrange
const spy = jest.spyOn(window, 'open').mockImplementation();
render(<SettingsForm onSubmit={jest.fn} />);
const downloadButton = screen.getByRole('button', { name: 'Download certificate' });
// act
fireEvent.click(downloadButton);
// assert
await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -2,9 +2,11 @@ import { LanguageSelector } from '@/components/LanguageSelector';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IconAdjustmentsAlt, IconUser } from '@tabler/icons-react';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Tooltip } from 'react-tooltip';
import validator from 'validator';
export type SettingsFormValues = {
@ -13,6 +15,7 @@ export type SettingsFormValues = {
appsRepoUrl?: string;
domain?: string;
storagePath?: string;
localDomain?: string;
};
interface IProps {
@ -29,6 +32,10 @@ export const SettingsForm = (props: IProps) => {
const validateFields = (values: SettingsFormValues) => {
const errors: { [K in keyof SettingsFormValues]?: string } = {};
if (values.localDomain && !validator.isFQDN(values.localDomain)) {
errors.localDomain = t('invalid-domain');
}
if (values.dnsIp && !validator.isIP(values.dnsIp)) {
errors.dnsIp = t('invalid-ip');
}
@ -86,6 +93,11 @@ export const SettingsForm = (props: IProps) => {
}
};
const downloadCertificate = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
window.open('/certificate');
};
return (
<>
<div className="d-flex">
@ -100,23 +112,80 @@ export const SettingsForm = (props: IProps) => {
</div>
<p className="mb-4">{t('subtitle')}</p>
<div className="mb-3">
<Input {...register('domain')} label={t('domain-name')} error={errors.domain?.message} placeholder="tipi.localhost" />
<span className="text-muted">{t('domain-name-hint')}</span>
<Input
{...register('domain')}
label={
<>
{t('domain-name')}
<Tooltip anchorSelect=".domain-name-hint">{t('domain-name-hint')}</Tooltip>
<span className={clsx('ms-1 form-help domain-name-hint')}>?</span>
</>
}
error={errors.domain?.message}
placeholder="example.com"
/>
</div>
<div className="mb-3">
<Input {...register('dnsIp')} label={t('dns-ip')} error={errors.dnsIp?.message} placeholder="9.9.9.9" />
</div>
<div className="mb-3">
<Input {...register('internalIp')} label={t('internal-ip')} error={errors.internalIp?.message} placeholder="192.168.1.100" />
<span className="text-muted">{t('internal-ip-hint')}</span>
<Input
{...register('internalIp')}
label={
<>
{t('internal-ip')}
<Tooltip anchorSelect=".internal-ip-hint">{t('internal-ip-hint')}</Tooltip>
<span className={clsx('ms-1 form-help internal-ip-hint')}>?</span>
</>
}
error={errors.internalIp?.message}
placeholder="192.168.1.100"
/>
</div>
<div className="mb-3">
<Input {...register('appsRepoUrl')} label={t('apps-repo')} error={errors.appsRepoUrl?.message} placeholder="https://github.com/meienberger/runtipi-appstore" />
<span className="text-muted">{t('apps-repo-hint')}</span>
<Input
{...register('appsRepoUrl')}
label={
<>
{t('apps-repo')}
<Tooltip anchorSelect=".apps-repo-hint">{t('apps-repo-hint')}</Tooltip>
<span className={clsx('ms-1 form-help apps-repo-hint')}>?</span>
</>
}
error={errors.appsRepoUrl?.message}
placeholder="https://github.com/meienberger/runtipi-appstore"
/>
</div>
<div className="mb-3">
<Input {...register('storagePath')} label={t('storage-path')} error={errors.storagePath?.message} placeholder={t('storage-path')} />
<span className="text-muted">{t('storage-path-hint')}</span>
<Input
{...register('storagePath')}
label={
<>
{t('storage-path')}
<Tooltip anchorSelect=".storage-path-hint">{t('storage-path-hint')}</Tooltip>
<span className={clsx('ms-1 form-help storage-path-hint')}>?</span>
</>
}
error={errors.storagePath?.message}
placeholder={t('storage-path')}
/>
</div>
<div className="mb-3">
<Input
{...register('localDomain')}
label={
<>
{t('local-domain')}
<Tooltip anchorSelect=".local-domain-hint">{t('local-domain-hint')}</Tooltip>
<span className={clsx('ms-1 form-help local-domain-hint')}>?</span>
</>
}
error={errors.localDomain?.message}
placeholder="tipi.lan"
/>
<Button className="mt-2" onClick={downloadCertificate}>
{t('download-certificate')}
</Button>
</div>
<Button loading={loading} type="submit" className="btn-success">
{t('submit')}

View file

@ -1,5 +1,6 @@
import merge from 'lodash.merge';
import Cookies from 'js-cookie';
import { deleteCookie, setCookie } from 'cookies-next';
import { fromPartial } from '@total-typescript/shoehorn';
import { getAuthedPageProps, getMessagesPageProps } from '../page-helpers';
import englishMessages from '../../messages/en.json';
import frenchMessages from '../../messages/fr-FR.json';
@ -33,7 +34,7 @@ describe('test: getAuthedPageProps()', () => {
describe('test: getMessagesPageProps()', () => {
beforeEach(() => {
Cookies.remove('locale');
deleteCookie('tipi-locale');
});
it('should return correct messages if the locale is in the session', async () => {
@ -51,7 +52,7 @@ describe('test: getMessagesPageProps()', () => {
it('should return correct messages if the locale in the cookie', async () => {
// arrange
const ctx = { req: { session: {}, headers: {} } };
Cookies.set('locale', 'fr-FR');
setCookie('tipi-locale', 'fr-FR', { req: fromPartial(ctx.req) });
// act
// @ts-expect-error - we're passing in a partial context

View file

@ -1,6 +1,7 @@
import { GetServerSideProps } from 'next';
import merge from 'lodash.merge';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import { getCookie } from 'cookies-next';
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
const { userId } = ctx.req.session;
@ -20,11 +21,11 @@ export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
};
export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
const { cookies } = ctx.req;
const { locale: sessionLocale } = ctx.req.session;
const cookieLocale = getCookie('tipi-locale', { req: ctx.req });
const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
const locale = getLocaleFromString(sessionLocale || cookies?.locale || browserLocale || 'en');
const locale = getLocaleFromString(String(sessionLocale || cookieLocale || browserLocale || 'en'));
const englishMessages = (await import(`../messages/en.json`)).default;
const messages = (await import(`../messages/${locale}.json`)).default;

View file

@ -24,6 +24,7 @@ const configSchema = z.object({
appsRepoId: z.string(),
appsRepoUrl: z.string().url().trim(),
domain: z.string().trim(),
localDomain: z.string().trim(),
storagePath: z
.string()
.trim()
@ -47,7 +48,7 @@ const configSchema = z.object({
}),
});
export const settingsSchema = configSchema.partial().pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true });
export const settingsSchema = configSchema.partial().pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true });
type TipiSettingsType = z.infer<typeof settingsSchema>;
@ -80,6 +81,7 @@ export class TipiConfig {
appsRepoId: conf.APPS_REPO_ID,
appsRepoUrl: conf.APPS_REPO_URL,
domain: conf.DOMAIN,
localDomain: conf.LOCAL_DOMAIN,
dnsIp: conf.DNS_IP || '9.9.9.9',
status: 'RUNNING',
storagePath: conf.STORAGE_PATH,

View file

@ -11,6 +11,7 @@ import { runPostgresMigrations } from './run-migration';
import { AppServiceClass } from './services/apps/apps.service';
import { db } from './db';
import { sessionMiddleware } from './middlewares/session.middleware';
import { AuthQueries } from './queries/auth/auth.queries';
let conf = {};
let nextApp: NextServer;
@ -33,12 +34,26 @@ const handle = nextApp.getRequestHandler();
nextApp.prepare().then(async () => {
const app = express();
const authService = new AuthQueries(db);
app.disable('x-powered-by');
app.use(sessionMiddleware);
app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
app.use('/certificate', async (req, res) => {
const userId = req.session?.userId;
const user = await authService.getUserById(userId as number);
if (user?.operator) {
res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
return res.sendFile(`${getConfig().rootFolder}/traefik/tls/cert.pem`);
}
return res.status(403).send('Forbidden');
});
app.all('*', (req, res) => {
const parsedUrl = parse(req.url, true);
@ -50,7 +65,9 @@ nextApp.prepare().then(async () => {
EventDispatcher.clear();
// Run database migrations
await runPostgresMigrations();
if (getConfig().NODE_ENV !== 'development') {
await runPostgresMigrations();
}
setConfig('status', 'RUNNING');
// Clone and update apps repo

View file

@ -183,7 +183,7 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act & assert
expect(() => generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).toThrowError('Variable TEST_FIELD is required');
expect(() => generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).toThrowError('Variable test is required');
});
it('Should throw an error if app does not exist', async () => {

View file

@ -179,7 +179,7 @@ export const generateEnvFile = (app: App) => {
}
const baseEnvFile = readFile('/runtipi/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\n`;
let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\nAPP_ID=${app.id}\n`;
const envMap = getEnvMap(app.id);
if (parsedConfig.data.generate_vapid_keys) {
@ -197,8 +197,8 @@ export const generateEnvFile = (app: App) => {
const formValue = castAppConfig(app.config)[field.env_variable];
const envVar = field.env_variable;
if (formValue) {
envFile += `${envVar}=${formValue}\n`;
if (formValue || typeof formValue === 'boolean') {
envFile += `${envVar}=${String(formValue)}\n`;
} else if (field.type === 'random') {
if (envMap.has(envVar)) {
envFile += `${envVar}=${envMap.get(envVar)}\n`;
@ -209,7 +209,7 @@ export const generateEnvFile = (app: App) => {
envFile += `${envVar}=${randomString}\n`;
}
} else if (field.required) {
throw new Error(`Variable ${field.env_variable} is required`);
throw new Error(`Variable ${field.label || field.env_variable} is required`);
}
});
@ -229,6 +229,80 @@ export const generateEnvFile = (app: App) => {
writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
};
/**
* Given a template and a map of variables, this function replaces all instances of the variables in the template with their values.
*
* @param {string} template - The template to be rendered.
* @param {Map<string, string>} envMap - The map of variables and their values.
*/
const renderTemplate = (template: string, envMap: Map<string, string>) => {
let renderedTemplate = template;
envMap.forEach((value, key) => {
renderedTemplate = renderedTemplate.replace(new RegExp(`{{${key}}}`, 'g'), value);
});
return renderedTemplate;
};
/**
* Given an app, this function copies the app's data directory to the app-data folder.
* If a file with an extension of .template is found, it will be copied as a file without the .template extension and the template variables will be replaced
* by the values in the app's env file.
*
* @param {string} id - The id of the app.
*/
export const copyDataDir = async (id: string) => {
const envMap = getEnvMap(id);
const appDataDirExists = (await fs.promises.lstat(`/runtipi/apps/${id}/data`).catch(() => false)) as fs.Stats;
if (!appDataDirExists || !appDataDirExists.isDirectory()) {
return;
}
const dataDir = await fs.promises.readdir(`/runtipi/apps/${id}/data`);
const processFile = async (file: string) => {
if (file.endsWith('.template')) {
const template = await fs.promises.readFile(`/runtipi/apps/${id}/data/${file}`, 'utf-8');
const renderedTemplate = renderTemplate(template, envMap);
await fs.promises.writeFile(`/app/storage/app-data/${id}/data/${file.replace('.template', '')}`, renderedTemplate);
} else {
await fs.promises.copyFile(`/runtipi/apps/${id}/data/${file}`, `/app/storage/app-data/${id}/data/${file}`);
}
};
const processDir = async (path: string) => {
await fs.promises.mkdir(`/app/storage/app-data/${id}/data/${path}`, { recursive: true });
const files = await fs.promises.readdir(`/runtipi/apps/${id}/data/${path}`);
await Promise.all(
files.map(async (file) => {
const fullPath = `/runtipi/apps/${id}/data/${path}/${file}`;
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
await processDir(`${path}/${file}`);
} else {
await processFile(`${path}/${file}`);
}
}),
);
};
await Promise.all(
dataDir.map(async (file) => {
const fullPath = `/runtipi/apps/${id}/data/${file}`;
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
await processDir(file);
} else {
await processFile(file);
}
}),
);
};
/**
This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app,
parses the config file, filters out any apps that are not available and returns an array of app information.
@ -330,7 +404,7 @@ export const getAppInfo = (id: string, status?: App['status']) => {
* If the app folder does not exist, it copies the app folder from the apps repository.
*
* @param {string} appName - The name of the app.
* @param {boolean} [cleanup=false] - A flag indicating whether to cleanup the app folder before ensuring its existence.
* @param {boolean} [cleanup] - A flag indicating whether to cleanup the app folder before ensuring its existence.
* @throws Will throw an error if the app folder cannot be copied from the repository
*/
export const ensureAppFolder = (appName: string, cleanup = false): void => {

View file

@ -40,7 +40,7 @@ describe('Install app', () => {
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
});
it('Should add app in database', async () => {
@ -239,6 +239,38 @@ describe('Install app', () => {
// act & assert
await expect(AppsService.installApp(appConfig.id, {})).rejects.toThrowError();
});
it('should replace env variables in .templates files in data folder', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST', type: 'text', label: 'test', required: true }] });
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test2.txt`, 'test {{TEST}}');
// act
await AppsService.installApp(appConfig.id, { TEST: 'test' });
// assert
const file = await fs.promises.readFile(`/app/storage/app-data/${appConfig.id}/data/test.txt`);
const file2 = await fs.promises.readFile(`/app/storage/app-data/${appConfig.id}/data/test2.txt`);
expect(file.toString()).toBe('test test');
expect(file2.toString()).toBe('test {{TEST}}');
});
it('should copy and replace env variables in deeply nested .templates files in data folder', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST', type: 'text', label: 'test', required: true }] });
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/apps/${appConfig.id}/data/test`);
await fs.promises.mkdir(`/runtipi/apps/${appConfig.id}/data/test/test`);
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test/test/test.txt.template`, 'test {{TEST}}');
// act
await AppsService.installApp(appConfig.id, { TEST: 'test' });
// assert
const file = await fs.promises.readFile(`/app/storage/app-data/${appConfig.id}/data/test/test/test.txt`);
expect(file.toString()).toBe('test test');
});
});
describe('Uninstall app', () => {
@ -336,7 +368,7 @@ describe('Start app', () => {
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
});
it('Should throw if start script fails', async () => {
@ -395,7 +427,7 @@ describe('Update app config', () => {
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nTEST_FIELD=${word}\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=${word}\nAPP_DOMAIN=localhost:${appConfig.port}`);
});
it('Should throw if required field is missing', async () => {

View file

@ -3,7 +3,7 @@ import { App } from '@/server/db/schema';
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { TranslatedError } from '@/server/utils/errors';
import { Database } from '@/server/db';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo } from './apps.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo, copyDataDir } from './apps.helpers';
import { getConfig } from '../../core/TipiConfig';
import { EventDispatcher } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
@ -125,7 +125,7 @@ export class AppServiceClass {
checkAppRequirements(id);
// Create app folder
createFolder(`/app/storage/app-data/${id}`);
createFolder(`/app/storage/app-data/${id}/data`);
const appInfo = getAppInfo(id);
@ -154,6 +154,7 @@ export class AppServiceClass {
if (newApp) {
// Create env file
generateEnvFile(newApp);
await copyDataDir(id);
}
// Run script
@ -185,7 +186,7 @@ export class AppServiceClass {
*
* @param {string} id - The ID of the app to update.
* @param {object} form - The new configuration of the app.
* @param {boolean} [exposed=false] - If the app should be exposed or not.
* @param {boolean} [exposed] - If the app should be exposed or not.
* @param {string} [domain] - The domain for the app if exposed is true.
*/
public updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {

View file

@ -1,17 +1,19 @@
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import fs from 'fs-extra';
import semver from 'semver';
import { faker } from '@faker-js/faker';
import fetch from 'node-fetch-commonjs';
import { EventDispatcher } from '../../core/EventDispatcher';
import { setConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { SystemServiceClass } from '.';
jest.mock('redis');
jest.mock('node-fetch-commonjs');
const SystemService = new SystemServiceClass();
const server = setupServer();
beforeEach(async () => {
jest.mock('fs-extra');
jest.resetModules();
@ -63,19 +65,28 @@ describe('Test: systemInfo', () => {
});
describe('Test: getVersion', () => {
beforeAll(() => {
server.listen();
});
beforeEach(() => {
server.resetHandlers();
TipiCache.del('latestVersion');
});
afterAll(() => {
server.close();
jest.restoreAllMocks();
});
it('It should return version with body', async () => {
// Arrange
const body = faker.lorem.words(10);
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.string.numeric(1)}.${faker.string.numeric(1)}.${faker.string.numeric()}`, body }) }));
server.use(
rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
return res(ctx.json({ name: `v${faker.string.numeric(1)}.${faker.string.numeric(1)}.${faker.string.numeric()}`, body }));
}),
);
// Act
const version = await SystemService.getVersion();
@ -88,8 +99,11 @@ describe('Test: getVersion', () => {
});
it('Should return undefined for latest if request fails', async () => {
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.reject(new Error('API is down')));
server.use(
rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
return res(ctx.status(500));
}),
);
const version = await SystemService.getVersion();
@ -100,8 +114,11 @@ describe('Test: getVersion', () => {
it('Should return cached version', async () => {
// Arrange
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.string.numeric(1)}.${faker.string.numeric(1)}.${faker.string.numeric()}` }) }));
server.use(
rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
return res(ctx.json({ name: `v${faker.string.numeric(1)}.${faker.string.numeric(1)}.${faker.string.numeric()}` }));
}),
);
// Act
const version = await SystemService.getVersion();
@ -156,8 +173,11 @@ describe('Test: update', () => {
it('Should throw an error if latest version is not set', async () => {
// Arrange
TipiCache.del('latestVersion');
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: null }) }));
server.use(
rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
return res(ctx.json({ name: null }));
}),
);
setConfig('version', '0.0.1');
// Act & Assert

View file

@ -1,6 +1,6 @@
import semver from 'semver';
import { z } from 'zod';
import fetch from 'node-fetch-commonjs';
import axios from 'redaxios';
import { TranslatedError } from '@/server/utils/errors';
import { readJsonFile } from '../../common/fs.helpers';
import { EventDispatcher } from '../../core/EventDispatcher';
@ -48,11 +48,10 @@ export class SystemServiceClass {
let body = await this.cache.get('latestVersionBody');
if (!version) {
const data = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const release = (await data.json()) as { name: string; body: string };
const { data } = await axios.get<{ name: string; body: string }>('https://api.github.com/repos/meienberger/runtipi/releases/latest');
version = release.name.replace('v', '');
body = release.body;
version = data.name.replace('v', '');
body = data.body;
await this.cache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
await this.cache.set('latestVersionBody', body || '', 60 * 60);

View file

@ -1,6 +1,8 @@
const APP_LOCALES = {
'de-DE': 'Deutsch',
'en-US': 'English',
'fr-FR': 'Français',
'hu-HU': 'Magyar',
'ja-JP': '日本語',
'pl-PL': 'Polski',
'sv-SE': 'Svenska',
@ -10,15 +12,18 @@ const APP_LOCALES = {
'zh-TW': '繁體中文',
} as const;
const FALLBACK_LOCALES = [
{ from: 'fr', to: 'fr-FR' },
type BaseLang<T extends string> = T extends `${infer U}-${string}` ? U : T; // 'en-US' -> 'en'
const FALLBACK_LOCALES: { from: BaseLang<keyof typeof APP_LOCALES>; to: keyof typeof APP_LOCALES }[] = [
{ from: 'de', to: 'de-DE' },
{ from: 'en', to: 'en-US' },
{ from: 'fr', to: 'fr-FR' },
{ from: 'ja', to: 'ja-JP' },
{ from: 'pl', to: 'pl-PL' },
{ from: 'sv', to: 'sv-SE' },
{ from: 'ro', to: 'ro-RO' },
{ from: 'ru', to: 'ru-RU' },
{ from: 'zh', to: 'zh-CN' },
{ from: 'pl', to: 'pl-PL' },
{ from: 'sv', to: 'sv-SE' },
];
export type Locale = keyof typeof APP_LOCALES;

View file

@ -21,3 +21,5 @@ POSTGRES_PASSWORD=<postgres_password>
POSTGRES_PORT=<postgres_port>
REDIS_HOST=<redis_host>
DEMO_MODE=<demo_mode>
LOCAL_DOMAIN=<local_domain>
DOCKER_TAG=<docker_tag>

View file

@ -18,6 +18,8 @@ class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
// Mock localStorage
@ -41,6 +43,8 @@ const localStorageMock = (() => {
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
Object.defineProperty(window, 'ResizeObserver', { value: ResizeObserver });
Object.defineProperty(window, 'MutationObserver', { value: ResizeObserver });
Object.defineProperty(window, 'matchMedia', {
value: () => {
return {

View file

@ -1,6 +1,12 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { EventDispatcher } from '../../src/server/core/EventDispatcher';
global.fetch = jest.fn();
// Mock global location
global.location = fromPartial({
hostname: 'localhost',
});
console.error = jest.fn();
// Mock Logger

Some files were not shown because too many files have changed in this diff Show more