commit
27f5ccda82
311 changed files with 7573 additions and 7985 deletions
|
@ -315,7 +315,9 @@
|
|||
"avatar_url": "https://avatars.githubusercontent.com/u/106091011?v=4",
|
||||
"profile": "https://github.com/steveiliop56",
|
||||
"contributions": [
|
||||
"translation"
|
||||
"translation",
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -353,6 +355,15 @@
|
|||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "itsrllyhim",
|
||||
"name": "him",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/143047010?v=4",
|
||||
"profile": "https://github.com/itsrllyhim",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
echo '{
|
||||
"appsRepoUrl": "https://github.com/meienberger/runtipi-appstore.git/"
|
||||
"appsRepoUrl": "https://github.com/runtipi/runtipi-appstore.git/"
|
||||
}' > state/settings.json
|
||||
npm i -g pnpm
|
||||
pnpm i
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
APPS_REPO_ID=7a92c8307e0a8074763c80be1fcfa4f87da6641daea9211aea6743b0116aba3b
|
||||
APPS_REPO_URL=https://github.com/meienberger/runtipi-appstore
|
||||
APPS_REPO_URL=https://github.com/runtipi/runtipi-appstore
|
||||
TZ=Etc/UTC
|
||||
INTERNAL_IP=localhost
|
||||
DNS_IP=9.9.9.9
|
||||
ARCHITECTURE=arm64 # arm64 or amd64
|
||||
ARCHITECTURE=arm64
|
||||
TIPI_VERSION=1.5.2
|
||||
JWT_SECRET=secret
|
||||
ROOT_FOLDER_HOST=/path/to/runtipi # absolute path to the root folder of the runtipi installation
|
||||
STORAGE_PATH=/path/to/runtipi # absolute path to the root folder of the runtipi installation
|
||||
ROOT_FOLDER_HOST=/path/to/runtipi
|
||||
STORAGE_PATH=/path/to/runtipi
|
||||
NGINX_PORT=7000
|
||||
NGINX_PORT_SSL=443
|
||||
DOMAIN=tipi.localhost
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'jsx-a11y', 'testing-library', 'jest-dom'],
|
||||
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'next/core-web-vitals',
|
||||
|
@ -10,7 +10,6 @@ module.exports = {
|
|||
'plugin:import/typescript',
|
||||
'prettier',
|
||||
'plugin:react/recommended',
|
||||
'plugin:jsdoc/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
@ -53,8 +52,6 @@ module.exports = {
|
|||
'no-underscore-dangle': 0,
|
||||
'arrow-body-style': 0,
|
||||
'class-methods-use-this': 0,
|
||||
'jsdoc/require-returns': 0,
|
||||
'jsdoc/tag-lines': 0,
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
|
|
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -11,7 +11,7 @@ assignees: meienberger
|
|||
Before opening your issue be sure to have completed all those tasks.
|
||||
- [ ] I have searched for an already existing issue with similar context and errors. My issue has not yet been reported.
|
||||
- [ ] I have included a clear description and steps to reproduce.
|
||||
- [ ] I have included my OS information
|
||||
- [ ] I have included logs from the file `runtipi/logs/error.log` if relevant
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
@ -29,11 +29,10 @@ A clear and concise description of what you expected to happen.
|
|||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
**Server (please complete the following information):**
|
||||
- OS: [e.g. Ubuntu 20.04]
|
||||
- Tipi Version [e.g. 2.0.5] (can be found in settings page)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. Like results of the `start` script or container logs
|
||||
Please include logs here `runtipi/logs/error.log` and add any other context about the problem here. Like results of the `start` script or container logs `docker logs ...`
|
||||
|
||||
|
|
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
|
@ -90,7 +90,7 @@ jobs:
|
|||
with:
|
||||
command: |
|
||||
echo 'Downloading install script from GitHub'
|
||||
curl -s https://raw.githubusercontent.com/meienberger/runtipi/${{ inputs.version }}/scripts/install.sh > install.sh
|
||||
curl -s https://raw.githubusercontent.com/runtipi/runtipi/${{ inputs.version }}/scripts/install.sh > install.sh
|
||||
chmod +x install.sh
|
||||
echo 'Running install script'
|
||||
./install.sh --version ${{ inputs.version }}
|
||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -23,7 +23,7 @@ jobs:
|
|||
echo "tag=v${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
build-images:
|
||||
if: github.repository == 'meienberger/runtipi'
|
||||
if: github.repository == 'runtipi/runtipi'
|
||||
needs: get-tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
@ -14,6 +14,7 @@ WORKDIR /app
|
|||
|
||||
COPY ./pnpm-lock.yaml ./
|
||||
COPY ./pnpm-workspace.yaml ./
|
||||
COPY ./patches ./patches
|
||||
RUN pnpm fetch --no-scripts
|
||||
|
||||
COPY ./package*.json ./
|
||||
|
|
|
@ -8,6 +8,7 @@ RUN npm install pnpm -g
|
|||
WORKDIR /app
|
||||
|
||||
COPY ./pnpm-lock.yaml ./
|
||||
COPY ./patches ./patches
|
||||
RUN pnpm fetch --ignore-scripts
|
||||
|
||||
COPY ./package*.json ./
|
||||
|
|
22
README.md
22
README.md
|
@ -1,16 +1,17 @@
|
|||
# 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)
|
||||
[](https://github.com/meienberger/runtipi/releases)
|
||||

|
||||
[](https://github.com/runtipi/runtipi/blob/master/LICENSE)
|
||||
[](https://github.com/runtipi/runtipi/releases)
|
||||

|
||||
[](https://hub.docker.com/r/meienberger/runtipi/)
|
||||
[](https://hub.docker.com/r/meienberger/runtipi/)
|
||||

|
||||
[](https://codecov.io/gh/meienberger/runtipi)
|
||||

|
||||
[](https://crowdin.com/project/runtipi)
|
||||
|
||||
#### Join the discussion
|
||||
|
@ -18,11 +19,11 @@
|
|||
[](https://discord.gg/Bu9qEPnHsc)
|
||||
[](https://matrix.to/#/#runtipi:matrix.org)
|
||||
|
||||

|
||||

|
||||
|
||||
> ⚠️ Tipi is still at an early stage of development and issues are to be expected. Feel free to open an issue or pull request if you find a bug.
|
||||
|
||||
Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/meienberger/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below.
|
||||
Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/runtipi/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
@ -51,7 +52,7 @@ We are looking for contributions of all kinds. If you know design, development,
|
|||
|
||||
## 📜 License
|
||||
|
||||
[](https://github.com/meienberger/runtipi/blob/master/LICENSE)
|
||||
[](https://github.com/runtipi/runtipi/blob/master/LICENSE)
|
||||
|
||||
Tipi is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
||||
|
||||
|
@ -112,13 +113,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||
<td align="center" valign="top" width="14.28%"><a href="https://micro.nghialele.com"><img src="https://avatars.githubusercontent.com/u/129353223?v=4?s=100" width="100px;" alt="Nghia Lele"/><br /><sub><b>Nghia Lele</b></sub></a><br /><a href="#translation-nghialele" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/amusingimpala75"><img src="https://avatars.githubusercontent.com/u/69653100?v=4?s=100" width="100px;" alt="amusingimpala75"/><br /><sub><b>amusingimpala75</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=amusingimpala75" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://m1n.omg.lol"><img src="https://avatars.githubusercontent.com/u/54779580?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="#translation-M1n-4d316e" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveiliop56"><img src="https://avatars.githubusercontent.com/u/106091011?v=4?s=100" width="100px;" alt="Stavros Iliopoulos"/><br /><sub><b>Stavros Iliopoulos</b></sub></a><br /><a href="#translation-steveiliop56" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/steveiliop56"><img src="https://avatars.githubusercontent.com/u/106091011?v=4?s=100" width="100px;" alt="Stavros"/><br /><sub><b>Stavros</b></sub></a><br /><a href="#translation-steveiliop56" title="Translation">🌍</a> <a href="https://github.com/meienberger/runtipi/commits?author=steveiliop56" title="Code">💻</a> <a href="https://github.com/meienberger/runtipi/commits?author=steveiliop56" title="Tests">⚠️</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/loxiry"><img src="https://avatars.githubusercontent.com/u/86959495?v=4?s=100" width="100px;" alt="loxiry"/><br /><sub><b>loxiry</b></sub></a><br /><a href="#translation-loxiry" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JigSawFr"><img src="https://avatars.githubusercontent.com/u/5781907?v=4?s=100" width="100px;" alt="JigSaw"/><br /><sub><b>JigSaw</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=JigSawFr" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DireMunchkin"><img src="https://avatars.githubusercontent.com/u/1665676?v=4?s=100" width="100px;" alt="DireMunchkin"/><br /><sub><b>DireMunchkin</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=DireMunchkin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/FabioCingottini"><img src="https://avatars.githubusercontent.com/u/32102735?v=4?s=100" width="100px;" alt="Fabio Cingottini"/><br /><sub><b>Fabio Cingottini</b></sub></a><br /><a href="#translation-FabioCingottini" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/itsrllyhim"><img src="https://avatars.githubusercontent.com/u/143047010?v=4?s=100" width="100px;" alt="him"/><br /><sub><b>him</b></sub></a><br /><a href="https://github.com/meienberger/runtipi/commits?author=itsrllyhim" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
@ -40,13 +40,13 @@ services:
|
|||
|
||||
tipi-redis:
|
||||
container_name: tipi-redis
|
||||
image: redis:alpine
|
||||
image: redis:7.2.0
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
|
@ -103,9 +103,8 @@ services:
|
|||
networks:
|
||||
tipi_main_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 10.21.21.0/24
|
||||
name: runtipi_tipi_main_network
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
|
|
110
docker-compose.prod.yml
Normal file
110
docker-compose.prod.yml
Normal file
|
@ -0,0 +1,110 @@
|
|||
version: '3.7'
|
||||
|
||||
services:
|
||||
tipi-reverse-proxy:
|
||||
container_name: tipi-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
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 1m
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
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:7.2.0
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-dashboard:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tipi-dashboard
|
||||
depends_on:
|
||||
tipi-db:
|
||||
condition: service_healthy
|
||||
tipi-redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
networks:
|
||||
- tipi_main_network
|
||||
ports:
|
||||
- 3000:3000
|
||||
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:
|
||||
traefik.enable: true
|
||||
traefik.http.services.dashboard.loadbalancer.server.port: 3000
|
||||
traefik.http.middlewares.redirect-to-https.redirectscheme.scheme: https
|
||||
# Local ip
|
||||
traefik.http.routers.dashboard.rule: PathPrefix("/")
|
||||
traefik.http.routers.dashboard.service: dashboard
|
||||
traefik.http.routers.dashboard.entrypoints: web
|
||||
# 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:
|
||||
driver: bridge
|
||||
name: runtipi_tipi_main_network
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
|
@ -1,8 +1,6 @@
|
|||
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 { clearDatabase } from './helpers/db';
|
||||
import { testUser } from './helpers/constants';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
@ -33,15 +31,3 @@ test('user can change their password', async ({ page }) => {
|
|||
|
||||
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');
|
||||
});
|
||||
|
|
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
|
|
@ -4,6 +4,10 @@ const nextConfig = {
|
|||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@runtipi/shared'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['bullmq'],
|
||||
serverActions: true,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
INTERNAL_IP: process.env.INTERNAL_IP,
|
||||
TIPI_VERSION: process.env.TIPI_VERSION,
|
||||
|
@ -19,6 +23,14 @@ const nextConfig = {
|
|||
NODE_ENV: process.env.NODE_ENV,
|
||||
REDIS_HOST: process.env.REDIS_HOST,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/apps/:id',
|
||||
destination: '/app-store/:id',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
178
package.json
178
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.6",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"knip": "knip",
|
||||
|
@ -13,7 +13,7 @@
|
|||
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
|
||||
"dev": "npm run db:migrate && next dev",
|
||||
"dev:watcher": "pnpm -r --filter cli dev",
|
||||
"db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
|
||||
"db:migrate": "NODE_ENV=development dotenv -e .env.local -- tsx ./src/server/run-migrations-dev.ts",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"build": "next build",
|
||||
|
@ -21,7 +21,7 @@
|
|||
"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:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up --build",
|
||||
"start:e2e": "./scripts/start-e2e.sh latest",
|
||||
"start:prod": "npm run prepare && docker compose --env-file ./.env -f docker-compose.prod.yml up --build",
|
||||
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14",
|
||||
"version": "echo $npm_package_version",
|
||||
"release:rc": "./scripts/deploy/release-rc.sh",
|
||||
|
@ -32,119 +32,106 @@
|
|||
"tsc": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.1.1",
|
||||
"@hookform/resolvers": "^3.3.1",
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-select": "^1.2.2",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@runtipi/postgres-migrations": "^5.3.0",
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"@tabler/core": "1.0.0-beta19",
|
||||
"@tabler/icons-react": "^2.23.0",
|
||||
"@tanstack/react-query": "^4.29.7",
|
||||
"@tanstack/react-query-devtools": "^4.29.7",
|
||||
"@trpc/client": "^10.27.1",
|
||||
"@trpc/next": "^10.27.1",
|
||||
"@trpc/react-query": "^10.27.1",
|
||||
"@trpc/server": "^10.27.1",
|
||||
"argon2": "^0.30.3",
|
||||
"bullmq": "^4.5.0",
|
||||
"clsx": "^1.1.1",
|
||||
"@tabler/icons-react": "^2.38.0",
|
||||
"argon2": "^0.31.1",
|
||||
"bullmq": "^4.12.3",
|
||||
"clsx": "^2.0.0",
|
||||
"connect-redis": "^7.1.0",
|
||||
"cookies-next": "^2.1.2",
|
||||
"drizzle-orm": "^0.27.0",
|
||||
"drizzle-orm": "^0.28.6",
|
||||
"fs-extra": "^11.1.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"next": "13.4.7",
|
||||
"next-intl": "^2.15.1",
|
||||
"pg": "^8.11.1",
|
||||
"next": "13.5.4",
|
||||
"next-client-cookies": "^1.0.5",
|
||||
"next-intl": "^2.20.2",
|
||||
"next-safe-action": "^3.4.0",
|
||||
"pg": "^8.11.3",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.45.1",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-select": "^5.7.3",
|
||||
"react-tooltip": "^5.16.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-select": "^5.7.7",
|
||||
"react-tooltip": "^5.21.5",
|
||||
"redaxios": "^0.5.1",
|
||||
"redis": "^4.6.7",
|
||||
"redis": "^4.6.10",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^3.0.3",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.63.6",
|
||||
"semver": "^7.5.3",
|
||||
"sharp": "0.32.1",
|
||||
"superjson": "^1.12.3",
|
||||
"tslib": "^2.5.3",
|
||||
"uuid": "^9.0.0",
|
||||
"validator": "^13.7.0",
|
||||
"winston": "^3.9.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.69.2",
|
||||
"semver": "^7.5.4",
|
||||
"sharp": "0.32.6",
|
||||
"swr": "^2.2.4",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^9.0.1",
|
||||
"validator": "^13.11.0",
|
||||
"winston": "^3.11.0",
|
||||
"zod": "^3.21.4",
|
||||
"zustand": "^4.3.8"
|
||||
"zustand": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.5",
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@testing-library/dom": "^9.3.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@babel/core": "^7.23.0",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@playwright/test": "^1.38.1",
|
||||
"@testing-library/dom": "^9.3.3",
|
||||
"@testing-library/jest-dom": "^6.1.3",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@total-typescript/shoehorn": "^0.1.1",
|
||||
"@total-typescript/ts-reset": "^0.4.2",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/isomorphic-fetch": "^0.0.36",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/fs-extra": "^11.0.2",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/lodash.merge": "^4.6.7",
|
||||
"@types/node": "20.3.2",
|
||||
"@types/pg": "^8.10.2",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/testing-library__jest-dom": "^5.14.6",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@types/validator": "^13.7.17",
|
||||
"@typescript-eslint/eslint-plugin": "^5.60.1",
|
||||
"@typescript-eslint/parser": "^5.60.1",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^0.32.2",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"eslint": "8.43.0",
|
||||
"@types/node": "20.8.4",
|
||||
"@types/pg": "^8.10.5",
|
||||
"@types/react": "18.2.28",
|
||||
"@types/react-dom": "18.2.13",
|
||||
"@types/semver": "^7.5.3",
|
||||
"@types/uuid": "^9.0.5",
|
||||
"@types/validator": "^13.11.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
"@typescript-eslint/parser": "^6.7.5",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "8.51.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-next": "13.4.7",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.5",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-jest": "^27.2.2",
|
||||
"eslint-plugin-jest-dom": "^5.0.1",
|
||||
"eslint-plugin-jsdoc": "^46.3.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.6.1",
|
||||
"eslint-plugin-react": "^7.31.10",
|
||||
"eslint-config-airbnb-typescript": "^17.1.0",
|
||||
"eslint-config-next": "13.5.4",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jest": "^27.4.2",
|
||||
"eslint-plugin-jest-dom": "^5.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-testing-library": "^5.11.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"knip": "^2.19.4",
|
||||
"memfs": "^4.2.0",
|
||||
"msw": "^1.2.2",
|
||||
"next-router-mock": "^0.9.7",
|
||||
"prettier": "^2.8.8",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"eslint-plugin-testing-library": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"knip": "^2.33.1",
|
||||
"memfs": "^4.6.0",
|
||||
"msw": "^1.3.2",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"prettier": "^3.0.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsx": "^3.12.7",
|
||||
"typescript": "5.1.5",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.32.2",
|
||||
"tsx": "^3.13.0",
|
||||
"typescript": "5.2.2",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^0.34.6",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
},
|
||||
"msw": {
|
||||
|
@ -152,12 +139,17 @@
|
|||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/meienberger/runtipi.git"
|
||||
"url": "git+https://github.com/runtipi/runtipi.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "GNU General Public License v3.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/meienberger/runtipi/issues"
|
||||
"url": "https://github.com/runtipi/runtipi/issues"
|
||||
},
|
||||
"homepage": "https://github.com/meienberger/runtipi#readme"
|
||||
"homepage": "https://github.com/runtipi/runtipi#readme",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"next-safe-action@3.4.0": "patches/next-safe-action@3.4.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,3 +6,8 @@ ROOT_FOLDER_HOST=/runtipi
|
|||
STORAGE_PATH=/runtipi
|
||||
TIPI_VERSION=1
|
||||
REDIS_PASSWORD=redis
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_DBNAME=postgres
|
||||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_PORT=5433
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@runtipi/cli",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.6",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": "dist/index.js",
|
||||
|
@ -28,36 +28,37 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/node": "20.3.2",
|
||||
"@types/web-push": "^3.3.2",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"esbuild": "^0.16.17",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"memfs": "^4.2.0",
|
||||
"nodemon": "^2.0.22",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.3",
|
||||
"@types/node": "20.8.4",
|
||||
"@types/web-push": "^3.6.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"esbuild": "^0.19.4",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"memfs": "^4.6.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"pkg": "^5.8.1",
|
||||
"vite": "^4.4.7",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
"vitest": "^0.32.2"
|
||||
"vite": "^4.4.11",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@runtipi/postgres-migrations": "^5.3.0",
|
||||
"@runtipi/shared": "workspace:^",
|
||||
"axios": "^1.4.0",
|
||||
"axios": "^1.5.1",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "^4.5.0",
|
||||
"bullmq": "^4.12.3",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"cli-spinners": "^2.9.0",
|
||||
"cli-spinners": "^2.9.1",
|
||||
"commander": "^11.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"log-update": "^5.0.1",
|
||||
"pg": "^8.11.1",
|
||||
"semver": "^7.5.3",
|
||||
"systeminformation": "^5.18.7",
|
||||
"web-push": "^3.6.3",
|
||||
"pg": "^8.11.3",
|
||||
"semver": "^7.5.4",
|
||||
"systeminformation": "^5.21.11",
|
||||
"web-push": "^3.6.6",
|
||||
"zod": "^3.21.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -176,7 +176,7 @@ export class AppExecutors {
|
|||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
await compose(appId, 'up --detach --force-recreate --remove-orphans');
|
||||
await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always');
|
||||
|
||||
this.logger.info(`App ${appId} started`);
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable no-restricted-syntax */
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { Queue } from 'bullmq';
|
||||
import { Redis } from 'ioredis';
|
||||
import fs from 'fs';
|
||||
import cliProgress from 'cli-progress';
|
||||
import semver from 'semver';
|
||||
|
@ -66,7 +67,6 @@ export class SystemExecutors {
|
|||
const filesAndFolders = [
|
||||
path.join(rootFolderHost, 'apps'),
|
||||
path.join(rootFolderHost, 'logs'),
|
||||
path.join(rootFolderHost, 'media'),
|
||||
path.join(rootFolderHost, 'repos'),
|
||||
path.join(rootFolderHost, 'state'),
|
||||
path.join(rootFolderHost, 'traefik'),
|
||||
|
@ -258,6 +258,13 @@ export class SystemExecutors {
|
|||
|
||||
spinner.done('Watcher started');
|
||||
|
||||
// Flush redis cache
|
||||
this.logger.info('Flushing redis cache...');
|
||||
const cache = new Redis({ host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD'), lazyConnect: true });
|
||||
await cache.connect();
|
||||
await cache.flushdb();
|
||||
await cache.quit();
|
||||
|
||||
this.logger.info('Starting queue...');
|
||||
const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD') } });
|
||||
this.logger.info('Obliterating queue...');
|
||||
|
@ -296,16 +303,21 @@ export class SystemExecutors {
|
|||
await appExecutor.startAllApps();
|
||||
|
||||
console.log(
|
||||
boxen(`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get('NGINX_PORT')} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io`, {
|
||||
title: 'Tipi successfully started 🎉',
|
||||
titleAlignment: 'center',
|
||||
textAlignment: 'center',
|
||||
padding: 1,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'green',
|
||||
width: 80,
|
||||
margin: { top: 1 },
|
||||
}),
|
||||
boxen(
|
||||
`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get(
|
||||
'NGINX_PORT',
|
||||
)} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io\n\nTipi is entierly written in TypeScript and we are looking for contributors!`,
|
||||
{
|
||||
title: 'Tipi successfully started 🎉',
|
||||
titleAlignment: 'center',
|
||||
textAlignment: 'center',
|
||||
padding: 1,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'green',
|
||||
width: 80,
|
||||
margin: { top: 1 },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true, message: 'Tipi started' };
|
||||
|
@ -355,7 +367,7 @@ export class SystemExecutors {
|
|||
|
||||
if (!targetVersion || targetVersion === 'latest') {
|
||||
spinner.setMessage('Fetching latest version...');
|
||||
const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases/latest');
|
||||
const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/runtipi/runtipi/releases/latest');
|
||||
this.logger.info(`Getting latest version from GitHub: ${data.tag_name}`);
|
||||
targetVersion = data.tag_name;
|
||||
}
|
||||
|
@ -375,7 +387,7 @@ export class SystemExecutors {
|
|||
|
||||
const fileName = `runtipi-cli-${targetVersion}`;
|
||||
const savePath = path.join(rootFolderHost, fileName);
|
||||
const fileUrl = `https://github.com/meienberger/runtipi/releases/download/${targetVersion}/${assetName}`;
|
||||
const fileUrl = `https://github.com/runtipi/runtipi/releases/download/${targetVersion}/${assetName}`;
|
||||
this.logger.info(`Downloading Tipi ${targetVersion} from ${fileUrl}`);
|
||||
|
||||
spinner.done(`Target version: ${targetVersion}`);
|
||||
|
|
|
@ -37,7 +37,8 @@ type EnvKeys =
|
|||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
| (string & {});
|
||||
|
||||
const DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore';
|
||||
const OLD_DEFAULT_REPO_URL = 'https://github.com/meienberger/runtipi-appstore';
|
||||
const DEFAULT_REPO_URL = 'https://github.com/runtipi/runtipi-appstore';
|
||||
|
||||
/**
|
||||
* Reads and returns the generated seed
|
||||
|
@ -145,6 +146,10 @@ export const generateSystemEnvFile = async () => {
|
|||
|
||||
const { data } = settings;
|
||||
|
||||
if (data.appsRepoUrl === OLD_DEFAULT_REPO_URL) {
|
||||
data.appsRepoUrl = DEFAULT_REPO_URL;
|
||||
}
|
||||
|
||||
const jwtSecret = envMap.get('JWT_SECRET') || (await deriveEntropy('jwt_secret'));
|
||||
const repoId = getRepoHash(data.appsRepoUrl || DEFAULT_REPO_URL);
|
||||
const postgresPassword = envMap.get('POSTGRES_PASSWORD') || (await deriveEntropy('postgres_password'));
|
||||
|
|
|
@ -105,7 +105,7 @@ export const startWorker = async () => {
|
|||
|
||||
return { success, stdout: message };
|
||||
},
|
||||
{ connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 } },
|
||||
{ connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 }, removeOnComplete: { count: 200 }, removeOnFail: { count: 500 } },
|
||||
);
|
||||
|
||||
worker.on('ready', () => {
|
||||
|
@ -121,6 +121,6 @@ export const startWorker = async () => {
|
|||
});
|
||||
|
||||
worker.on('error', async (e) => {
|
||||
fileLogger.error(`Worker error: ${e}`);
|
||||
fileLogger.debug(`Worker error: ${e}`);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -5,12 +5,9 @@ import { fileLogger } from '../logger/file-logger';
|
|||
import { execAsync } from '../exec-async/execAsync';
|
||||
|
||||
const composeUp = async (args: string[]) => {
|
||||
fileLogger.info(`Running docker compose with args ${args.join(' ')}`);
|
||||
const { stdout, stderr } = await execAsync(`docker compose ${args.join(' ')}`);
|
||||
|
||||
if (stderr) {
|
||||
fileLogger.error(stderr);
|
||||
}
|
||||
|
||||
return { stdout, stderr };
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ export class TerminalSpinner {
|
|||
|
||||
frame = 0;
|
||||
|
||||
interval: NodeJS.Timer | null = null;
|
||||
interval: NodeJS.Timeout | null = null;
|
||||
|
||||
start() {
|
||||
this.interval = setInterval(() => {
|
||||
|
|
|
@ -34,6 +34,10 @@ export const newLogger = (id: string, logsFolder: string) => {
|
|||
);
|
||||
exceptionHandlers = [new transports.File({ filename: path.join(logsFolder, 'error.log') })];
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
tr.push(new transports.Console({ level: 'debug' }));
|
||||
}
|
||||
|
||||
return createLogger({
|
||||
level: 'debug',
|
||||
format: combine(
|
||||
|
|
0
patches/.gitkeep
Normal file
0
patches/.gitkeep
Normal file
113
patches/next-safe-action@3.4.0.patch
Normal file
113
patches/next-safe-action@3.4.0.patch
Normal file
|
@ -0,0 +1,113 @@
|
|||
diff --git a/dist/hook.mjs b/dist/hook.mjs
|
||||
index 4f2ea0f6818194b906590f2467f788e66d3524d9..fcec224f19be119a922734e8a6fb7d1916921d8a 100644
|
||||
--- a/dist/hook.mjs
|
||||
+++ b/dist/hook.mjs
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
- useTransition
|
||||
+ useTransition,
|
||||
} from "react";
|
||||
|
||||
// src/utils.ts
|
||||
@@ -17,8 +17,12 @@ var isNextNotFoundError = (e) => isError(e) && e.message === "NEXT_NOT_FOUND";
|
||||
|
||||
// src/hook.ts
|
||||
var getActionStatus = (res) => {
|
||||
- const hasSucceded = typeof res.data !== "undefined";
|
||||
- const hasErrored = typeof res.validationError !== "undefined" || typeof res.serverError !== "undefined" || typeof res.fetchError !== "undefined";
|
||||
+ const hasSucceded = typeof res?.data !== "undefined";
|
||||
+ const hasErrored =
|
||||
+ typeof res === "undefined" ||
|
||||
+ typeof res.validationError !== "undefined" ||
|
||||
+ typeof res.serverError !== "undefined" ||
|
||||
+ typeof res.fetchError !== "undefined";
|
||||
const hasExecuted = hasSucceded || hasErrored;
|
||||
return { hasExecuted, hasSucceded, hasErrored };
|
||||
};
|
||||
@@ -49,12 +53,15 @@ var useAction = (clientCaller, cb) => {
|
||||
onExecute(input2);
|
||||
}
|
||||
return startTransition(() => {
|
||||
- return executor.current(input2).then((res2) => setRes(res2)).catch((e) => {
|
||||
- if (isNextRedirectError(e) || isNextNotFoundError(e)) {
|
||||
- throw e;
|
||||
- }
|
||||
- setRes({ fetchError: e });
|
||||
- });
|
||||
+ return executor
|
||||
+ .current(input2)
|
||||
+ .then((res2) => setRes(res2))
|
||||
+ .catch((e) => {
|
||||
+ if (isNextRedirectError(e) || isNextNotFoundError(e)) {
|
||||
+ throw e;
|
||||
+ }
|
||||
+ setRes({ fetchError: e });
|
||||
+ });
|
||||
});
|
||||
}, []);
|
||||
const reset = useCallback(() => {
|
||||
@@ -68,17 +75,20 @@ var useAction = (clientCaller, cb) => {
|
||||
reset,
|
||||
hasExecuted,
|
||||
hasSucceded,
|
||||
- hasErrored
|
||||
+ hasErrored,
|
||||
};
|
||||
};
|
||||
var useOptimisticAction = (clientCaller, initialOptData, cb) => {
|
||||
const [res, setRes] = useState({});
|
||||
const [input, setInput] = useState();
|
||||
- const [optState, syncState] = experimental_useOptimistic({ ...initialOptData, ...res.data, __isExecuting__: false }, (state, newState) => ({
|
||||
- ...state,
|
||||
- ...newState,
|
||||
- __isExecuting__: true
|
||||
- }));
|
||||
+ const [optState, syncState] = experimental_useOptimistic(
|
||||
+ { ...initialOptData, ...res.data, __isExecuting__: false },
|
||||
+ (state, newState) => ({
|
||||
+ ...state,
|
||||
+ ...newState,
|
||||
+ __isExecuting__: true,
|
||||
+ })
|
||||
+ );
|
||||
const executor = useRef(clientCaller);
|
||||
const onExecuteRef = useRef(cb?.onExecute);
|
||||
const { hasExecuted, hasSucceded, hasErrored } = getActionStatus(res);
|
||||
@@ -90,12 +100,15 @@ var useOptimisticAction = (clientCaller, initialOptData, cb) => {
|
||||
if (onExecute) {
|
||||
onExecute(input2);
|
||||
}
|
||||
- return executor.current(input2).then((res2) => setRes(res2)).catch((e) => {
|
||||
- if (isNextRedirectError(e) || isNextNotFoundError(e)) {
|
||||
- throw e;
|
||||
- }
|
||||
- setRes({ fetchError: e });
|
||||
- });
|
||||
+ return executor
|
||||
+ .current(input2)
|
||||
+ .then((res2) => setRes(res2))
|
||||
+ .catch((e) => {
|
||||
+ if (isNextRedirectError(e) || isNextNotFoundError(e)) {
|
||||
+ throw e;
|
||||
+ }
|
||||
+ setRes({ fetchError: e });
|
||||
+ });
|
||||
},
|
||||
[syncState]
|
||||
);
|
||||
@@ -113,11 +126,8 @@ var useOptimisticAction = (clientCaller, initialOptData, cb) => {
|
||||
reset,
|
||||
hasExecuted,
|
||||
hasSucceded,
|
||||
- hasErrored
|
||||
+ hasErrored,
|
||||
};
|
||||
};
|
||||
-export {
|
||||
- useAction,
|
||||
- useOptimisticAction
|
||||
-};
|
||||
+export { useAction, useOptimisticAction };
|
||||
//# sourceMappingURL=hook.mjs.map
|
7787
pnpm-lock.yaml
generated
7787
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Binary file not shown.
Before Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
Before Width: | Height: | Size: 9.4 KiB |
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
Binary file not shown.
Before Width: | Height: | Size: 1.2 KiB |
|
@ -2,7 +2,7 @@
|
|||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (1.2.2).
|
||||
* Mock Service Worker (1.3.2).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 3.9 KiB |
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="682.667" height="682.667" preserveAspectRatio="xMidYMid meet" version="1.0" viewBox="0 0 512 512"><metadata>Created by potrace 1.14, written by Peter Selinger 2001-2017</metadata><g fill="#000" stroke="none"><path d="M2263 5057 c-67 -34 -125 -65 -128 -68 -3 -4 53 -126 125 -271 l132 -263 -122 -275 c-66 -151 -130 -295 -142 -320 -11 -25 -37 -83 -58 -130 -21 -47 -347 -706 -725 -1465 -378 -759 -786 -1578 -906 -1820 l-219 -440 2337 -3 c1285 -1 2338 0 2340 2 2 2 -397 809 -888 1792 -706 1417 -931 1879 -1085 2224 l-194 434 134 267 133 267 -135 66 c-103 51 -137 64 -143 54 -5 -7 -41 -79 -81 -160 -40 -81 -75 -147 -78 -148 -3 0 -27 44 -54 98 -101 203 -111 222 -116 221 -3 0 -60 -29 -127 -62z" transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"/></g></svg>
|
Before Width: | Height: | Size: 815 B |
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
|
@ -166,7 +166,7 @@ function check_dependency_and_install() {
|
|||
|
||||
# If version was not given it will install the latest version
|
||||
if [[ "${VERSION}" == "latest" ]]; then
|
||||
LATEST_VERSION=$(curl -s https://api.github.com/repos/meienberger/runtipi/releases/latest | grep tag_name | cut -d '"' -f4)
|
||||
LATEST_VERSION=$(curl -sL https://api.github.com/repos/runtipi/runtipi/releases/latest | grep tag_name | cut -d '"' -f4)
|
||||
VERSION="${LATEST_VERSION}"
|
||||
fi
|
||||
|
||||
|
@ -175,7 +175,7 @@ if [ "$ARCHITECTURE" == "arm64" ] || [ "$ARCHITECTURE" == "aarch64" ]; then
|
|||
ASSET="runtipi-cli-linux-arm64"
|
||||
fi
|
||||
|
||||
URL="https://github.com/meienberger/runtipi/releases/download/$VERSION/$ASSET"
|
||||
URL="https://github.com/runtipi/runtipi/releases/download/$VERSION/$ASSET"
|
||||
|
||||
if [[ "${UPDATE}" == "false" ]]; then
|
||||
mkdir -p runtipi
|
||||
|
|
33
src/app/(auth)/layout.tsx
Normal file
33
src/app/(auth)/layout.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { getCurrentLocale } from 'src/utils/getCurrentLocale';
|
||||
import { LanguageSelector } from '../components/LanguageSelector';
|
||||
|
||||
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = getCurrentLocale();
|
||||
|
||||
return (
|
||||
<div className="page page-center">
|
||||
<div className="position-absolute top-0 mt-3 end-0 me-1 pb-4">
|
||||
<LanguageSelector locale={locale} />
|
||||
</div>
|
||||
<div className="container container-tight py-4">
|
||||
<div className="text-center mb-4">
|
||||
<Image
|
||||
alt="Tipi logo"
|
||||
src="/tipi.png"
|
||||
height={50}
|
||||
width={50}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="card card-md">
|
||||
<div className="card-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { loginAction } from '@/actions/login/login-action';
|
||||
import { verifyTotpAction } from '@/actions/verify-totp/verify-totp-action';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LoginForm } from '../LoginForm';
|
||||
import { TotpForm } from '../TotpForm';
|
||||
|
||||
export function LoginContainer() {
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const loginMutation = useAction(loginAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else if (data.success && data.totpSessionId) {
|
||||
setTotpSessionId(data.totpSessionId);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotpMutation = useAction(verifyTotpAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (totpSessionId) {
|
||||
return <TotpForm loading={verifyTotpMutation.isExecuting} onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })} />;
|
||||
}
|
||||
|
||||
return <LoginForm loading={loginMutation.isExecuting} onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })} />;
|
||||
}
|
|
@ -4,8 +4,8 @@ import z from 'zod';
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Input } from '../../../../components/ui/Input';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
type FormValues = { email: string; password: string };
|
||||
|
23
src/app/(auth)/login/page.tsx
Normal file
23
src/app/(auth)/login/page.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
import { db } from '@/server/db';
|
||||
import { LoginContainer } from './components/LoginContainer';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const authQueries = new AuthQueries(db);
|
||||
const isConfigured = await authQueries.getFirstOperator();
|
||||
|
||||
if (!isConfigured) {
|
||||
redirect('/register');
|
||||
}
|
||||
|
||||
const user = await getUserFromCookie();
|
||||
|
||||
if (user) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
return <LoginContainer />;
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { registerAction } from '@/actions/register/register-action';
|
||||
import { RegisterForm } from '../RegisterForm';
|
||||
|
||||
export const RegisterContainer: React.FC = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const registerMutation = useAction(registerAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return <RegisterForm onSubmit={({ email, password }) => registerMutation.execute({ username: email, password })} loading={registerMutation.isExecuting} />;
|
||||
};
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Input } from '../../../../components/ui/Input';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface IProps {
|
||||
onSubmit: (values: FormValues) => void;
|
22
src/app/(auth)/register/page.tsx
Normal file
22
src/app/(auth)/register/page.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
import { db } from '@/server/db';
|
||||
import { RegisterContainer } from './components/RegisterContainer';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const user = await getUserFromCookie();
|
||||
if (user) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
const authQueries = new AuthQueries(db);
|
||||
const isConfigured = await authQueries.getFirstOperator();
|
||||
|
||||
if (isConfigured) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return <RegisterContainer />;
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { resetPasswordAction } from '@/actions/reset-password/reset-password-action';
|
||||
import { cancelResetPasswordAction } from '@/actions/cancel-reset-password/cancel-reset-password-action';
|
||||
import { ResetPasswordForm } from '../ResetPasswordForm';
|
||||
|
||||
export const ResetPasswordContainer: React.FC = () => {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const resetPasswordMutation = useAction(resetPasswordAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cancelRequestMutation = useAction(cancelResetPasswordAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (resetPasswordMutation.res.data?.success && resetPasswordMutation.res.data?.email) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
|
||||
<p>{t('auth.reset-password.success', { email: resetPasswordMutation.res.data.email })}</p>
|
||||
<Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
|
||||
{t('auth.reset-password.back-to-login')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetPasswordForm
|
||||
loading={resetPasswordMutation.isExecuting}
|
||||
onCancel={() => cancelRequestMutation.execute()}
|
||||
onSubmit={({ password }) => resetPasswordMutation.execute({ newPassword: password })}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Input } from '../../../../components/ui/Input';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface IProps {
|
||||
onSubmit: (values: FormValues) => void;
|
23
src/app/(auth)/reset-password/page.tsx
Normal file
23
src/app/(auth)/reset-password/page.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { AuthServiceClass } from '@/server/services/auth/auth.service';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
import { ResetPasswordContainer } from './components/ResetPasswordContainer';
|
||||
|
||||
export default async function ResetPasswordPage() {
|
||||
const isRequested = AuthServiceClass.checkPasswordChangeRequest();
|
||||
const translator = await getTranslatorFromCookie();
|
||||
|
||||
if (isRequested) {
|
||||
return <ResetPasswordContainer />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-3">{translator('auth.reset-password.title')}</h2>
|
||||
<p>{translator('auth.reset-password.instructions')}</p>
|
||||
<pre>
|
||||
<code>./runtipi-cli reset-password</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { AppActions } from './AppActions';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../../tests/test-utils';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
@ -136,14 +136,14 @@ describe('Test: AppActions', () => {
|
|||
|
||||
// act
|
||||
const openButton = screen.getByRole('button', { name: 'Open' });
|
||||
userEvent.type(openButton, '{arrowdown}');
|
||||
await 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 userEvent.click(domainButton);
|
||||
await waitFor(() => {
|
||||
expect(openFn).toHaveBeenCalledWith('domain');
|
||||
});
|
||||
|
@ -157,14 +157,14 @@ describe('Test: AppActions', () => {
|
|||
|
||||
// act
|
||||
const openButton = screen.getByRole('button', { name: 'Open' });
|
||||
userEvent.type(openButton, '{arrowdown}');
|
||||
await 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 userEvent.click(localButton);
|
||||
await waitFor(() => {
|
||||
expect(openFn).toHaveBeenCalledWith('local_domain');
|
||||
});
|
||||
|
@ -178,14 +178,14 @@ describe('Test: AppActions', () => {
|
|||
|
||||
// act
|
||||
const openButton = screen.getByRole('button', { name: 'Open' });
|
||||
userEvent.type(openButton, '{arrowdown}');
|
||||
await userEvent.type(openButton, '{arrowdown}');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/localhost:3000/)).toBeInTheDocument();
|
||||
});
|
||||
const localButton = screen.getByText(/localhost:3000/);
|
||||
|
||||
// assert
|
||||
userEvent.click(localButton);
|
||||
await userEvent.click(localButton);
|
||||
await waitFor(() => {
|
||||
expect(openFn).toHaveBeenCalledWith('local');
|
||||
});
|
|
@ -5,11 +5,11 @@ 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 { AppWithInfo } from '../../../../core/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { AppService } from '@/server/services/apps/apps.service';
|
||||
|
||||
interface IProps {
|
||||
app: AppWithInfo;
|
||||
app: Awaited<ReturnType<AppService['getApp']>>;
|
||||
status?: AppStatus;
|
||||
updateAvailable: boolean;
|
||||
localDomain?: string;
|
||||
|
@ -52,6 +52,8 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
|
|||
const t = useTranslations('apps.app-details');
|
||||
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
|
||||
|
||||
const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
|
||||
|
@ -87,7 +89,7 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
|
|||
{!app.info.force_expose && (
|
||||
<DropdownMenuItem onClick={() => onOpen('local')}>
|
||||
<IconLockOff className="text-muted me-2" size={16} />
|
||||
{window.location.hostname}:{app.info.port}
|
||||
{hostname}:{app.info.port}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
|
@ -0,0 +1,219 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useDisclosure } from '@/client/hooks/useDisclosure';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { installAppAction } from '@/actions/app-actions/install-app-action';
|
||||
import { uninstallAppAction } from '@/actions/app-actions/uninstall-app-action';
|
||||
import { stopAppAction } from '@/actions/app-actions/stop-app-action';
|
||||
import { startAppAction } from '@/actions/app-actions/start-app-action';
|
||||
import { updateAppAction } from '@/actions/app-actions/update-app-action';
|
||||
import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';
|
||||
import { AppLogo } from '@/components/AppLogo';
|
||||
import { AppStatus } from '@/components/AppStatus';
|
||||
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
|
||||
import { castAppConfig } from '@/lib/helpers/castAppConfig';
|
||||
import { AppService } from '@/server/services/apps/apps.service';
|
||||
import { InstallModal } from '../InstallModal';
|
||||
import { StopModal } from '../StopModal';
|
||||
import { UninstallModal } from '../UninstallModal';
|
||||
import { UpdateModal } from '../UpdateModal';
|
||||
import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal';
|
||||
import { AppActions } from '../AppActions';
|
||||
import { AppDetailsTabs } from '../AppDetailsTabs';
|
||||
import { FormValues } from '../InstallForm';
|
||||
|
||||
interface IProps {
|
||||
app: Awaited<ReturnType<AppService['getApp']>>;
|
||||
localDomain?: string;
|
||||
}
|
||||
type OpenType = 'local' | 'domain' | 'local_domain';
|
||||
|
||||
export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
||||
const [customStatus, setCustomStatus] = React.useState<AppStatusEnum>(app.status);
|
||||
|
||||
const t = useTranslations();
|
||||
const installDisclosure = useDisclosure();
|
||||
const uninstallDisclosure = useDisclosure();
|
||||
const stopDisclosure = useDisclosure();
|
||||
const updateDisclosure = useDisclosure();
|
||||
const updateSettingsDisclosure = useDisclosure();
|
||||
|
||||
const installMutation = useAction(installAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('running');
|
||||
toast.success(t('apps.app-details.install-success'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useAction(uninstallAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('missing');
|
||||
toast.success(t('apps.app-details.uninstall-success'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useAction(stopAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('stopped');
|
||||
toast.success(t('apps.app-details.stop-success'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const startMutation = useAction(startAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('running');
|
||||
toast.success(t('apps.app-details.start-success'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useAction(updateAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('stopped');
|
||||
toast.success(t('apps.app-details.update-success'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useAction(updateAppConfigAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
toast.success(t('apps.app-details.update-config-success'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
|
||||
|
||||
const handleInstallSubmit = async (values: FormValues) => {
|
||||
setCustomStatus('installing');
|
||||
installDisclosure.close();
|
||||
const { exposed, domain } = values;
|
||||
installMutation.execute({ id: app.id, form: values, exposed, domain });
|
||||
};
|
||||
|
||||
const handleUnistallSubmit = () => {
|
||||
setCustomStatus('uninstalling');
|
||||
uninstallDisclosure.close();
|
||||
uninstallMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleStopSubmit = () => {
|
||||
setCustomStatus('stopping');
|
||||
stopDisclosure.close();
|
||||
stopMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleStartSubmit = async () => {
|
||||
setCustomStatus('starting');
|
||||
startMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleUpdateSettingsSubmit = async (values: FormValues) => {
|
||||
updateSettingsDisclosure.close();
|
||||
const { exposed, domain } = values;
|
||||
updateConfigMutation.execute({ id: app.id, form: values, exposed, domain });
|
||||
};
|
||||
|
||||
const handleUpdateSubmit = async () => {
|
||||
setCustomStatus('updating');
|
||||
updateDisclosure.close();
|
||||
updateMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
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;
|
||||
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}.${localDomain}`;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
};
|
||||
|
||||
const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
|
||||
|
||||
return (
|
||||
<div className="card" data-testid="app-details">
|
||||
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
|
||||
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
|
||||
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
|
||||
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
|
||||
<UpdateSettingsModal
|
||||
onSubmit={handleUpdateSettingsSubmit}
|
||||
isOpen={updateSettingsDisclosure.isOpen}
|
||||
onClose={updateSettingsDisclosure.close}
|
||||
info={app.info}
|
||||
config={castAppConfig(app?.config)}
|
||||
exposed={app?.exposed}
|
||||
domain={app?.domain || ''}
|
||||
/>
|
||||
<div className="card-header d-flex flex-column flex-md-row">
|
||||
<AppLogo id={app.id} size={130} alt={app.info.name} />
|
||||
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
|
||||
<div>
|
||||
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
|
||||
<span className="badge bg-gray mt-2">{app.info.version}</span>
|
||||
</div>
|
||||
<span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
|
||||
<div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
|
||||
<AppActions
|
||||
localDomain={localDomain}
|
||||
updateAvailable={updateAvailable}
|
||||
onUpdate={updateDisclosure.open}
|
||||
onUpdateSettings={updateSettingsDisclosure.open}
|
||||
onStop={stopDisclosure.open}
|
||||
onCancel={stopDisclosure.open}
|
||||
onUninstall={uninstallDisclosure.open}
|
||||
onInstall={installDisclosure.open}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStartSubmit}
|
||||
app={app}
|
||||
status={customStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AppDetailsTabs info={app.info} />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
|
||||
import Markdown from '../../../components/Markdown/Markdown';
|
||||
import Markdown from '@/components/Markdown/Markdown';
|
||||
import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
|
@ -0,0 +1 @@
|
|||
export { AppDetailsTabs } from './AppDetailsTabs';
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { FormField } from '@runtipi/shared';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
|
||||
import { InstallForm } from './InstallForm';
|
||||
|
||||
describe('Test: InstallForm', () => {
|
|
@ -6,9 +6,9 @@ import { Tooltip } from 'react-tooltip';
|
|||
import clsx from 'clsx';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type FormField, type AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Switch } from '../../../../components/ui/Switch';
|
||||
import { Input } from '../../../../components/ui/Input';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { validateAppConfig } from '../../utils/validators';
|
||||
|
||||
interface IProps {
|
|
@ -0,0 +1 @@
|
|||
export { InstallForm, type FormValues } from './InstallForm';
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { InstallModal } from './InstallModal';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
|
||||
|
||||
describe('InstallModal', () => {
|
||||
const app = {
|
|
@ -2,8 +2,7 @@ import React from 'react';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { InstallForm } from '../InstallForm';
|
||||
import { FormValues } from '../InstallForm/InstallForm';
|
||||
import { InstallForm, FormValues } from '../InstallForm';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
|
@ -0,0 +1 @@
|
|||
export { StopModal } from './StopModal';
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
|
@ -0,0 +1 @@
|
|||
export { UninstallModal } from './UninstallModal';
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
|
||||
import { fireEvent, render, screen } from '../../../../../../../tests/test-utils';
|
||||
import { UpdateModal } from './UpdateModal';
|
||||
|
||||
describe('UpdateModal', () => {
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface IProps {
|
||||
newVersion: string;
|
|
@ -2,8 +2,7 @@ import React from 'react';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { InstallForm } from './InstallForm';
|
||||
import { FormValues } from './InstallForm/InstallForm';
|
||||
import { InstallForm, type FormValues } from '../InstallForm';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
23
src/app/(dashboard)/app-store/[id]/page.tsx
Normal file
23
src/app/(dashboard)/app-store/[id]/page.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { AppServiceClass } from '@/server/services/apps/apps.service';
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { db } from '@/server/db';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
import { getSettings } from '@/server/core/TipiConfig';
|
||||
import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translator = await getTranslatorFromCookie();
|
||||
|
||||
return {
|
||||
title: `${translator('apps.app-store.title')} - Tipi`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AppDetailsPage({ params }: { params: { id: string } }) {
|
||||
const appsService = new AppServiceClass(db);
|
||||
const app = await appsService.getApp(params.id);
|
||||
const settings = getSettings();
|
||||
|
||||
return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AppStoreTile } from '../AppStoreTile';
|
||||
import { AppTableData } from '../../helpers/table.types';
|
||||
import { useAppStoreState } from '../../state/appStoreState';
|
||||
import { sortTable } from '../../helpers/table.helpers';
|
||||
|
||||
interface IProps {
|
||||
data: AppTableData;
|
||||
}
|
||||
|
||||
export const AppStoreTable: React.FC<IProps> = ({ data }) => {
|
||||
const { category, search, sort, sortDirection } = useAppStoreState();
|
||||
|
||||
const tableData = React.useMemo(() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }), [data, sort, sortDirection, category, search]);
|
||||
|
||||
return (
|
||||
<div className="row row-cards">
|
||||
{tableData.map((app) => (
|
||||
<AppStoreTile key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { AppStoreTable } from './AppStoreTable';
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import styles from './AppStoreTableActions.module.scss';
|
||||
import { useAppStoreState } from '../../state/appStoreState';
|
||||
import { CategorySelector } from '../CategorySelector';
|
||||
|
||||
export const AppStoreTableActions = () => {
|
||||
const { setCategory, category, search, setSearch } = useAppStoreState();
|
||||
const t = useTranslations('apps.app-store');
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('search-placeholder')} className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
|
||||
<CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,11 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
|
||||
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
|
||||
import { AppLogo } from '@/components/AppLogo';
|
||||
import styles from './AppStoreTile.module.scss';
|
||||
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
|
||||
|
||||
type App = {
|
||||
id: string;
|
||||
|
@ -14,7 +16,7 @@ type App = {
|
|||
short_desc: string;
|
||||
};
|
||||
|
||||
const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
|
||||
export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
|
||||
const t = useTranslations('apps.app-details');
|
||||
|
||||
return (
|
||||
|
@ -34,5 +36,3 @@ const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
|
|||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppStoreTile;
|
|
@ -0,0 +1 @@
|
|||
export { AppStoreTile } from './AppStoreTile';
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import CategorySelector from './CategorySelector';
|
||||
import { CategorySelector } from './CategorySelector';
|
||||
import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
|
||||
|
||||
describe('Test: CategorySelector', () => {
|
|
@ -1,10 +1,10 @@
|
|||
import { Icon } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import Select, { SingleValue, OptionProps, ControlProps, components } from 'react-select';
|
||||
import { Icon } from '@tabler/icons-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
import { APP_CATEGORIES } from '../../../../core/constants';
|
||||
import { useUIStore } from '../../../../state/uiStore';
|
||||
import { useUIStore } from '@/client/state/uiStore';
|
||||
import { iconForCategory } from '../../helpers/table.helpers';
|
||||
|
||||
const { Option, Control } = components;
|
||||
|
||||
|
@ -47,10 +47,10 @@ const ControlComponent = (props: ControlProps<OptionsType>) => {
|
|||
return <Control {...rest}> {children}</Control>;
|
||||
};
|
||||
|
||||
const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
|
||||
export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
|
||||
const t = useTranslations('apps');
|
||||
const { darkMode } = useUIStore();
|
||||
const options: OptionsType[] = APP_CATEGORIES.map((category) => ({
|
||||
const options: OptionsType[] = iconForCategory.map((category) => ({
|
||||
value: category.id,
|
||||
label: t(`app-details.categories.${category.id}`),
|
||||
icon: category.icon,
|
||||
|
@ -112,5 +112,3 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
|
|||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategorySelector;
|
|
@ -0,0 +1 @@
|
|||
export { CategorySelector } from './CategorySelector';
|
|
@ -1,4 +1,22 @@
|
|||
import { AppCategory, AppInfo } from '@runtipi/shared';
|
||||
import {
|
||||
Icon,
|
||||
IconBook,
|
||||
IconBrain,
|
||||
IconBroadcast,
|
||||
IconCamera,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconDeviceGamepad2,
|
||||
IconMovie,
|
||||
IconMusic,
|
||||
IconPigMoney,
|
||||
IconRobot,
|
||||
IconShieldLock,
|
||||
IconStar,
|
||||
IconTool,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { AppTableData } from './table.types';
|
||||
|
||||
type SortParams = {
|
||||
|
@ -47,3 +65,26 @@ export const colorSchemeForCategory: Record<AppCategory, string> = {
|
|||
gaming: 'pink',
|
||||
ai: 'gray',
|
||||
};
|
||||
|
||||
type AppCategoryEntry = {
|
||||
id: AppCategory;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
export const iconForCategory: AppCategoryEntry[] = [
|
||||
{ id: 'network', icon: IconBroadcast },
|
||||
{ id: 'media', icon: IconMovie },
|
||||
{ id: 'development', icon: IconCode },
|
||||
{ id: 'automation', icon: IconRobot },
|
||||
{ id: 'social', icon: IconUsers },
|
||||
{ id: 'utilities', icon: IconTool },
|
||||
{ id: 'photography', icon: IconCamera },
|
||||
{ id: 'security', icon: IconShieldLock },
|
||||
{ id: 'featured', icon: IconStar },
|
||||
{ id: 'books', icon: IconBook },
|
||||
{ id: 'data', icon: IconDatabase },
|
||||
{ id: 'music', icon: IconMusic },
|
||||
{ id: 'finance', icon: IconPigMoney },
|
||||
{ id: 'gaming', icon: IconDeviceGamepad2 },
|
||||
{ id: 'ai', icon: IconBrain },
|
||||
];
|
23
src/app/(dashboard)/app-store/page.tsx
Normal file
23
src/app/(dashboard)/app-store/page.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { AppServiceClass } from '@/server/services/apps/apps.service';
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
import { AppStoreTable } from './components/AppStoreTable';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translator = await getTranslatorFromCookie();
|
||||
|
||||
return {
|
||||
title: `${translator('apps.app-store.title')} - Tipi`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AppStorePage() {
|
||||
const { apps } = await AppServiceClass.listApps();
|
||||
|
||||
return (
|
||||
<div className="card px-3 pb-3">
|
||||
<AppStoreTable data={apps} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
|
@ -5,10 +7,10 @@ import { Tooltip } from 'react-tooltip';
|
|||
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { AppInfo } from '@runtipi/shared';
|
||||
import { AppStatus } from '../AppStatus';
|
||||
import { AppLogo } from '../AppLogo/AppLogo';
|
||||
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
|
||||
import { AppLogo } from '@/components/AppLogo';
|
||||
import { AppStatus } from '@/components/AppStatus';
|
||||
import styles from './AppTile.module.scss';
|
||||
import { limitText } from '../../app-store/helpers/table.helpers';
|
||||
|
||||
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
|
||||
|
37
src/app/(dashboard)/apps/page.tsx
Normal file
37
src/app/(dashboard)/apps/page.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { AppServiceClass } from '@/server/services/apps/apps.service';
|
||||
import { db } from '@/server/db';
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
import { AppTile } from './components/AppTile';
|
||||
import { EmptyPage } from '../../components/EmptyPage';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translator = await getTranslatorFromCookie();
|
||||
|
||||
return {
|
||||
title: `${translator('apps.my-apps.title')} - Tipi`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const appsService = new AppServiceClass(db);
|
||||
const installedApps = await appsService.installedApps();
|
||||
|
||||
const renderApp = (app: (typeof installedApps)[number]) => {
|
||||
const updateAvailable = Number(app.version) < Number(app.latestVersion);
|
||||
|
||||
if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{installedApps.length === 0 && <EmptyPage title="apps.my-apps.empty-title" subtitle="apps.my-apps.empty-subtitle" redirectPath="/app-store" actionLabel="apps.my-apps.empty-action" />}
|
||||
<div className="row row-cards " data-testid="apps-list">
|
||||
{installedApps?.map(renderApp)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue