Compare commits

..
54 Commits
Author SHA1 Message Date
github-actions[bot] 7b65d5648f chore: release 1.18.4 2026-06-16 19:12:17 +00:00
Shawn MandGitHub eb4a31e5f4 chore: Update README.md (#323)
* chore: Update README.md

added unraid link

* chore: Update README.md

fix markdown
2026-06-16 21:11:25 +02:00
github-actions[bot] 0d2f2957b3 chore: release 1.18.3 2026-06-11 13:53:57 +00:00
Killian LarcherGitHubkillian-larcherCharles GTEgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>charles-gauthereaugithub-actions[bot] <github-actions[bot]@users.noreply.github.com>Michael Kutý
51bf9cf679 fix: externalize e2e testings and profile name trim (#318)
* fix: externalize e2e testings

* fix: externalize e2e tests.

* refactor: improve e2e workflow.

* chore: upgrade workflow action version.

* fix: createEnv by adding emptyStringAsUndefined (#317)

* chore: release 1.18.2

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(layout): guard undefined user.name in avatar fallbacks (#320)

* fix(layout): guard undefined user.name in avatar fallbacks

* fix: AUTH_DEFAULT_USER as zod email

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>

* fix: profile name trim

---------

Co-authored-by: killian-larcher <killian.larcher@soluce-technologies.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Kutý <6du1ro.n@gmail.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-11 15:53:07 +02:00
github-actions[bot] 87f8a50326 chore: release 1.18.2 2026-06-10 15:50:59 +00:00
Charles GTEandGitHub b70a8c8a16 fix: createEnv by adding emptyStringAsUndefined (#317) 2026-06-10 17:50:04 +02:00
github-actions[bot] ebdef2d302 chore: release 1.18.1 2026-06-09 18:07:45 +00:00
Charles GTEandGitHub a6bb50c5e2 Merge pull request #314 from Portabase/dev
Dev
2026-06-09 19:55:38 +02:00
Charles GTEandGitHub 50eb48fcf9 Merge pull request #311 from GaboritJunior/fix/tusd-behind-proxy
fix: Enable --behind-proxy for tusd
2026-06-09 19:44:03 +02:00
Charles GTE 6622565b14 fix: echo log in app-prod-entrypoint.sh and TUSD_BEHIND_PROXY in .env.example 2026-06-09 19:42:24 +02:00
Junior GABORITGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
f001de1e97 Update docker/nginx/nginx.conf
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-08 11:38:04 +02:00
Junior GABORIT 6711fab413 [docker/nginx/nginx.conf] Apply coderabbitai change 2026-06-08 11:22:20 +02:00
Junior GABORIT e2f28a84bc [K8S] Add TUSD_BEHIND_PROXY env var 2026-06-08 10:08:41 +02:00
Junior GABORIT d1941c2f3d [docker/entrypoints] Update tusd lauch command 2026-06-08 10:07:14 +02:00
Junior GABORIT f35e6abb05 [docker/Dockerfile] Add TUSD_BEHIND_PROXY env var 2026-06-08 10:05:48 +02:00
Junior GABORIT 0598f78a34 [docker/nginx/nginx.conf] Update X-Forwarded-Proto value 2026-06-08 10:05:18 +02:00
github-actions[bot] f30b55ba47 chore: release 1.18.0 2026-06-07 15:42:38 +00:00
Charles GTEandGitHub 751f29e340 Merge pull request #309 from Portabase/feat/backup-restore-logs
feat: backup-restore-logs
2026-06-07 17:41:43 +02:00
charles-gauthereau 2e629f8423 fix: refactoring and ordering data on agent and project page 2026-06-07 17:33:47 +02:00
Charles GTE ae431f5b85 fix: pnpm 2026-06-07 11:22:22 +02:00
Charles GTE f5f697ceb0 fix: refactoring 2026-06-07 11:18:45 +02:00
Charles GTE 02a8f0baf9 fix: also add for restoration 2026-06-07 11:15:21 +02:00
Charles GTE 2932149b92 feat: backup and restore logs modal view 2026-06-06 22:52:21 +02:00
Charles GTE e1e4d67114 feat: backup and restore logs modal view 2026-06-06 22:46:36 +02:00
Charles GTE 1e3f112d9c Merge branch 'main' into dev 2026-06-05 21:32:28 +02:00
github-actions[bot] e3600949b0 chore: release 1.17.4 2026-06-04 07:09:53 +00:00
Charles GTEandGitHub 856c80cc20 fix: release.yml (#307) 2026-06-04 09:09:06 +02:00
Charles GTE 8c77796f91 Merge branch 'main' into dev 2026-06-04 08:58:07 +02:00
charles-gauthereau f9986e1b7f Merge branch 'main' into dev 2026-06-02 21:21:06 +02:00
charles-gauthereau 48f8d00d2e Merge branch 'main' into dev 2026-05-29 18:29:03 +02:00
Charles GTE 8db995010f Merge branch 'main' into dev 2026-05-24 17:13:16 +02:00
Charles GTE 41d8b2c375 Merge branch 'main' into dev 2026-05-23 18:42:07 +02:00
Charles GTE 3b8fb93fb2 Merge branch 'main' into dev 2026-05-21 19:28:57 +02:00
killian-larcher 55be9374e1 Revert "docs: add OIDC E2E design spec"
This reverts commit b28bb44f97.
2026-05-20 21:45:46 +02:00
killian-larcher ce1bade773 Merge remote-tracking branch 'origin/dev' into dev 2026-05-17 10:46:53 +02:00
Charles GTE 9fe046617c fix: logger.ts 2026-05-17 10:34:54 +02:00
killian-larcher 01763a6b36 chore: ignore local worktrees 2026-05-17 10:31:15 +02:00
killian-larcher b28bb44f97 docs: add OIDC E2E design spec 2026-05-17 10:23:41 +02:00
Charles GTE 411d0b4565 Merge branch 'main' into dev 2026-05-17 10:04:10 +02:00
Charles GTE 16697c442c Merge branch 'main' into dev 2026-05-15 23:03:57 +02:00
Charles GTE a3b7af3ba5 Merge branch 'main' into dev 2026-04-29 22:41:02 +02:00
Charles GTE 33492b8f3a Merge branch 'main' into dev 2026-04-07 18:40:51 +02:00
Charles GTE 150ab83f73 Merge branch 'main' into dev 2026-03-30 17:51:28 +02:00
Charles GTE 35db7a4416 fix: trusted url error due to better auth update. 2026-03-29 14:08:20 +02:00
Charles GTE 1ce93af144 fix: refactoring logs and adding pino logger for production and dev. 2026-03-29 11:41:39 +02:00
Charles GTE f410a8fdd5 Merge branch 'main' into dev 2026-03-29 11:23:38 +02:00
Charles GTE 88914169bc Merge branch 'main' into dev 2026-03-29 09:25:00 +02:00
Charles GTE 8d51bcb74d Merge branch 'main' into dev 2026-03-28 19:01:31 +01:00
Charles GTE 5a87529934 fix: refactoring 2026-03-28 16:55:31 +01:00
Charles GTE 0d7a41996e chore: package.json 2026-03-21 08:39:29 +01:00
killianlarcher dc97de03d0 Merge branch 'main' into dev 2026-03-18 19:34:44 +01:00
charlesgauthereau 5d4a1248e4 fix: S3ChannelConfigSchema type port mismatch 2026-03-16 08:54:13 +01:00
charlesgauthereau f876d8eb09 Merge branch 'main' into dev 2026-03-16 07:59:39 +01:00
charlesgauthereau 1d1d92a5cf fix: release.yml 2026-03-08 21:57:13 +01:00
49 changed files with 14245 additions and 1500 deletions
+3
View File
@@ -78,3 +78,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
#OPENAPI_ENABLED=true #OPENAPI_ENABLED=true
#API_ENABLED=true #API_ENABLED=true
#MCP_ENABLED=true #MCP_ENABLED=true
# Default to false
#TUSD_BEHIND_PROXY=true
+66 -97
View File
@@ -4,114 +4,83 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
packages: write
jobs: jobs:
build-and-test: build-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository if: github.event.pull_request.head.repo.full_name == github.repository
outputs:
image: ${{ steps.image.outputs.ref }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup pnpm - name: Compute image reference
uses: pnpm/action-setup@v4 id: image
run: echo "ref=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portabase:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Set up Docker Buildx
uses: actions/setup-node@v4 uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with: with:
node-version: 22 registry: ghcr.io
cache: 'pnpm' username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies - name: Build and push Docker image
run: pnpm install --frozen-lockfile
- name: Build Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/dockerfile/Dockerfile file: ./docker/dockerfile/Dockerfile
push: false push: true
load: true tags: ${{ steps.image.outputs.ref }}
tags: portabase/portabase:test target: prod
- name: Run app container run-e2e:
run: | if: github.event.pull_request.head.repo.full_name == github.repository
docker run -d --name myapp \ needs: build-image
-p 8887:80 \ uses: portabase/e2e-tests/.github/workflows/run-e2e.yml@main
-e NODE_ENV=production \ with:
-e PROJECT_URL=http://localhost:8887 \ server_image: ${{ needs.build-image.outputs.image }}
-e PROJECT_SECRET=80af5e4c875dfded3a6ba511c6ed8b0160cad723f848ee0851403ed6141f6ba1 \ secrets:
portabase/portabase:test E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
- name: Wait for app port to be listening E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
run: | E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
for i in {1..40}; do E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
if curl -fsS http://localhost:8887/ >/dev/null; then E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
echo "App is responding on port 8887" E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
exit 0 E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
fi E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
echo "Waiting for app readiness... ($i/40)" E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
sleep 2 E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
done E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
echo "Timeout: app never became ready" E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
docker logs myapp E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
exit 1 E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
- name: Install Playwright browsers E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
run: pnpm exec playwright install --with-deps chromium E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
- name: Run Playwright tests E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
run: pnpm exec playwright test E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
env: E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
PROJECT_URL: http://localhost:8887 E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
# E2E Notification E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }} E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }} E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }} E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }} E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }} E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }} E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }} E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }} E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }} E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }} E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }} E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
# E2E Storage
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
- name: Upload report if failed
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Cleanup
if: always()
run: docker stop myapp && docker rm myapp || true
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
create-release: create-release:
needs: check-skip needs: check-skip
if: ${{ needs.check-skip.outputs.skip == 'false' }} if: ${{ needs.check-skip.outputs.skip == 'false' && github.event.pull_request.merged == true }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
draft_tag: ${{ steps.release_step.outputs.draft_tag }} draft_tag: ${{ steps.release_step.outputs.draft_tag }}
+1
View File
@@ -59,3 +59,4 @@ certificates
.claude .claude
.codex .codex
/docs /docs
.worktrees
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.17.3 version: 1.18.4
date-released: '2026-03-02' date-released: '2026-03-02'
+3 -2
View File
@@ -54,12 +54,13 @@
## Installation ## Installation
You have 4 ways to install Portabase: You have 5 ways to install Portabase:
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli) - Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker) - Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose) - Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
- Kubernetes with Helm [details](https://portabase.io/docs/dashboard/setup#helm) - Unraid Community Apps - [details](https://ca.unraid.net/apps/portabase-dashboard-1sdc97m05ufd7q) - [unraid support thread](https://forums.unraid.net/topic/199072-support-portabase-dashboard-agent-dockers/)
- Kubernetes with Helm - [details](https://portabase.io/docs/dashboard/setup#helm)
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development) - Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
**Ensure Docker is installed on your machine before getting started.** **Ensure Docker is installed on your machine before getting started.**
@@ -9,6 +9,7 @@ import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context"; import {BackupModalProvider} from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content"; import {DatabaseContent} from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck"; import {getHealthLast12hLogs} from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
projectId: string; projectId: string;
@@ -34,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
project: true, project: true,
retentionPolicy: true, retentionPolicy: true,
alertPolicies: true, alertPolicies: true,
storagePolicies: true storagePolicies: true,
} }
}); });
@@ -50,13 +51,17 @@ export default async function RoutePage(props: PageParams<{
with: { with: {
storageChannel: true storageChannel: true
} }
} },
logs: true
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], orderBy: (b, {desc}) => [desc(b.createdAt)],
}); });
const restorations = await db.query.restoration.findMany({ const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)], orderBy: (r, {desc}) => [desc(r.createdAt)],
}); });
@@ -84,7 +89,7 @@ export default async function RoutePage(props: PageParams<{
notFound(); notFound();
} }
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({ id: dbItem.id }) : [] const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
@@ -93,23 +98,25 @@ export default async function RoutePage(props: PageParams<{
return ( return (
<Page> <Page>
<BackupModalProvider> <LogsModalProvider>
<DatabaseContent <BackupModalProvider>
activeMember={activeMember} <DatabaseContent
settings={settings} activeMember={activeMember}
database={dbItem} settings={settings}
databaseHealthLogs={databaseHealthLogs} database={dbItem}
isAlreadyRestore={isAlreadyRestore} databaseHealthLogs={databaseHealthLogs}
restorations={restorations} isAlreadyRestore={isAlreadyRestore}
backups={backups} restorations={restorations}
totalBackups={totalBackups} backups={backups}
availableBackups={availableBackups} totalBackups={totalBackups}
successRate={successRate} availableBackups={availableBackups}
organizationId={organization.id} successRate={successRate}
activeOrganizationChannels={[]} organizationId={organization.id}
activeOrganizationStorageChannels={[]} activeOrganizationChannels={[]}
/> activeOrganizationStorageChannels={[]}
</BackupModalProvider> />
</BackupModalProvider>
</LogsModalProvider>
</Page> </Page>
); );
} }
@@ -85,7 +85,9 @@ export default async function RoutePage(props: PageParams<{
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? ( {proj.databases.length > 0 ? (
<CardsWithPagination <CardsWithPagination
data={proj.databases} data={[...proj.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
organizationSlug={organization.slug} organizationSlug={organization.slug}
// @ts-ignore // @ts-ignore
cardItem={ProjectDatabaseCard} cardItem={ProjectDatabaseCard}
+30 -1
View File
@@ -9,6 +9,7 @@ import {eventEmitter} from "@/lib/event";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {EventKind} from "@/features/notifications/notifications.types"; import {EventKind} from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/backup/route"}); const log = logger.child({module: "api/agent/backup/route"});
@@ -22,6 +23,8 @@ export type BodyPatch = {
status: "success" | "failed" status: "success" | "failed"
size: number size: number
generatedId: string generatedId: string
logs: JobLogEntry[]
durationMs: number
} }
export const POST = withAgentCheck(async (request: Request, {params, agent}: { export const POST = withAgentCheck(async (request: Request, {params, agent}: {
@@ -30,6 +33,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
}) => { }) => {
try { try {
const body: BodyPost = await request.json(); const body: BodyPost = await request.json();
const method = body.method const method = body.method
const database = await getDatabaseOrThrow(body.generatedId); const database = await getDatabaseOrThrow(body.generatedId);
@@ -127,12 +131,37 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
.update(drizzleDb.schemas.backup) .update(drizzleDb.schemas.backup)
.set(withUpdatedAt({ .set(withUpdatedAt({
status: status, status: status,
fileSize: backupSize fileSize: backupSize,
durationMs: body.durationMs
})) }))
.where(eq(drizzleDb.schemas.backup.id, backup.id)) .where(eq(drizzleDb.schemas.backup.id, backup.id))
.returning(); .returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: backup.id,
restorationId: null,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind); await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
+31 -5
View File
@@ -1,17 +1,20 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db as dbClient, db} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types";
const log = logger.child({module: "api/agent/restore"}); const log = logger.child({module: "api/agent/restore"});
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string
status: string status: string
logs: JobLogEntry[]
durationMs: number
} }
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
@@ -60,11 +63,34 @@ export async function POST(
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404}) return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
} }
const [restorationUpdated] = await db
await db
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus})) .set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore"); await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
+1
View File
@@ -92,6 +92,7 @@ ENV PORT=80
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PGDATA=/data/postgres ENV PGDATA=/data/postgres
ENV PRIVATE_PATH=/data/private ENV PRIVATE_PATH=/data/private
ENV TUSD_BEHIND_PROXY=false
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
+12 -1
View File
@@ -72,7 +72,18 @@ fi
mkdir -p /data/private/uploads/tmp mkdir -p /data/private/uploads/tmp
echo "▶ Starting tusd server..." echo "▶ Starting tusd server..."
tusd --base-path /tus/files/ --upload-dir /data/private/uploads/tmp --hooks-http http://127.0.0.1:3000/api/tus/hooks --port 1080 --max-size 21474836480 & TUSD_BEHIND_PROXY_FLAG=""
if [ "${TUSD_BEHIND_PROXY:-false}" = "true" ]; then
TUSD_BEHIND_PROXY_FLAG="--behind-proxy"
echo "[INFO] TUSD_BEHIND_PROXY=true, enabling tusd proxy mode"
fi
tusd \
--base-path /tus/files/ \
--upload-dir /data/private/uploads/tmp \
--hooks-http http://127.0.0.1:3000/api/tus/hooks \
--port 1080 \
--max-size 21474836480 \
$TUSD_BEHIND_PROXY_FLAG &
echo "▶ Starting Next.js server..." echo "▶ Starting Next.js server..."
PORT=3000 node server.js & PORT=3000 node server.js &
+7 -2
View File
@@ -1,6 +1,11 @@
events {} events {}
http { http {
map $http_x_forwarded_proto $forwarded_proto {
default $scheme;
"~*^\s*(https?)\s*(?:,|$)" $1;
}
client_max_body_size 20G; client_max_body_size 20G;
ignore_invalid_headers off; ignore_invalid_headers off;
@@ -18,7 +23,7 @@ http {
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -34,7 +39,7 @@ http {
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
} }
+2
View File
@@ -24,6 +24,8 @@ spec:
value: {{ .Values.project.url }} value: {{ .Values.project.url }}
- name: PROJECT_SECRET - name: PROJECT_SECRET
value: {{ .Values.project.secret }} value: {{ .Values.project.secret }}
- name: TUSD_BEHIND_PROXY
value: {{ .Values.project.behindProxy | quote }}
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /data mountPath: /data
+1
View File
@@ -23,6 +23,7 @@ timezone: Europe/Paris
project: project:
url: http://localhost:8887 url: http://localhost:8887
secret: "change_me_generate_secure_secret" secret: "change_me_generate_secure_secret"
behindProxy: false
persistence: persistence:
enabled: true enabled: true
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.17.3", "version": "1.18.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -139,5 +139,5 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"zenstack": "2.14.2" "zenstack": "2.14.2"
}, },
"packageManager": "pnpm@11.3.0" "packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916"
} }
+1794 -1343
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -17,6 +17,7 @@ import * as storagePolicy from "@/db/schema/13_storage-policy";
import * as backupStorage from "@/db/schema/14_storage-backup"; import * as backupStorage from "@/db/schema/14_storage-backup";
import * as healthcheckLog from "@/db/schema/15_healthcheck-log"; import * as healthcheckLog from "@/db/schema/15_healthcheck-log";
import * as apiKey from "@/db/schema/16_apikey"; import * as apiKey from "@/db/schema/16_apikey";
import * as jobLog from "@/db/schema/17_job-log";
const log = logger.child({module: "db"}); const log = logger.child({module: "db"});
@@ -53,7 +54,8 @@ export const schemas = {
...storagePolicy, ...storagePolicy,
...backupStorage, ...backupStorage,
...healthcheckLog, ...healthcheckLog,
...apiKey ...apiKey,
...jobLog
}; };
export const db = drizzle({ export const db = drizzle({
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "job_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"backup_id" uuid,
"restoration_id" uuid,
"updated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_backup_id_backups_id_fk" FOREIGN KEY ("backup_id") REFERENCES "public"."backups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "job_log" ADD CONSTRAINT "job_log_restoration_id_restorations_id_fk" FOREIGN KEY ("restoration_id") REFERENCES "public"."restorations"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,10 @@
CREATE TYPE "public"."job_log_entry_type" AS ENUM('log', 'command');--> statement-breakpoint
CREATE TYPE "public"."job_log_level" AS ENUM('debug', 'info', 'warn', 'error');--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "logged_at" timestamp with time zone NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "entry_type" "job_log_entry_type" NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "level" "job_log_level" NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "message" text NOT NULL;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "command" text;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "output" text;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "exit_code" integer;--> statement-breakpoint
ALTER TABLE "job_log" ADD COLUMN "duration_ms" bigint;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "backups" ADD COLUMN "duration_ms" bigint;
@@ -0,0 +1 @@
ALTER TABLE "restorations" ADD COLUMN "duration_ms" bigint;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -407,6 +407,34 @@
"when": 1779698258492, "when": 1779698258492,
"tag": "0057_cooing_nocturne", "tag": "0057_cooing_nocturne",
"breakpoints": true "breakpoints": true
},
{
"idx": 58,
"version": "7",
"when": 1780769342681,
"tag": "0058_slim_annihilus",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1780770010009,
"tag": "0059_past_fabian_cortez",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1780771191196,
"tag": "0060_shiny_sersi",
"breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1780822414802,
"tag": "0061_illegal_mole_man",
"breakpoints": true
} }
] ]
} }
+13 -2
View File
@@ -9,6 +9,7 @@ import {timestamps} from "@/db/schema/00_common";
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy"; import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy"; import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup"; import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
import {JobLog, jobLog} from "@/db/schema/17_job-log";
export const database = pgTable("databases", { export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -38,6 +39,7 @@ export const backup = pgTable(
status: statusEnum("status").default("waiting").notNull(), status: statusEnum("status").default("waiting").notNull(),
file: text("file"), file: text("file"),
fileSize: bigint("file_size", { mode: "number" }), fileSize: bigint("file_size", { mode: "number" }),
durationMs: bigint("duration_ms", { mode: "number" }),
databaseId: uuid("database_id") databaseId: uuid("database_id")
.notNull() .notNull()
.references(() => database.id, {onDelete: "cascade"}), .references(() => database.id, {onDelete: "cascade"}),
@@ -66,7 +68,7 @@ export const retentionPolicy = pgTable("retention_policies", {
export const restoration = pgTable("restorations", { export const restoration = pgTable("restorations", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
status: statusEnum("status").default("waiting").notNull(), status: statusEnum("status").default("waiting").notNull(),
durationMs: bigint("duration_ms", { mode: "number" }),
backupStorageId: uuid("backup_storage_id") backupStorageId: uuid("backup_storage_id")
.references(() => backupStorage.id, {onDelete: "cascade"}), .references(() => backupStorage.id, {onDelete: "cascade"}),
backupId: uuid("backup_id") backupId: uuid("backup_id")
@@ -94,12 +96,15 @@ export const backupRelations = relations(backup, ({one, many}) => ({
database: one(database, {fields: [backup.databaseId], references: [database.id]}), database: one(database, {fields: [backup.databaseId], references: [database.id]}),
restorations: many(restoration), restorations: many(restoration),
storages: many(backupStorage), storages: many(backupStorage),
logs: many(jobLog),
})); }));
export const restorationRelations = relations(restoration, ({one}) => ({ export const restorationRelations = relations(restoration, ({one, many}) => ({
backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}), backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}),
database: one(database, {fields: [restoration.databaseId], references: [database.id]}), database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}), backupStorage: one(backupStorage, {fields: [restoration.backupStorageId], references: [backupStorage.id]}),
logs: many(jobLog),
})); }));
@@ -137,6 +142,12 @@ export type DatabaseWith = Database & {
export type BackupWith = Backup & { export type BackupWith = Backup & {
restorations?: Restoration[] | null; restorations?: Restoration[] | null;
storages?: BackupStorage[] | null; storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
};
export type RestorationWith = Restoration & {
logs?: JobLog[] | null;
}; };
+66
View File
@@ -0,0 +1,66 @@
import {pgTable, uuid, text, integer, pgEnum, bigint, timestamp} from "drizzle-orm/pg-core";
import {timestamps} from "@/db/schema/00_common";
import {backup, restoration} from "@/db/schema/07_database";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
import {relations} from "drizzle-orm";
export const jobLogLevelEnum = pgEnum("job_log_level", [
"debug",
"info",
"warn",
"error",
]);
export const jobLogEntryTypeEnum = pgEnum("job_log_entry_type", [
"log",
"command",
]);
export const jobLog = pgTable(
"job_log",
{
id: uuid("id").primaryKey().defaultRandom(),
backupId: uuid("backup_id").references(() => backup.id, {
onDelete: "cascade",
}),
restorationId: uuid("restoration_id").references(() => restoration.id, {
onDelete: "cascade",
}),
loggedAt: timestamp("logged_at", {withTimezone: true}).notNull(),
entryType: jobLogEntryTypeEnum("entry_type").notNull(),
level: jobLogLevelEnum("level").notNull(),
message: text("message").notNull(),
command: text("command"),
output: text("output"),
exitCode: integer("exit_code"),
durationMs: bigint("duration_ms", {mode: "number"}),
...timestamps,
},
);
export const jobLogRelations = relations(jobLog, ({one}) => ({
backup: one(backup, {
fields: [jobLog.backupId],
references: [backup.id],
}),
restoration: one(restoration, {
fields: [jobLog.restorationId],
references: [restoration.id],
}),
}));
export const jobLogSchema = createSelectSchema(jobLog);
export type JobLog = z.infer<typeof jobLogSchema>;
+3 -1
View File
@@ -6,6 +6,7 @@ import packageJson from "../package.json" with {type: "json"};
const {version} = packageJson; const {version} = packageJson;
export const env = createEnv({ export const env = createEnv({
emptyStringAsUndefined: true,
server: { server: {
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(), NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
@@ -30,7 +31,7 @@ export const env = createEnv({
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
AUTH_DEFAULT_USER_NAME: z.string().optional(), AUTH_DEFAULT_USER_NAME: z.string().optional(),
AUTH_DEFAULT_USER: z.string().optional(), AUTH_DEFAULT_USER: z.email().optional(),
AUTH_DEFAULT_PASSWORD: z.string().optional(), AUTH_DEFAULT_PASSWORD: z.string().optional(),
@@ -199,3 +200,4 @@ export const env = createEnv({
}, },
}); });
+3 -1
View File
@@ -117,7 +117,9 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
<CardsWithPagination <CardsWithPagination
cardsPerPage={4} cardsPerPage={4}
numberOfColumns={2} numberOfColumns={2}
data={agent.databases} data={[...agent.databases].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)}
cardItem={AgentDatabaseCard} cardItem={AgentDatabaseCard}
/> />
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@
import {action, ActionError, userAction} from "@/lib/safe-actions/actions"; import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/features/agents/agents.schema"; import {AgentSchema} from "@/features/agents/agents.schema";
import {z} from "zod"; import {z} from "zod";
import {eq, and, ne, count} from "drizzle-orm"; import {eq, and, ne, count, desc} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify"; import {slugify} from "@/utils/slugify";
+20 -3
View File
@@ -2,23 +2,24 @@
import {ColumnDef} from "@tanstack/react-table"; import {ColumnDef} from "@tanstack/react-table";
import {StatusBadge} from "@/components/common/status-badge"; import {StatusBadge} from "@/components/common/status-badge";
import {Backup, DatabaseWith} from "@/db/schema/07_database"; import {BackupWith, DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {MemberWithUser} from "@/db/schema/03_organization"; import {MemberWithUser} from "@/db/schema/03_organization";
import {formatLocalizedDate} from "@/utils/date-formatting"; import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes} from "@/utils/text"; import {formatBytes, formatDuration} from "@/utils/text";
import {DatabaseActionsCell} from "@/features/database/backup-actions-cell"; import {DatabaseActionsCell} from "@/features/database/backup-actions-cell";
import { Badge as BadgeC } from "@/components/ui/badge"; import { Badge as BadgeC } from "@/components/ui/badge";
import {backupOnly} from "@/features/database/database-tabs"; import {backupOnly} from "@/features/database/database-tabs";
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
export function backupColumns( export function backupColumns(
isAlreadyRestore: boolean, isAlreadyRestore: boolean,
settings: Setting, settings: Setting,
database: DatabaseWith, database: DatabaseWith,
activeMember: MemberWithUser activeMember: MemberWithUser
): ColumnDef<Backup>[] { ): ColumnDef<BackupWith>[] {
const isBackupOnly = backupOnly.some((type) => database.dbms === type) const isBackupOnly = backupOnly.some((type) => database.dbms === type)
@@ -85,6 +86,14 @@ export function backupColumns(
return formatBytes(row.getValue("fileSize")) return formatBytes(row.getValue("fileSize"))
}, },
}, },
{
accessorKey: "durationMs",
header: "Duration",
cell: ({row}) => {
const durationMs = row.getValue("durationMs");
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
},
},
{ {
accessorKey: "createdAt", accessorKey: "createdAt",
header: "Created At", header: "Created At",
@@ -99,6 +108,14 @@ export function backupColumns(
return <StatusBadge status={row.getValue("status")}/>; return <StatusBadge status={row.getValue("status")}/>;
}, },
}, },
{
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
},
},
{ {
id: "actions", id: "actions",
cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>, cell: ({row}) => <DatabaseActionsCell isAlreadyRestore={isAlreadyRestore} activeMember={activeMember} backup={row.original} isBackupOnly={isBackupOnly}/>,
@@ -4,7 +4,7 @@ import {z} from "zod";
import {db} from "@/db"; import {db} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {BackupWith, Restoration} from "@/db/schema/07_database"; import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel"; import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {getHealthLast12hLogs} from "@/db/services/healthcheck"; import {getHealthLast12hLogs} from "@/db/services/healthcheck";
@@ -36,15 +36,19 @@ export const getDatabaseDataAction = userAction
with: { with: {
storageChannel: true storageChannel: true
} }
} },
logs: true
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[]; }) as BackupWith[];
const restorations = await db.query.restoration.findMany({ const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId), where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)], orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as Restoration[]; }) as RestorationWith[];
const totalBackups = backups.length; const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length; const availableBackups = backups.filter(b => !b.deletedAt).length;
+4 -2
View File
@@ -2,7 +2,7 @@
import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal"; import {DatabaseBackupActionsModal} from "@/features/database/backup-actions-modal";
import {DatabaseTabs} from "@/features/database/database-tabs"; import {DatabaseTabs} from "@/features/database/database-tabs";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database"; import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization"; import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/features/database/backup-modal-context"; import {useBackupModal} from "@/features/database/backup-modal-context";
import {DatabaseKpi} from "@/features/database/database-kpi"; import {DatabaseKpi} from "@/features/database/database-kpi";
@@ -23,11 +23,12 @@ import {BackupButton} from "@/features/database/backup-button";
import {HealthModal} from "@/features/database/health-modal"; import {HealthModal} from "@/features/database/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log"; import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {LogsModal} from "@/features/logs/logs-modal";
export type DatabaseContentProps = { export type DatabaseContentProps = {
settings: Setting; settings: Setting;
backups: BackupWith[]; backups: BackupWith[];
restorations: Restoration[]; restorations: RestorationWith[];
isAlreadyRestore: boolean; isAlreadyRestore: boolean;
database: DatabaseWith; database: DatabaseWith;
activeMember: MemberWithUser; activeMember: MemberWithUser;
@@ -156,6 +157,7 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups={stats.availableBackups} availableBackups={stats.availableBackups}
totalBackups={stats.totalBackups} totalBackups={stats.totalBackups}
/> />
<LogsModal/>
<DatabaseBackupActionsModal/> <DatabaseBackupActionsModal/>
<DatabaseTabs <DatabaseTabs
activeMember={props.activeMember} activeMember={props.activeMember}
+20 -2
View File
@@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Check, MoreHorizontal, Trash2, X } from "lucide-react"; import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
import { ReloadIcon } from "@radix-ui/react-icons"; import { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/common/status-badge"; import { StatusBadge } from "@/components/common/status-badge";
import { Restoration } from "@/db/schema/07_database"; import {Restoration, RestorationWith} from "@/db/schema/07_database";
import { formatLocalizedDate } from "@/utils/date-formatting"; import { formatLocalizedDate } from "@/utils/date-formatting";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -24,11 +24,13 @@ import { toast } from "sonner";
import { TooltipCustom } from "@/components/common/tooltip-custom"; import { TooltipCustom } from "@/components/common/tooltip-custom";
import { MemberWithUser } from "@/db/schema/03_organization"; import { MemberWithUser } from "@/db/schema/03_organization";
import { ButtonWithConfirm } from "@/components/common/button-with-confirm"; import { ButtonWithConfirm } from "@/components/common/button-with-confirm";
import {LogsModalTrigger} from "@/features/logs/logs-modal-trigger";
import {formatDuration} from "@/utils/text";
export function restoreColumns( export function restoreColumns(
isAlreadyRestore: boolean, isAlreadyRestore: boolean,
activeMember: MemberWithUser, activeMember: MemberWithUser,
): ColumnDef<Restoration>[] { ): ColumnDef<RestorationWith>[] {
return [ return [
{ {
accessorKey: "id", accessorKey: "id",
@@ -48,6 +50,22 @@ export function restoreColumns(
return <StatusBadge status={row.getValue("status")} />; return <StatusBadge status={row.getValue("status")} />;
}, },
}, },
{
accessorKey: "durationMs",
header: "Duration",
cell: ({row}) => {
const durationMs = row.getValue("durationMs");
return durationMs ? formatDuration(row.getValue("durationMs")) : "-"
},
},
{
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
},
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
+7 -2
View File
@@ -21,6 +21,9 @@ export const deleteRestoreAction = userAction
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))) .where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
.execute(); .execute();
await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
return { return {
success: true, success: true,
@@ -50,14 +53,16 @@ export const rerunRestorationAction = userAction
) )
.action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => { .action(async ({parsedInput}): Promise<ServerActionResult<Restoration>> => {
try { try {
const updateResult = await db const [updatedRestoration] = await db
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "waiting"}) .set({status: "waiting"})
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)) .where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
.returning() .returning()
.execute(); .execute();
const updatedRestoration = updateResult[0]; await db
.delete(drizzleDb.schemas.jobLog)
.where(eq(drizzleDb.schemas.jobLog.restorationId, parsedInput.restorationId));
if (!updatedRestoration) { if (!updatedRestoration) {
return { return {
+1 -1
View File
@@ -33,7 +33,7 @@ export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts,
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown"> <SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="size-6"> <Avatar className="size-6">
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback> <AvatarFallback>{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()}</AvatarFallback>
{user.image && <AvatarImage src={user.image} />} {user.image && <AvatarImage src={user.image} />}
</Avatar> </Avatar>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
+131
View File
@@ -0,0 +1,131 @@
"use client"
import { useState } from "react"
import { Minus, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { LevelType } from "@/features/logs/types"
import { JobLog } from "@/db/schema/17_job-log"
import { formatLocalizedDate } from "@/utils/date-formatting"
import { formatDuration } from "@/utils/text"
const levelLabel: Record<LevelType, string> = {
info: "Info",
debug: "Debug",
error: "Error",
warn: "Warning",
}
function TypeBadge({ entry }: { entry: JobLog }) {
const isCommand = entry.entryType === "command"
const label = isCommand ? "Command" : levelLabel[entry.level]
const styles = isCommand
? "border-transparent bg-secondary text-secondary-foreground"
: entry.level === "error"
? "border-transparent bg-destructive/20 text-destructive"
: entry.level === "warn"
? "border-transparent bg-amber-500/20 text-amber-600 dark:text-amber-300"
: entry.level === "debug"
? "border-transparent bg-muted text-muted-foreground"
: "border-transparent bg-sky-500/20 text-sky-600 dark:text-sky-300"
return (
<Badge variant="outline" className={cn("rounded-full", styles)}>
{label}
</Badge>
)
}
export function LogRow({ entry }: { entry: JobLog }) {
const [open, setOpen] = useState(true)
const isCommand = entry.entryType === "command"
const date = formatLocalizedDate(entry.loggedAt)
const accent =
entry.level === "error"
? "bg-destructive"
: entry.level === "warn"
? "bg-amber-500"
: isCommand
? "bg-emerald-500"
: "bg-sky-500"
return (
<div className="relative border-b border-border last:border-b-0">
<div className={cn("absolute left-0 top-0 h-full w-[3px]", accent)} aria-hidden="true" />
<div className="flex flex-col gap-3 py-4 pl-6 pr-4 sm:flex-row sm:items-start sm:gap-4 sm:pr-6">
<span className="shrink-0 font-mono text-sm text-muted-foreground sm:w-[130px] sm:pt-0.5">
{date}
</span>
<span className="shrink-0 sm:w-[90px] sm:pt-0.5">
<TypeBadge entry={entry} />
</span>
<div className="min-w-0 flex-1">
{isCommand ? (
<div className="flex items-start gap-3">
<code className="block flex-1 truncate rounded-md bg-muted px-3 py-1.5 font-mono text-sm text-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
{entry.message}
</code>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "Collapse command output" : "Expand command output"}
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{open ? <Minus className="size-4" /> : <Plus className="size-4" />}
</button>
</div>
) : (
<p className="pt-0.5 text-sm text-foreground">{entry.message}</p>
)}
{isCommand && entry.command && open && (
<div className="mt-4">
<div className="overflow-hidden rounded-xl bg-muted ring-1 ring-border">
<div className="max-h-[15rem] overflow-auto px-4 py-3.5">
<code className="block whitespace-pre font-mono text-sm leading-relaxed text-foreground">
<span className="text-emerald-600 dark:text-emerald-400">$ </span>
{entry.command}
</code>
{entry.output ? (
<pre className="mt-3 whitespace-pre font-mono text-sm leading-relaxed text-muted-foreground">
{entry.output}
</pre>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm sm:gap-6">
{entry.exitCode !== undefined && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Exit code:</span>
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
entry.exitCode === 0
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-300"
: "bg-destructive/20 text-destructive",
)}
>
{entry.exitCode}
</span>
</div>
)}
{entry.durationMs !== null && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Duration:</span>
<span className="font-medium text-foreground">{formatDuration(entry.durationMs)}</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import {createContext, useContext, useState, ReactNode} from "react";
import {JobLog} from "@/db/schema/17_job-log";
type LogsModalContextType = {
open: boolean;
logs: JobLog[];
openModal: (logs: JobLog[]) => void;
closeModal: () => void;
};
const LogsModalContext = createContext<LogsModalContextType | undefined>(undefined);
export const LogsModalProvider = ({children}: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
const [logs, setLogs] = useState<JobLog[]>([]);
const openModal = (newLogs: JobLog[]) => {
setLogs(newLogs);
setOpen(true);
};
const closeModal = () => {
setOpen(false);
setLogs([]);
};
return (
<LogsModalContext.Provider value={{open, logs, openModal, closeModal}}>
{children}
</LogsModalContext.Provider>
);
};
export const useLogsModal = () => {
const context = useContext(LogsModalContext);
if (!context) throw new Error("useLogsModal must be used within LogsModalProvider");
return context;
};
+19
View File
@@ -0,0 +1,19 @@
import {FileText} from "lucide-react";
import {JobLog} from "@/db/schema/17_job-log";
import {Button} from "@/components/ui/button";
import {useLogsModal} from "@/features/logs/logs-modal-context";
export type LogsModalTriggerProps = {
logs: JobLog[]
}
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
const {openModal} = useLogsModal();
return (
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
openModal(logs);
}}>
<FileText />
</Button>
)
}
+41
View File
@@ -0,0 +1,41 @@
"use client"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useLogsModal } from "@/features/logs/logs-modal-context"
import { JobLog } from "@/db/schema/17_job-log"
import { LogRow } from "@/features/logs/log-row-modal"
export const LogsModal = () => {
const { open, logs, closeModal } = useLogsModal()
return (
<Dialog open={open} onOpenChange={closeModal}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-6xl">
<DialogHeader className="shrink-0 border-b border-border px-6 py-5">
<DialogTitle className="text-xl font-bold tracking-tight text-foreground">
Job Logs
</DialogTitle>
</DialogHeader>
<div className="hidden shrink-0 items-center gap-4 border-b border-border bg-card py-3 pl-6 pr-4 sm:flex">
<span className="w-[130px] text-sm font-semibold text-foreground">Date</span>
<span className="w-[90px] text-sm font-semibold text-foreground">Type</span>
<span className="flex-1 text-sm font-semibold text-foreground">Message</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}
+14
View File
@@ -0,0 +1,14 @@
export type LevelType = "info" | "debug" | "error" | "warn";
export type EntryType = "log" | "command";
export interface JobLogEntry {
timestamp: string
type: EntryType
level: LevelType
message: string
command?: string
output?: string
exit_code?: number
duration_ms?: number
}
+1 -1
View File
@@ -72,7 +72,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20"> <Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined}/> <AvatarImage className="object-cover" src={user.image || undefined}/>
<AvatarFallback className="text-3xl">{user.name.charAt(0).toUpperCase()}</AvatarFallback> <AvatarFallback className="text-3xl">{(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<div <div
+1 -1
View File
@@ -2,7 +2,7 @@ import z from "zod";
import {zString} from "@/lib/zod"; import {zString} from "@/lib/zod";
export const ProfileSchema = z.object({ export const ProfileSchema = z.object({
name: zString().nonempty(), name: zString().trim().nonempty(),
role: zString().nonempty(), role: zString().nonempty(),
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ import {userAction} from "@/lib/safe-actions/actions";
import {ApiKey} from "@better-auth/api-key"; import {ApiKey} from "@better-auth/api-key";
const UpdateProfileSchema = z.object({ const UpdateProfileSchema = z.object({
name: z.string().optional(), name: z.string().trim().nonempty().optional(),
}); });
export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => { export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
-1
View File
@@ -1,5 +1,4 @@
import {formatDistanceToNow} from "date-fns"; import {formatDistanceToNow} from "date-fns";
import {format} from "date-fns";
/** /**
* Get user's locale and timezone from the browser * Get user's locale and timezone from the browser
+41
View File
@@ -25,4 +25,45 @@ export function formatBytes(bytes: number | null, decimals = 2): string {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function formatDuration(ms: number): string {
if (ms == null || Number.isNaN(ms)) return "0 ms";
const totalMs = Math.max(0, Math.floor(ms));
if (totalMs < 1000) {
return `${totalMs} ms`;
}
const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60;
if (totalSeconds < 60) {
return `${totalSeconds} s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
if (totalMinutes < 60) {
return seconds > 0
? `${totalMinutes} min ${seconds} s`
: `${totalMinutes} min`;
}
const totalHours = Math.floor(totalMinutes / 60);
const hours = totalHours % 24;
if (totalHours < 24) {
return minutes > 0
? `${totalHours} h ${minutes} min`
: `${totalHours} h`;
}
const days = Math.floor(totalHours / 24);
return hours > 0
? `${days} d ${hours} h`
: `${days} d`;
} }
+2 -1
View File
@@ -33,7 +33,8 @@
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts",
".next/dev/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"