commit
496747f5cb
102 changed files with 3375 additions and 1418 deletions
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
17
.github/stale.yml
vendored
Normal 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
|
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
@ -17,4 +17,4 @@ jobs:
|
|||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v3
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v1
|
||||
uses: actions/dependency-review-action@v3
|
||||
|
|
238
.github/workflows/e2e.yml
vendored
Normal file
238
.github/workflows/e2e.yml
vendored
Normal 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
|
4
.github/workflows/release-candidate.yml
vendored
4
.github/workflows/release-candidate.yml
vendored
|
@ -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
|
||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -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
4
.gitignore
vendored
|
@ -54,8 +54,12 @@ node_modules/
|
|||
/repos/
|
||||
/apps/
|
||||
traefik/shared
|
||||
traefik/tls
|
||||
|
||||
# media folder
|
||||
media
|
||||
|
||||
/state/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Tipi — A personal homeserver for everyone
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[](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>
|
||||
|
|
|
@ -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];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
121
docker-compose.e2e.yml
Normal 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
|
|
@ -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:
|
||||
|
|
|
@ -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
29
e2e/0001-register.spec.ts
Normal 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
26
e2e/0002-login.spec.ts
Normal 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
57
e2e/0003-apps.spec.ts
Normal 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 });
|
||||
});
|
47
e2e/0004-user-settings.spec.ts
Normal file
47
e2e/0004-user-settings.spec.ts
Normal 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
25
e2e/fixtures/fixtures.ts
Normal 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
4
e2e/helpers/constants.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export const testUser = {
|
||||
email: 'tester@test.com',
|
||||
password: 'password',
|
||||
};
|
17
e2e/helpers/db.ts
Normal file
17
e2e/helpers/db.ts
Normal 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);
|
||||
};
|
10
e2e/helpers/global-setup.ts
Normal file
10
e2e/helpers/global-setup.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { clearDatabase } from './db';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function globalSetup() {
|
||||
await clearDatabase();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
41
package.json
41
package.json
|
@ -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
76
playwright.config.ts
Normal 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
1248
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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=$?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
107
scripts/start-e2e.sh
Executable 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
|
263
scripts/start.sh
263
scripts/start.sh
|
@ -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 ""
|
||||
|
|
4
src/@types/next.d.ts
vendored
4
src/@types/next.d.ts
vendored
|
@ -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> {
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
57
src/client/components/ui/DropdownMenu/DropdownMenu.tsx
Normal file
57
src/client/components/ui/DropdownMenu/DropdownMenu.tsx
Normal 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 };
|
1
src/client/components/ui/DropdownMenu/index.ts
Normal file
1
src/client/components/ui/DropdownMenu/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { DropdownMenu, DropdownMenuItem, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuContent, DropdownMenuTrigger } from './DropdownMenu';
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Zwei‐Faktor‐Authentifizierung 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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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é",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "セキュリティ",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Доступно обновление"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "安全",
|
||||
|
|
|
@ -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": "安全性",
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue